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 mcp_servers: &[McpServerLaunch],
59 resume: bool,
60 ) -> Result<Box<dyn RuntimeConnection>> {
61 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
62 if !mcp_servers.is_empty() {
67 let file = mcp_config_file("pi", &runtime_id, mcp_servers).await?;
68 launch.arguments.extend([
69 "--extension".into(),
70 std::env::var(PI_MCP_ADAPTER_ENV)
71 .ok()
72 .filter(|value| !value.is_empty())
73 .unwrap_or_else(|| PI_MCP_ADAPTER.into()),
74 "--mcp-config".into(),
75 file.to_string_lossy().into_owned(),
76 ]);
77 }
78 if resume {
79 launch
80 .arguments
81 .extend(["--session".into(), runtime_id.clone()]);
82 } else {
83 launch
84 .arguments
85 .extend(["--session-id".into(), runtime_id.clone()]);
86 }
87 let transport = RawLineTransport::spawn(&launch, Some(cwd), "pi-rpc-jsonl").await?;
88 let handle = RuntimeHandle {
89 harness: HarnessId::from(HarnessId::PI),
90 runtime_id,
91 endpoint: transport.endpoint.clone(),
92 };
93 Ok(Box::new(PiRuntimeConnection {
94 handle,
95 transport,
96 next_request: 1,
97 }))
98 }
99}
100
101#[async_trait]
102impl RuntimeBackend for PiRuntimeBackend {
103 fn harness(&self) -> HarnessId {
104 HarnessId::from(HarnessId::PI)
105 }
106
107 fn capabilities(&self) -> RuntimeCapabilities {
108 RuntimeCapabilities {
109 start_session: true,
110 resume_session: true,
111 attach_existing_process: false,
112 send_input: true,
113 stream_events: true,
114 interrupt: true,
115 steer: false,
116 respond_to_requests: true,
117 }
118 }
119
120 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
121 self.open(
122 &request.cwd,
123 generated_session_id(),
124 request.launch,
125 &request.mcp_servers,
126 false,
127 )
128 .await
129 }
130
131 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
132 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
133 self.open(
134 &cwd,
135 request.runtime_id,
136 request.launch,
137 &request.mcp_servers,
138 true,
139 )
140 .await
141 }
142}
143
144const PI_MCP_ADAPTER: &str = "npm:pi-mcp-adapter@2.37.0";
147const PI_MCP_ADAPTER_ENV: &str = "SUPERCODE_PI_MCP_ADAPTER";
151
152struct PiRuntimeConnection {
153 handle: RuntimeHandle,
154 transport: RawLineTransport,
155 next_request: u64,
156}
157
158#[async_trait]
159impl RuntimeConnection for PiRuntimeConnection {
160 fn handle(&self) -> &RuntimeHandle {
161 &self.handle
162 }
163
164 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
165 if !input.image_urls.is_empty() {
166 return Err(Error::Other(
167 "Pi RPC image input is not verified by the installed protocol contract".into(),
168 ));
169 }
170 let id = format!("supercode-{}", self.next_request);
171 self.next_request += 1;
172 self.transport
173 .write(json!({"id": id, "type": "prompt", "message": input.text}))
174 .await?;
175 Ok(Some(id))
176 }
177
178 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
179 raw_next_event(&mut self.transport.receiver).await
180 }
181
182 async fn interrupt(&mut self) -> Result<()> {
183 self.transport.write(json!({"type": "abort"})).await
184 }
185
186 async fn respond(&mut self, request_id: Value, mut response: Value) -> Result<()> {
187 if let Value::Object(object) = &mut response {
188 object.entry("id").or_insert(request_id);
189 self.transport.write(response).await
190 } else {
191 self.transport
192 .write(json!({"id": request_id, "response": response}))
193 .await
194 }
195 }
196
197 async fn close(&mut self) -> Result<()> {
198 self.transport.close().await
199 }
200}
201
202#[derive(Debug, Clone)]
209pub struct ClaudeCodeRuntimeBackend {
210 launch: RuntimeLaunch,
211 permission_timeout: Duration,
212}
213
214impl Default for ClaudeCodeRuntimeBackend {
215 fn default() -> Self {
216 Self::new()
217 }
218}
219
220impl ClaudeCodeRuntimeBackend {
221 pub fn new() -> Self {
224 Self {
225 launch: RuntimeLaunch {
226 program: "claude".into(),
227 arguments: vec![
228 "--print".into(),
229 "--input-format".into(),
230 "stream-json".into(),
231 "--output-format".into(),
232 "stream-json".into(),
233 "--verbose".into(),
234 "--permission-prompt-tool".into(),
243 "stdio".into(),
244 ],
245 env: BTreeMap::new(),
246 },
247 permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
248 }
249 }
250
251 pub fn launch(&self) -> &RuntimeLaunch {
256 &self.launch
257 }
258
259 pub fn with_launch(launch: RuntimeLaunch) -> Self {
261 Self {
262 launch,
263 permission_timeout: CLAUDE_PERMISSION_RESPONSE_TIMEOUT,
264 }
265 }
266
267 pub fn with_permission_timeout(mut self, timeout: Duration) -> Self {
273 self.permission_timeout = timeout;
274 self
275 }
276
277 async fn open(
278 &self,
279 cwd: &Path,
280 runtime_id: String,
281 launch: Option<RuntimeLaunch>,
282 mcp_servers: &[McpServerLaunch],
283 resume: bool,
284 ) -> Result<Box<dyn RuntimeConnection>> {
285 let mut prefix = launch.unwrap_or_else(|| self.launch.clone());
289 if !mcp_servers.is_empty() {
293 let file = mcp_config_file("claude", &runtime_id, mcp_servers).await?;
294 prefix
295 .arguments
296 .extend(["--mcp-config".into(), file.to_string_lossy().into_owned()]);
297 }
298 let mut launch = prefix.clone();
299 launch.arguments.extend(if resume {
300 vec!["--resume".into(), runtime_id.clone()]
301 } else {
302 vec!["--session-id".into(), runtime_id.clone()]
303 });
304 let transport = RawLineTransport::spawn(&launch, Some(cwd), "claude-stream-json").await?;
305 Ok(Box::new(ClaudeRuntimeConnection {
306 handle: RuntimeHandle {
307 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
308 runtime_id,
309 endpoint: transport.endpoint.clone(),
310 },
311 transport,
312 prefix,
313 cwd: cwd.to_path_buf(),
314 spoke: false,
315 buffered_events: VecDeque::new(),
316 next_control_request: 1,
317 control_timeout: CLAUDE_CONTROL_RESPONSE_TIMEOUT,
318 pending_permissions: Vec::new(),
319 permission_timeout: self.permission_timeout,
320 mounted_mcp_servers: mcp_servers
321 .iter()
322 .map(|server| server.name.clone())
323 .collect(),
324 }))
325 }
326}
327
328const CLAUDE_CONTROL_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
338
339pub const CLAUDE_PERMISSION_RESPONSE_TIMEOUT: Duration = Duration::from_secs(300);
352
353const CLAUDE_PERMISSION_BEHAVIORS: [&str; 2] = ["allow", "deny"];
360
361const CLAUDE_PERMISSION_TIMEOUT_MESSAGE: &str =
363 "supercode denied this permission request: no answer arrived before the adapter's \
364 permission timeout elapsed";
365
366#[async_trait]
367impl RuntimeBackend for ClaudeCodeRuntimeBackend {
368 fn harness(&self) -> HarnessId {
369 HarnessId::from(HarnessId::CLAUDE_CODE)
370 }
371
372 fn capabilities(&self) -> RuntimeCapabilities {
373 RuntimeCapabilities {
374 start_session: true,
375 resume_session: true,
376 attach_existing_process: false,
377 send_input: true,
378 stream_events: true,
379 interrupt: true,
380 steer: true,
381 respond_to_requests: true,
382 }
383 }
384
385 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
386 self.open(
387 &request.cwd,
388 generated_session_id(),
389 request.launch,
390 &request.mcp_servers,
391 false,
392 )
393 .await
394 }
395
396 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
397 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
398 self.open(
399 &cwd,
400 request.runtime_id,
401 request.launch,
402 &request.mcp_servers,
403 true,
404 )
405 .await
406 }
407}
408
409async fn mcp_config_file(
413 harness: &str,
414 runtime_id: &str,
415 servers: &[McpServerLaunch],
416) -> Result<PathBuf> {
417 let mut entries = serde_json::Map::new();
418 for server in servers {
419 entries.insert(
420 server.name.clone(),
421 json!({
422 "type": "stdio",
423 "command": server.command,
424 "args": server.arguments,
425 "env": server.env,
426 }),
427 );
428 }
429 let safe: String = runtime_id
430 .chars()
431 .filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
432 .collect();
433 let path = std::env::temp_dir().join(format!("supercode-{harness}-mcp-{safe}.json"));
434 let body = serde_json::to_vec_pretty(&json!({ "mcpServers": entries })).map_err(|error| {
435 Error::Other(format!(
436 "{harness} mcp config could not be encoded: {error}"
437 ))
438 })?;
439 tokio::fs::write(&path, body).await.map_err(|error| {
440 Error::Other(format!(
441 "{harness} mcp config could not be written: {error}"
442 ))
443 })?;
444 #[cfg(unix)]
445 {
446 use std::os::unix::fs::PermissionsExt;
447 tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
448 .await
449 .map_err(|error| {
450 Error::Other(format!(
451 "{harness} mcp config could not be protected: {error}"
452 ))
453 })?;
454 }
455 Ok(path)
456}
457
458struct ClaudeRuntimeConnection {
459 handle: RuntimeHandle,
460 transport: RawLineTransport,
461 prefix: RuntimeLaunch,
465 cwd: PathBuf,
469 spoke: bool,
474 buffered_events: VecDeque<Value>,
478 next_control_request: u64,
479 control_timeout: Duration,
480 pending_permissions: Vec<PendingPermission>,
484 permission_timeout: Duration,
485 mounted_mcp_servers: Vec<String>,
489}
490
491struct PendingPermission {
493 request_id: String,
495 deadline: tokio::time::Instant,
497}
498
499impl ClaudeRuntimeConnection {
500 async fn process_ended(&self) -> bool {
507 matches!(self.transport.child.lock().await.try_wait(), Ok(Some(_)))
508 }
509
510 async fn reopen(&mut self) -> Result<()> {
520 let mut launch = self.prefix.clone();
521 launch
522 .arguments
523 .extend(["--resume".into(), self.handle.runtime_id.clone()]);
524 let transport = RawLineTransport::spawn(&launch, Some(&self.cwd), "claude-stream-json")
525 .await
526 .map_err(|error| {
527 Error::Other(format!(
528 "could not resume Claude Code session `{}` after its process exited: {error}",
529 self.handle.runtime_id
530 ))
531 })?;
532 self.handle.endpoint = transport.endpoint.clone();
533 self.transport = transport;
534 self.pending_permissions.clear();
537 self.spoke = false;
538 Ok(())
539 }
540
541 async fn write_turn(&mut self, frame: Value) -> Result<()> {
549 if self.process_ended().await {
550 self.reopen().await?;
551 }
552 match self.transport.write(frame.clone()).await {
553 Ok(()) => Ok(()),
554 Err(error) if broken_pipe(&error) => {
555 self.reopen().await?;
556 self.transport.write(frame).await
557 }
558 Err(error) => Err(error),
559 }
560 }
561
562 fn is_control_response(value: &Value) -> bool {
567 value.get("type").and_then(Value::as_str) == Some("control_response")
568 }
569
570 fn control_result(value: &Value, request_id: &str) -> Option<Result<()>> {
578 let response = value.get("response")?;
579 if response.get("request_id").and_then(Value::as_str) != Some(request_id) {
580 return None;
581 }
582 match response.get("subtype").and_then(Value::as_str) {
583 Some("success") => Some(Ok(())),
584 other => Some(Err(Error::Other(format!(
585 "Claude Code rejected the interrupt control request: {}",
586 response
587 .get("error")
588 .and_then(Value::as_str)
589 .map(str::to_string)
590 .unwrap_or_else(|| format!(
591 "control_response subtype {}",
592 other.unwrap_or("(missing)")
593 ))
594 )))),
595 }
596 }
597
598 fn permission_request_id(value: &Value) -> Option<&str> {
605 if value.get("type").and_then(Value::as_str)? != "control_request" {
606 return None;
607 }
608 let request = value.get("request")?;
609 if request.get("subtype").and_then(Value::as_str)? != "can_use_tool" {
610 return None;
611 }
612 value.get("request_id").and_then(Value::as_str)
613 }
614
615 fn mounted_tool_permission_request(&self, payload: &Value) -> Option<String> {
619 let request_id = Self::permission_request_id(payload)?;
620 let tool = payload
621 .get("request")?
622 .get("tool_name")
623 .and_then(Value::as_str)?;
624 let mounted = self.mounted_mcp_servers.iter().any(|name| {
625 tool.strip_prefix("mcp__")
626 .and_then(|rest| rest.strip_prefix(name.as_str()))
627 .is_some_and(|rest| rest.starts_with("__"))
628 });
629 mounted.then(|| request_id.to_string())
630 }
631
632 fn note_permission_request(&mut self, payload: &Value) {
634 let Some(request_id) = Self::permission_request_id(payload) else {
635 return;
636 };
637 if self
638 .pending_permissions
639 .iter()
640 .any(|pending| pending.request_id == request_id)
641 {
642 return;
643 }
644 self.pending_permissions.push(PendingPermission {
645 request_id: request_id.to_string(),
646 deadline: tokio::time::Instant::now() + self.permission_timeout,
647 });
648 }
649
650 async fn write_permission_response(&mut self, request_id: &str, body: Value) -> Result<()> {
652 self.transport
653 .write(json!({
654 "type": "control_response",
655 "response": {
656 "subtype": "success",
657 "request_id": request_id,
658 "response": body,
659 },
660 }))
661 .await
662 }
663
664 async fn deny_expired_permissions(&mut self) -> Result<()> {
669 let now = tokio::time::Instant::now();
670 let expired = self
671 .pending_permissions
672 .iter()
673 .filter(|pending| pending.deadline <= now)
674 .map(|pending| pending.request_id.clone())
675 .collect::<Vec<_>>();
676 self.pending_permissions
677 .retain(|pending| pending.deadline > now);
678 for request_id in expired {
679 self.write_permission_response(
680 &request_id,
681 json!({"behavior": "deny", "message": CLAUDE_PERMISSION_TIMEOUT_MESSAGE}),
682 )
683 .await?;
684 }
685 Ok(())
686 }
687
688 async fn transport_ended(&mut self) -> Result<Option<HarnessEvent>> {
705 if !self.spoke {
706 return Ok(None);
707 }
708 std::future::pending().await
709 }
710
711 fn next_permission_deadline(&self) -> Option<Duration> {
713 let now = tokio::time::Instant::now();
714 self.pending_permissions
715 .iter()
716 .map(|pending| pending.deadline.saturating_duration_since(now))
717 .min()
718 }
719}
720
721fn claude_permission_result(response: Value) -> Result<Value> {
731 let Value::Object(mut body) = response else {
732 return Err(claude_permission_shape_error(&response));
733 };
734 match body.get("behavior").and_then(Value::as_str) {
735 Some("allow") => {}
736 Some("deny") => {
737 let empty = body
739 .get("message")
740 .and_then(Value::as_str)
741 .is_none_or(str::is_empty);
742 if empty {
743 body.insert(
744 "message".into(),
745 Value::String("supercode denied this permission request".into()),
746 );
747 }
748 }
749 _ => return Err(claude_permission_shape_error(&Value::Object(body))),
750 }
751 Ok(Value::Object(body))
752}
753
754fn claude_permission_shape_error(response: &Value) -> Error {
755 Error::Other(format!(
756 "Claude Code permission answers must carry a `behavior` of {}; got {response}",
757 CLAUDE_PERMISSION_BEHAVIORS
758 .map(|behavior| format!("`{behavior}`"))
759 .join(" or "),
760 ))
761}
762
763#[async_trait]
764impl RuntimeConnection for ClaudeRuntimeConnection {
765 fn handle(&self) -> &RuntimeHandle {
766 &self.handle
767 }
768
769 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
770 let content = if input.image_urls.is_empty() {
771 Value::String(input.text)
772 } else {
773 let mut parts = Vec::new();
774 if !input.text.is_empty() {
775 parts.push(json!({"type":"text", "text":input.text}));
776 }
777 for url in input.image_urls {
778 parts.push(claude_image_part(&url)?);
779 }
780 Value::Array(parts)
781 };
782 self.write_turn(json!({
783 "type": "user",
784 "session_id": self.handle.runtime_id,
785 "message": {"role": "user", "content": content},
786 }))
787 .await?;
788 Ok(None)
789 }
790
791 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
792 if let Some(payload) = self.buffered_events.pop_front() {
793 self.spoke = true;
794 return Ok(Some(harness_event(payload)));
795 }
796 loop {
797 self.deny_expired_permissions().await?;
801 let payload = match self.next_permission_deadline() {
802 Some(remaining) => {
803 match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
804 Err(_) => continue,
805 Ok(None) => return self.transport_ended().await,
806 Ok(Some(payload)) => payload,
807 }
808 }
809 None => match self.transport.receiver.recv().await {
810 None => return self.transport_ended().await,
811 Some(payload) => payload,
812 },
813 };
814 self.spoke = true;
815 if Self::is_control_response(&payload) {
816 continue;
817 }
818 if let Some(request_id) = self.mounted_tool_permission_request(&payload) {
819 self.write_permission_response(&request_id, json!({"behavior": "allow"}))
820 .await?;
821 continue;
822 }
823 self.note_permission_request(&payload);
824 return Ok(Some(harness_event(payload)));
825 }
826 }
827
828 async fn interrupt(&mut self) -> Result<()> {
837 if self.process_ended().await {
841 return Ok(());
842 }
843 let request_id = format!(
844 "supercode-{}-interrupt-{}",
845 self.handle.runtime_id, self.next_control_request
846 );
847 self.next_control_request += 1;
848 self.transport
849 .write(json!({
850 "type": "control_request",
851 "request_id": request_id,
852 "request": {"subtype": "interrupt"},
853 }))
854 .await?;
855
856 let deadline = tokio::time::Instant::now() + self.control_timeout;
857 loop {
858 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
859 if remaining.is_zero() {
860 return Err(claude_interrupt_timeout(self.control_timeout));
861 }
862 match tokio::time::timeout(remaining, self.transport.receiver.recv()).await {
863 Err(_) => return Err(claude_interrupt_timeout(self.control_timeout)),
864 Ok(None) => return Err(Error::Other(
865 "Claude Code stream-json transport closed before acknowledging the interrupt"
866 .into(),
867 )),
868 Ok(Some(payload)) => {
869 if Self::is_control_response(&payload) {
870 if let Some(result) = Self::control_result(&payload, &request_id) {
871 return result;
872 }
873 continue;
874 }
875 if let Some(request_id) = self.mounted_tool_permission_request(&payload) {
879 self.write_permission_response(&request_id, json!({"behavior": "allow"}))
880 .await?;
881 continue;
882 }
883 self.note_permission_request(&payload);
884 self.buffered_events.push_back(payload);
885 }
886 }
887 }
888 }
889
890 async fn steer(&mut self, text: String) -> Result<()> {
891 self.send_input(RuntimeInput {
892 text,
893 image_urls: Vec::new(),
894 })
895 .await
896 .map(|_| ())
897 }
898
899 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
908 let Some(request_id) = request_id.as_str().map(str::to_string) else {
909 return Err(Error::Other(format!(
910 "Claude Code control requests are identified by a string `request_id`; got \
911 {request_id}"
912 )));
913 };
914 let Some(index) = self
915 .pending_permissions
916 .iter()
917 .position(|pending| pending.request_id == request_id)
918 else {
919 return Err(Error::Other(format!(
920 "no Claude Code permission request `{request_id}` is waiting on this connection — \
921 a `can_use_tool` request is answerable only while its turn is blocked on it, and \
922 only until it is answered or denied on timeout"
923 )));
924 };
925 let body = claude_permission_result(response)?;
926 self.pending_permissions.remove(index);
927 self.write_permission_response(&request_id, body).await
928 }
929
930 async fn close(&mut self) -> Result<()> {
931 self.transport.close().await
932 }
933}
934
935#[derive(Debug, Clone)]
937pub struct AcpRuntimeBackend {
938 harness: HarnessId,
939 launch: RuntimeLaunch,
940 resume_session: bool,
941}
942
943impl AcpRuntimeBackend {
944 pub fn new(harness: HarnessId, launch: RuntimeLaunch) -> Self {
946 Self {
947 harness,
948 launch,
949 resume_session: false,
950 }
951 }
952
953 pub fn with_resume_support(mut self, supported: bool) -> Self {
957 self.resume_session = supported;
958 self
959 }
960
961 async fn connect(
962 &self,
963 cwd: &Path,
964 launch: Option<RuntimeLaunch>,
965 ) -> Result<(
966 Arc<JsonLineClient>,
967 mpsc::UnboundedReceiver<Value>,
968 RuntimeEndpoint,
969 Value,
970 )> {
971 let launch = launch.unwrap_or_else(|| self.launch.clone());
972 let (client, receiver, endpoint) =
973 JsonLineClient::spawn(&launch, Some(cwd), true, "acp-v1-jsonrpc").await?;
974 let initialized = client
975 .request(
976 "initialize",
977 json!({
978 "protocolVersion": 1,
979 "clientCapabilities": {},
980 "clientInfo": {
981 "name": "supercode",
982 "title": "Supercode",
983 "version": env!("CARGO_PKG_VERSION"),
984 },
985 }),
986 )
987 .await?;
988 if initialized.get("protocolVersion").and_then(Value::as_u64) != Some(1) {
989 return Err(Error::Other(format!(
990 "ACP agent negotiated unsupported protocol version: {}",
991 initialized
992 .get("protocolVersion")
993 .cloned()
994 .unwrap_or(Value::Null)
995 )));
996 }
997 Ok((client, receiver, endpoint, initialized))
998 }
999
1000 async fn session_request(
1001 &self,
1002 client: &JsonLineClient,
1003 initialized: &Value,
1004 method: &str,
1005 params: Value,
1006 ) -> Result<Value> {
1007 match client.request(method, params.clone()).await {
1008 Ok(response) => Ok(response),
1009 Err(error) if acp_auth_required(&error.to_string()) => {
1010 let cached = initialized
1011 .get("authMethods")
1012 .and_then(Value::as_array)
1013 .and_then(|methods| {
1014 methods.iter().find_map(|candidate| {
1015 (candidate.get("id").and_then(Value::as_str) == Some("cached_token"))
1016 .then_some("cached_token")
1017 })
1018 });
1019 let Some(method_id) = cached else {
1020 return Err(Error::Other(
1021 "ACP agent requires authentication but did not advertise the non-interactive `cached_token` method"
1022 .into(),
1023 ));
1024 };
1025 client
1026 .request(
1027 "authenticate",
1028 json!({"methodId": method_id, "_meta": {"headless": true}}),
1029 )
1030 .await?;
1031 client.request(method, params).await
1032 }
1033 Err(error) => Err(error),
1034 }
1035 }
1036
1037 async fn connection(
1038 &self,
1039 cwd: &Path,
1040 runtime_id: Option<String>,
1041 launch: Option<RuntimeLaunch>,
1042 mcp_servers: Vec<McpServerLaunch>,
1043 ) -> Result<Box<dyn RuntimeConnection>> {
1044 let mcp_servers = acp_mcp_servers(&mcp_servers);
1045 let (launch, runtime_id) = match runtime_id {
1052 Some(session_id) if self.harness.as_str() == HarnessId::SUPERCODE => {
1053 let mut continuation = launch.unwrap_or_else(|| self.launch.clone());
1054 let flags = continuation
1055 .arguments
1056 .iter()
1057 .skip_while(|argument| argument.as_str() != "acp")
1058 .skip(1)
1059 .cloned()
1060 .collect::<Vec<_>>();
1061 continuation.arguments = ["resume", &session_id, "--harness", "supercode", "--acp"]
1062 .into_iter()
1063 .map(String::from)
1064 .chain(flags)
1065 .collect();
1066 (Some(continuation), None)
1067 }
1068 other => (launch, other),
1069 };
1070 let (client, mut receiver, endpoint, initialized) = self.connect(cwd, launch).await?;
1071 let session_id = if let Some(session_id) = runtime_id {
1072 let resume = initialized
1073 .pointer("/agentCapabilities/sessionCapabilities/resume")
1074 .is_some();
1075 let load = initialized
1076 .pointer("/agentCapabilities/loadSession")
1077 .and_then(Value::as_bool)
1078 .unwrap_or(false);
1079 let method = if resume {
1080 "session/resume"
1081 } else if load {
1082 "session/load"
1083 } else {
1084 return Err(Error::Other(
1085 "ACP agent did not advertise session resume or load".into(),
1086 ));
1087 };
1088 self.session_request(
1089 client.as_ref(),
1090 &initialized,
1091 method,
1092 json!({"sessionId": session_id, "cwd": cwd, "mcpServers": mcp_servers}),
1093 )
1094 .await?;
1095 session_id
1096 } else {
1097 self.session_request(
1098 client.as_ref(),
1099 &initialized,
1100 "session/new",
1101 json!({"cwd": cwd, "mcpServers": mcp_servers}),
1102 )
1103 .await?
1104 .get("sessionId")
1105 .and_then(Value::as_str)
1106 .ok_or_else(|| Error::Other("ACP session/new omitted sessionId".into()))?
1107 .to_string()
1108 };
1109 while receiver.try_recv().is_ok() {}
1118 Ok(Box::new(AcpRuntimeConnection {
1119 handle: RuntimeHandle {
1120 harness: self.harness.clone(),
1121 runtime_id: session_id,
1122 endpoint,
1123 },
1124 client,
1125 receiver,
1126 active_prompt: None,
1127 }))
1128 }
1129}
1130
1131fn acp_mcp_servers(servers: &[McpServerLaunch]) -> Value {
1136 Value::Array(
1137 servers
1138 .iter()
1139 .map(|server| {
1140 json!({
1141 "name": server.name,
1142 "command": server.command,
1143 "args": server.arguments,
1144 "env": server
1145 .env
1146 .iter()
1147 .map(|(name, value)| json!({"name": name, "value": value}))
1148 .collect::<Vec<_>>(),
1149 })
1150 })
1151 .collect::<Vec<_>>(),
1152 )
1153}
1154
1155fn acp_auth_required(message: &str) -> bool {
1156 let message = message.to_ascii_lowercase();
1157 [
1158 "auth",
1159 "login",
1160 "sign in",
1161 "sign-in",
1162 "unauthorized",
1163 "forbidden",
1164 "credential",
1165 ]
1166 .iter()
1167 .any(|needle| message.contains(needle))
1168}
1169
1170#[async_trait]
1171impl RuntimeBackend for AcpRuntimeBackend {
1172 fn harness(&self) -> HarnessId {
1173 self.harness.clone()
1174 }
1175
1176 fn capabilities(&self) -> RuntimeCapabilities {
1177 RuntimeCapabilities {
1178 start_session: true,
1179 resume_session: self.resume_session,
1182 attach_existing_process: false,
1183 send_input: true,
1184 stream_events: true,
1185 interrupt: true,
1186 steer: false,
1187 respond_to_requests: true,
1188 }
1189 }
1190
1191 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1192 self.connection(&request.cwd, None, request.launch, request.mcp_servers)
1193 .await
1194 }
1195
1196 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1197 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1198 self.connection(
1199 &cwd,
1200 Some(request.runtime_id),
1201 request.launch,
1202 request.mcp_servers,
1203 )
1204 .await
1205 }
1206}
1207
1208struct AcpRuntimeConnection {
1209 handle: RuntimeHandle,
1210 client: Arc<JsonLineClient>,
1211 receiver: mpsc::UnboundedReceiver<Value>,
1212 active_prompt: Option<u64>,
1213}
1214
1215#[async_trait]
1216impl RuntimeConnection for AcpRuntimeConnection {
1217 fn handle(&self) -> &RuntimeHandle {
1218 &self.handle
1219 }
1220
1221 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1222 let mut prompt = Vec::new();
1223 if !input.text.is_empty() {
1224 prompt.push(json!({"type": "text", "text": input.text}));
1225 }
1226 for url in input.image_urls {
1227 let (mime_type, data) = data_image_parts(&url).ok_or_else(|| {
1228 Error::Other("ACP image prompts require base64 image data URLs".into())
1229 })?;
1230 prompt.push(json!({"type":"image", "mimeType":mime_type, "data":data}));
1231 }
1232 let (id, response) = self
1233 .client
1234 .begin_request(
1235 "session/prompt",
1236 json!({
1237 "sessionId": self.handle.runtime_id,
1238 "prompt": prompt,
1239 }),
1240 )
1241 .await?;
1242 self.active_prompt = Some(id);
1243 let client = self.client.clone();
1244 tokio::spawn(async move {
1245 let result = match response.await {
1246 Ok(Ok(result)) => json!({"id": id, "result": result}),
1247 Ok(Err(error)) => json!({"id": id, "error": error}),
1248 Err(_) => json!({"id": id, "error": "response channel closed"}),
1249 };
1250 client.emit(json!({
1251 "jsonrpc": "2.0",
1252 "method": "supercode/acp_request_completed",
1253 "params": result,
1254 }));
1255 });
1256 Ok(Some(id.to_string()))
1257 }
1258
1259 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1260 let Some(payload) = self.receiver.recv().await else {
1261 return Ok(None);
1262 };
1263 let kind = payload
1264 .get("method")
1265 .and_then(Value::as_str)
1266 .or_else(|| payload.get("type").and_then(Value::as_str))
1267 .unwrap_or("protocol")
1268 .to_string();
1269 if kind == "supercode/acp_request_completed" {
1270 self.active_prompt = None;
1271 }
1272 Ok(Some(HarnessEvent {
1273 sequence: None,
1274 kind,
1275 payload,
1276 }))
1277 }
1278
1279 async fn interrupt(&mut self) -> Result<()> {
1280 self.client
1281 .notify(
1282 "session/cancel",
1283 json!({"sessionId": self.handle.runtime_id}),
1284 )
1285 .await
1286 }
1287
1288 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1289 self.client.respond(request_id, response).await
1290 }
1291
1292 async fn close(&mut self) -> Result<()> {
1293 self.client.close().await
1294 }
1295}
1296
1297#[derive(Debug, Clone)]
1301pub struct OpenCodeRuntimeBackend {
1302 launch: RuntimeLaunch,
1303 base_url: Option<String>,
1304 bearer: Option<BearerToken>,
1305}
1306
1307impl Default for OpenCodeRuntimeBackend {
1308 fn default() -> Self {
1309 Self::new()
1310 }
1311}
1312
1313impl OpenCodeRuntimeBackend {
1314 pub fn new() -> Self {
1316 Self {
1317 launch: RuntimeLaunch {
1318 program: "opencode".into(),
1319 arguments: vec!["serve".into()],
1320 env: BTreeMap::new(),
1321 },
1322 base_url: None,
1323 bearer: None,
1324 }
1325 }
1326
1327 pub fn connect(base_url: impl Into<String>) -> Self {
1330 Self {
1331 base_url: Some(base_url.into().trim_end_matches('/').to_string()),
1332 ..Self::new()
1333 }
1334 }
1335
1336 pub fn with_launch(mut self, launch: RuntimeLaunch) -> Self {
1338 self.launch = launch;
1339 self
1340 }
1341
1342 pub fn with_bearer(mut self, token: BearerToken) -> Self {
1345 self.bearer = Some(token);
1346 self
1347 }
1348
1349 fn http_client(&self) -> Result<reqwest::Client> {
1350 let Some(token) = &self.bearer else {
1351 return Ok(reqwest::Client::new());
1352 };
1353 let mut headers = reqwest::header::HeaderMap::new();
1354 let mut value =
1355 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token.secret())).map_err(
1356 |_| Error::Other("connect-mode bearer token is not a valid header value".into()),
1357 )?;
1358 value.set_sensitive(true);
1359 headers.insert(reqwest::header::AUTHORIZATION, value);
1360 reqwest::Client::builder()
1361 .default_headers(headers)
1362 .build()
1363 .map_err(|error| Error::Other(format!("could not build HTTP client: {error}")))
1364 }
1365
1366 async fn service(
1367 &self,
1368 client: &reqwest::Client,
1369 launch: Option<RuntimeLaunch>,
1370 ) -> Result<(String, Option<super::GroupLeader>)> {
1371 if let Some(base_url) = &self.base_url {
1372 wait_for_health(client, base_url).await?;
1373 return Ok((base_url.clone(), None));
1374 }
1375 let port = TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port();
1376 let mut launch = launch.unwrap_or_else(|| self.launch.clone());
1377 launch.arguments.extend([
1378 "--hostname".into(),
1379 "127.0.0.1".into(),
1380 "--port".into(),
1381 port.to_string(),
1382 ]);
1383 let mut command = Command::new(&launch.program);
1384 command
1385 .args(&launch.arguments)
1386 .envs(&launch.env)
1387 .stdin(Stdio::null())
1388 .stdout(Stdio::null())
1389 .stderr(Stdio::inherit())
1390 .kill_on_drop(true);
1391 #[cfg(unix)]
1395 command.process_group(0);
1396 let mut child = super::GroupLeader(command.spawn().map_err(|error| {
1401 Error::Other(format!("could not launch {}: {error}", launch.program))
1402 })?);
1403 let base_url = format!("http://127.0.0.1:{port}");
1404 if let Err(error) = wait_for_health(client, &base_url).await {
1405 let _ = terminate_opencode_server(&mut child).await;
1406 return Err(error);
1407 }
1408 Ok((base_url, Some(child)))
1409 }
1410
1411 async fn open(
1412 &self,
1413 cwd: &Path,
1414 runtime_id: Option<String>,
1415 launch: Option<RuntimeLaunch>,
1416 ) -> Result<Box<dyn RuntimeConnection>> {
1417 let client = self.http_client()?;
1418 let (base_url, child) = self.service(&client, launch).await?;
1419 let cwd_string = cwd.to_string_lossy().to_string();
1420 let runtime_id = match runtime_id {
1421 Some(id) => {
1422 http_ok(
1423 client
1424 .get(format!("{base_url}/session/{id}"))
1425 .query(&[("directory", &cwd_string)])
1426 .send()
1427 .await,
1428 )
1429 .await?;
1430 id
1431 }
1432 None => {
1433 let response = http_ok(
1434 client
1435 .post(format!("{base_url}/session"))
1436 .query(&[("directory", &cwd_string)])
1437 .json(&json!({}))
1438 .send()
1439 .await,
1440 )
1441 .await?;
1442 response
1443 .json::<Value>()
1444 .await
1445 .map_err(http_error)?
1446 .get("id")
1447 .and_then(Value::as_str)
1448 .ok_or_else(|| Error::Other("OpenCode create session omitted id".into()))?
1449 .to_string()
1450 }
1451 };
1452 let receiver = spawn_sse(
1453 client.clone(),
1454 format!("{base_url}/event"),
1455 cwd_string.clone(),
1456 );
1457 Ok(Box::new(OpenCodeRuntimeConnection {
1458 handle: RuntimeHandle {
1459 harness: HarnessId::from(HarnessId::OPENCODE),
1460 runtime_id,
1461 endpoint: RuntimeEndpoint::Http {
1462 base_url: base_url.clone(),
1463 protocol: "opencode-http-sse".into(),
1464 },
1465 },
1466 base_url,
1467 cwd: cwd_string,
1468 client,
1469 receiver,
1470 child,
1471 }))
1472 }
1473}
1474
1475#[async_trait]
1476impl RuntimeBackend for OpenCodeRuntimeBackend {
1477 fn harness(&self) -> HarnessId {
1478 HarnessId::from(HarnessId::OPENCODE)
1479 }
1480
1481 fn capabilities(&self) -> RuntimeCapabilities {
1482 RuntimeCapabilities {
1483 start_session: true,
1484 resume_session: true,
1485 attach_existing_process: self.base_url.is_some(),
1486 send_input: true,
1487 stream_events: true,
1488 interrupt: true,
1489 steer: false,
1490 respond_to_requests: true,
1491 }
1492 }
1493
1494 async fn start(&self, request: RuntimeStartRequest) -> Result<Box<dyn RuntimeConnection>> {
1495 self.open(&request.cwd, None, request.launch).await
1496 }
1497
1498 async fn attach(&self, request: RuntimeAttachRequest) -> Result<Box<dyn RuntimeConnection>> {
1499 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1500 self.open(&cwd, Some(request.runtime_id), request.launch)
1501 .await
1502 }
1503
1504 async fn attach_existing(
1505 &self,
1506 request: RuntimeAttachRequest,
1507 ) -> Result<Box<dyn RuntimeConnection>> {
1508 if self.base_url.is_none() {
1509 return Err(Error::Other(
1510 "OpenCode live attach requires the existing server's `base_url`".into(),
1511 ));
1512 }
1513 let cwd = request.cwd.unwrap_or(std::env::current_dir()?);
1514 self.open(&cwd, Some(request.runtime_id), request.launch)
1515 .await
1516 }
1517}
1518
1519struct OpenCodeRuntimeConnection {
1520 handle: RuntimeHandle,
1521 base_url: String,
1522 cwd: String,
1523 client: reqwest::Client,
1524 receiver: mpsc::UnboundedReceiver<Value>,
1525 child: Option<super::GroupLeader>,
1526}
1527
1528#[async_trait]
1529impl RuntimeConnection for OpenCodeRuntimeConnection {
1530 fn handle(&self) -> &RuntimeHandle {
1531 &self.handle
1532 }
1533
1534 async fn send_input(&mut self, input: RuntimeInput) -> Result<Option<String>> {
1535 let mut parts = Vec::new();
1536 if !input.text.is_empty() {
1537 parts.push(json!({"type": "text", "text": input.text}));
1538 }
1539 for url in input.image_urls {
1540 let mime = image_mime_type(&url).ok_or_else(|| {
1541 Error::Other("OpenCode image prompts require a recognizable image MIME type".into())
1542 })?;
1543 parts.push(json!({"type":"file", "mime":mime, "url":url}));
1544 }
1545 http_ok(
1546 self.client
1547 .post(format!(
1548 "{}/session/{}/prompt_async",
1549 self.base_url, self.handle.runtime_id
1550 ))
1551 .query(&[("directory", &self.cwd)])
1552 .json(&json!({"parts": parts}))
1553 .send()
1554 .await,
1555 )
1556 .await?;
1557 Ok(None)
1558 }
1559
1560 async fn next_event(&mut self) -> Result<Option<HarnessEvent>> {
1561 loop {
1562 let Some(payload) = self.receiver.recv().await else {
1563 return Ok(None);
1564 };
1565 if opencode_event_session_id(&payload)
1566 .is_some_and(|session_id| session_id != self.handle.runtime_id)
1567 {
1568 continue;
1569 }
1570 let kind = payload
1571 .get("type")
1572 .and_then(Value::as_str)
1573 .unwrap_or("event")
1574 .to_string();
1575 return Ok(Some(HarnessEvent {
1576 sequence: None,
1577 kind,
1578 payload,
1579 }));
1580 }
1581 }
1582
1583 async fn interrupt(&mut self) -> Result<()> {
1584 http_ok(
1585 self.client
1586 .post(format!(
1587 "{}/session/{}/abort",
1588 self.base_url, self.handle.runtime_id
1589 ))
1590 .query(&[("directory", &self.cwd)])
1591 .send()
1592 .await,
1593 )
1594 .await?;
1595 Ok(())
1596 }
1597
1598 async fn respond(&mut self, request_id: Value, response: Value) -> Result<()> {
1599 let permission = request_id.as_str().ok_or_else(|| {
1600 Error::Other("OpenCode permission request id must be a string".into())
1601 })?;
1602 http_ok(
1603 self.client
1604 .post(format!(
1605 "{}/session/{}/permissions/{permission}",
1606 self.base_url, self.handle.runtime_id
1607 ))
1608 .query(&[("directory", &self.cwd)])
1609 .json(&response)
1610 .send()
1611 .await,
1612 )
1613 .await?;
1614 Ok(())
1615 }
1616
1617 async fn close(&mut self) -> Result<()> {
1618 if let Some(child) = &mut self.child {
1619 terminate_opencode_server(child).await?;
1620 }
1621 Ok(())
1622 }
1623}
1624
1625fn data_image_parts(url: &str) -> Option<(&str, &str)> {
1626 let rest = url.strip_prefix("data:")?;
1627 let (mime_type, data) = rest.split_once(";base64,")?;
1628 mime_type.starts_with("image/").then_some((mime_type, data))
1629}
1630
1631fn image_mime_type(url: &str) -> Option<&str> {
1632 if let Some((mime_type, _)) = data_image_parts(url) {
1633 return Some(mime_type);
1634 }
1635 let path = url.split(['?', '#']).next()?.to_ascii_lowercase();
1636 if path.ends_with(".png") {
1637 Some("image/png")
1638 } else if path.ends_with(".jpg") || path.ends_with(".jpeg") {
1639 Some("image/jpeg")
1640 } else if path.ends_with(".gif") {
1641 Some("image/gif")
1642 } else if path.ends_with(".webp") {
1643 Some("image/webp")
1644 } else {
1645 None
1646 }
1647}
1648
1649fn claude_image_part(url: &str) -> Result<Value> {
1650 if let Some((media_type, data)) = data_image_parts(url) {
1651 return Ok(json!({
1652 "type":"image",
1653 "source":{"type":"base64", "media_type":media_type, "data":data}
1654 }));
1655 }
1656 if url.starts_with("https://") || url.starts_with("http://") {
1657 return Ok(json!({"type":"image", "source":{"type":"url", "url":url}}));
1658 }
1659 Err(Error::Other(
1660 "Claude image prompts require image data URLs or HTTP(S) URLs".into(),
1661 ))
1662}
1663
1664fn opencode_event_session_id(payload: &Value) -> Option<&str> {
1665 let properties = payload.get("properties").unwrap_or(payload);
1666 properties
1667 .get("sessionID")
1668 .and_then(Value::as_str)
1669 .or_else(|| {
1670 properties
1671 .get("part")
1672 .and_then(|part| part.get("sessionID"))
1673 .and_then(Value::as_str)
1674 })
1675 .or_else(|| {
1676 properties
1677 .get("info")
1678 .and_then(|info| info.get("sessionID"))
1679 .and_then(Value::as_str)
1680 })
1681}
1682
1683async fn terminate_opencode_server(child: &mut Child) -> Result<()> {
1684 #[cfg(unix)]
1685 let process_group = child.id();
1686 let leader_exited = child.try_wait()?.is_some();
1687 if leader_exited {
1688 #[cfg(unix)]
1689 if let Some(pid) = process_group.filter(|pid| process_group_exists(*pid)) {
1690 crate::lsp::kill_process_group(pid);
1691 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1692 }
1693 return Ok(());
1694 }
1695 #[cfg(unix)]
1701 if let Some(pid) = process_group {
1702 unsafe {
1703 libc::kill(-(pid as libc::pid_t), libc::SIGTERM);
1704 }
1705 let mut leader_reaped = false;
1706 if let Ok(status) = tokio::time::timeout(Duration::from_millis(500), child.wait()).await {
1707 status?;
1708 leader_reaped = true;
1709 if !process_group_exists(pid) {
1710 return Ok(());
1711 }
1712 }
1713 crate::lsp::kill_process_group(pid);
1716 if leader_reaped {
1717 return wait_for_process_group_exit(pid, Duration::from_secs(3)).await;
1718 }
1719 }
1720 #[cfg(not(unix))]
1721 child.start_kill()?;
1722 tokio::time::timeout(Duration::from_secs(3), child.wait())
1723 .await
1724 .map_err(|_| Error::Other("timed out reaping the OpenCode server".into()))??;
1725 #[cfg(unix)]
1726 if let Some(pid) = process_group {
1727 wait_for_process_group_exit(pid, Duration::from_secs(3)).await?;
1728 }
1729 Ok(())
1730}
1731
1732#[cfg(unix)]
1733fn process_group_exists(pid: u32) -> bool {
1734 let result = unsafe { libc::kill(-(pid as libc::pid_t), 0) };
1735 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
1736}
1737
1738#[cfg(unix)]
1739async fn wait_for_process_group_exit(pid: u32, timeout: Duration) -> Result<()> {
1740 let deadline = tokio::time::Instant::now() + timeout;
1741 while process_group_exists(pid) {
1742 if tokio::time::Instant::now() >= deadline {
1743 return Err(Error::Other(format!(
1744 "timed out stopping OpenCode process group {pid}"
1745 )));
1746 }
1747 tokio::time::sleep(Duration::from_millis(10)).await;
1748 }
1749 Ok(())
1750}
1751
1752struct RawLineTransport {
1753 stdin: Mutex<ChildStdin>,
1754 child: Mutex<super::GroupLeader>,
1755 receiver: mpsc::UnboundedReceiver<Value>,
1756 endpoint: RuntimeEndpoint,
1757}
1758
1759impl RawLineTransport {
1760 async fn spawn(launch: &RuntimeLaunch, cwd: Option<&Path>, protocol: &str) -> Result<Self> {
1761 let mut command = Command::new(&launch.program);
1762 command
1763 .args(&launch.arguments)
1764 .envs(&launch.env)
1765 .stdin(Stdio::piped())
1766 .stdout(Stdio::piped())
1767 .stderr(Stdio::inherit())
1768 .kill_on_drop(true);
1769 #[cfg(unix)]
1773 command.process_group(0);
1774 if let Some(cwd) = cwd {
1775 command.current_dir(cwd);
1776 }
1777 let mut child = command.spawn().map_err(|error| {
1778 Error::Other(format!("could not launch {}: {error}", launch.program))
1779 })?;
1780 let pid = child.id();
1781 let stdin = child
1782 .stdin
1783 .take()
1784 .ok_or_else(|| Error::Other("runtime child has no stdin".into()))?;
1785 let stdout = child
1786 .stdout
1787 .take()
1788 .ok_or_else(|| Error::Other("runtime child has no stdout".into()))?;
1789 let (sender, receiver) = mpsc::unbounded_channel();
1790 tokio::spawn(async move {
1791 let mut lines = BufReader::new(stdout).lines();
1792 while let Ok(Some(line)) = lines.next_line().await {
1793 let value = serde_json::from_str(&line)
1794 .unwrap_or_else(|_| json!({"type": "malformed_output", "line": line}));
1795 let _ = sender.send(value);
1796 }
1797 });
1798 Ok(Self {
1799 stdin: Mutex::new(stdin),
1800 child: Mutex::new(super::GroupLeader(child)),
1801 receiver,
1802 endpoint: RuntimeEndpoint::LocalProcess {
1803 pid,
1804 command: std::iter::once(launch.program.clone())
1805 .chain(launch.arguments.iter().cloned())
1806 .collect(),
1807 protocol: protocol.into(),
1808 },
1809 })
1810 }
1811
1812 async fn write(&self, value: Value) -> Result<()> {
1813 let mut stdin = self.stdin.lock().await;
1814 stdin.write_all(value.to_string().as_bytes()).await?;
1815 stdin.write_all(b"\n").await?;
1816 stdin.flush().await?;
1817 Ok(())
1818 }
1819
1820 async fn close(&self) -> Result<()> {
1821 let mut child = self.child.lock().await;
1822 if child.try_wait()?.is_some() {
1823 return Ok(());
1824 }
1825 #[cfg(unix)]
1828 if let Some(pid) = child.id() {
1829 crate::lsp::kill_process_group(pid);
1830 tokio::time::timeout(Duration::from_secs(3), child.wait())
1831 .await
1832 .map_err(|_| Error::Other("timed out reaping runtime process group".into()))??;
1833 return Ok(());
1834 }
1835 #[cfg(not(unix))]
1836 child.kill().await?;
1837 Ok(())
1838 }
1839}
1840
1841async fn raw_next_event(
1842 receiver: &mut mpsc::UnboundedReceiver<Value>,
1843) -> Result<Option<HarnessEvent>> {
1844 let Some(payload) = receiver.recv().await else {
1845 return Ok(None);
1846 };
1847 Ok(Some(harness_event(payload)))
1848}
1849
1850fn harness_event(payload: Value) -> HarnessEvent {
1851 let kind = payload
1852 .get("type")
1853 .and_then(Value::as_str)
1854 .unwrap_or("event")
1855 .to_string();
1856 HarnessEvent {
1857 sequence: None,
1858 kind,
1859 payload,
1860 }
1861}
1862
1863fn broken_pipe(error: &Error) -> bool {
1870 matches!(error, Error::Io(io) if io.kind() == std::io::ErrorKind::BrokenPipe)
1871}
1872
1873fn claude_interrupt_timeout(bound: Duration) -> Error {
1874 Error::Other(format!(
1875 "Claude Code did not acknowledge the interrupt control request within {}s",
1876 bound.as_secs_f32()
1877 ))
1878}
1879
1880pub(crate) fn generated_session_id() -> String {
1881 let mut bytes = [0_u8; 16];
1882 if getrandom::getrandom(&mut bytes).is_err() {
1883 let nanos = SystemTime::now()
1884 .duration_since(UNIX_EPOCH)
1885 .unwrap_or_default()
1886 .as_nanos()
1887 .to_le_bytes();
1888 bytes.copy_from_slice(&nanos);
1889 }
1890 bytes[6] = (bytes[6] & 0x0f) | 0x40;
1891 bytes[8] = (bytes[8] & 0x3f) | 0x80;
1892 format!(
1893 "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
1894 bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
1895 bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
1896 )
1897}
1898
1899async fn wait_for_health(client: &reqwest::Client, base_url: &str) -> Result<()> {
1900 wait_for_health_for(client, base_url, Duration::from_secs(10)).await
1901}
1902
1903async fn wait_for_health_for(
1904 client: &reqwest::Client,
1905 base_url: &str,
1906 total_timeout: Duration,
1907) -> Result<()> {
1908 let url = format!("{base_url}/global/health");
1909 let mut last = None;
1910 let deadline = tokio::time::Instant::now() + total_timeout;
1911 loop {
1916 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1917 if remaining.is_zero() {
1918 break;
1919 }
1920 let request_timeout = remaining.min(Duration::from_millis(500));
1921 match tokio::time::timeout(request_timeout, client.get(&url).send()).await {
1922 Ok(Ok(response)) if response.status().is_success() => return Ok(()),
1923 Ok(Ok(response)) => last = Some(format!("HTTP {}", response.status())),
1924 Ok(Err(error)) => last = Some(error.to_string()),
1925 Err(_) => last = Some("health request timed out".into()),
1926 }
1927 let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
1928 if !remaining.is_zero() {
1929 tokio::time::sleep(remaining.min(Duration::from_millis(100))).await;
1930 }
1931 }
1932 Err(Error::Other(format!(
1933 "OpenCode server at {base_url} did not become healthy: {}",
1934 last.unwrap_or_else(|| "no response".into())
1935 )))
1936}
1937
1938async fn http_ok(
1939 response: std::result::Result<reqwest::Response, reqwest::Error>,
1940) -> Result<reqwest::Response> {
1941 response
1942 .map_err(http_error)?
1943 .error_for_status()
1944 .map_err(http_error)
1945}
1946
1947fn http_error(error: reqwest::Error) -> Error {
1948 Error::Other(format!("runtime HTTP request failed: {error}"))
1949}
1950
1951fn spawn_sse(
1952 client: reqwest::Client,
1953 url: String,
1954 directory: String,
1955) -> mpsc::UnboundedReceiver<Value> {
1956 let (sender, receiver) = mpsc::unbounded_channel();
1957 tokio::spawn(async move {
1958 let response = client
1959 .get(url)
1960 .query(&[("directory", directory)])
1961 .send()
1962 .await;
1963 let Ok(response) = response.and_then(reqwest::Response::error_for_status) else {
1964 let _ = sender.send(
1965 json!({"type": "stream_error", "message": "could not open OpenCode SSE stream"}),
1966 );
1967 return;
1968 };
1969 let mut stream = response.bytes_stream();
1970 let mut buffer = String::new();
1971 while let Some(chunk) = stream.next().await {
1972 let Ok(chunk) = chunk else {
1973 break;
1974 };
1975 buffer.push_str(&String::from_utf8_lossy(&chunk));
1976 while let Some(newline) = buffer.find('\n') {
1977 let line = buffer[..newline].trim_end_matches('\r').to_string();
1978 buffer.drain(..=newline);
1979 if let Some(data) = line.strip_prefix("data:") {
1980 let data = data.trim();
1981 if let Ok(value) = serde_json::from_str(data) {
1982 let _ = sender.send(value);
1983 }
1984 }
1985 }
1986 }
1987 });
1988 receiver
1989}
1990
1991#[cfg(test)]
1992mod tests {
1993 use super::*;
1994
1995 #[cfg(unix)]
1999 const FAKE_CLAUDE_ACKS: &str = r#"
2000cap="$1"
2001while IFS= read -r line; do
2002 printf '%s\n' "$line" >> "$cap"
2003 case "$line" in
2004 *'"subtype":"interrupt"'*)
2005 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2006 printf '{"type":"system","subtype":"mid_flight"}\n'
2007 printf '{"type":"control_response","response":{"subtype":"success","request_id":"someone-elses-request","response":{}}}\n'
2008 printf '{"type":"control_response","response":{"subtype":"success","request_id":"%s","response":{"still_queued":[]}}}\n' "$rid"
2009 ;;
2010 *'"type":"user"'*)
2011 printf '{"type":"assistant","message":{"role":"assistant","content":"replied"}}\n'
2012 ;;
2013 esac
2014done
2015"#;
2016
2017 #[cfg(unix)]
2020 const FAKE_CLAUDE_NEVER_ACKS: &str = r#"
2021cap="$1"
2022while IFS= read -r line; do
2023 printf '%s\n' "$line" >> "$cap"
2024done
2025"#;
2026
2027 #[cfg(unix)]
2034 const FAKE_CLAUDE_ASKS_PERMISSION: &str = r#"
2035cap="$1"
2036printf '{"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'
2037while IFS= read -r line; do
2038 printf '%s\n' "$line" >> "$cap"
2039 case "$line" in
2040 *'"request_id":"053f8a2d-3445-4011-a259-4261b31c7326"'*)
2041 case "$line" in
2042 *'"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' ;;
2043 *) printf '{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_mock_1","content":"denied","is_error":true}]}}\n' ;;
2044 esac
2045 ;;
2046 esac
2047done
2048"#;
2049
2050 #[cfg(unix)]
2052 const FAKE_CLAUDE_REJECTS: &str = r#"
2053cap="$1"
2054while IFS= read -r line; do
2055 printf '%s\n' "$line" >> "$cap"
2056 case "$line" in
2057 *'"subtype":"interrupt"'*)
2058 rid=$(printf '%s' "$line" | sed -n 's/.*"request_id":"\([^"]*\)".*/\1/p')
2059 printf '{"type":"control_response","response":{"subtype":"error","request_id":"%s","error":"no active worker"}}\n' "$rid"
2060 ;;
2061 esac
2062done
2063"#;
2064
2065 #[cfg(unix)]
2066 struct FakeClaude {
2067 connection: ClaudeRuntimeConnection,
2068 capture: std::path::PathBuf,
2069 _dir: std::path::PathBuf,
2070 }
2071
2072 #[cfg(unix)]
2073 impl FakeClaude {
2074 async fn spawn(script: &str, control_timeout: Duration) -> Self {
2075 Self::spawn_with(script, control_timeout, CLAUDE_PERMISSION_RESPONSE_TIMEOUT).await
2076 }
2077
2078 async fn spawn_with(
2079 script: &str,
2080 control_timeout: Duration,
2081 permission_timeout: Duration,
2082 ) -> Self {
2083 let dir = std::env::temp_dir().join(format!(
2084 "supercode-fake-claude-{}-{}",
2085 std::process::id(),
2086 generated_session_id()
2087 ));
2088 std::fs::create_dir_all(&dir).unwrap();
2089 let capture = dir.join("stdin.jsonl");
2090 let launch = RuntimeLaunch {
2091 program: "/bin/sh".into(),
2092 arguments: vec![
2093 "-c".into(),
2094 script.into(),
2095 "fake-claude".into(),
2096 capture.display().to_string(),
2097 ],
2098 env: BTreeMap::new(),
2099 };
2100 let transport = RawLineTransport::spawn(&launch, Some(&dir), "claude-stream-json")
2101 .await
2102 .unwrap();
2103 let connection = ClaudeRuntimeConnection {
2104 handle: RuntimeHandle {
2105 harness: HarnessId::from(HarnessId::CLAUDE_CODE),
2106 runtime_id: "fake-session".into(),
2107 endpoint: transport.endpoint.clone(),
2108 },
2109 transport,
2110 prefix: launch.clone(),
2111 cwd: dir.clone(),
2112 spoke: false,
2113 buffered_events: VecDeque::new(),
2114 next_control_request: 1,
2115 control_timeout,
2116 pending_permissions: Vec::new(),
2117 permission_timeout,
2118 mounted_mcp_servers: Vec::new(),
2119 };
2120 Self {
2121 connection,
2122 capture,
2123 _dir: dir,
2124 }
2125 }
2126
2127 fn written_frames(&self) -> Vec<Value> {
2128 std::fs::read_to_string(&self.capture)
2129 .unwrap_or_default()
2130 .lines()
2131 .filter(|line| !line.trim().is_empty())
2132 .map(|line| serde_json::from_str(line).expect("adapter wrote a non-JSON frame"))
2133 .collect()
2134 }
2135 }
2136
2137 #[cfg(unix)]
2138 #[tokio::test]
2139 async fn claude_interrupt_writes_one_control_request_per_call_with_a_fresh_id() {
2140 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
2141
2142 fake.connection.interrupt().await.unwrap();
2143 fake.connection.interrupt().await.unwrap();
2144
2145 let frames = fake.written_frames();
2146 assert_eq!(
2147 frames.len(),
2148 2,
2149 "each interrupt must write exactly one control frame: {frames:?}"
2150 );
2151 let mut ids = Vec::new();
2152 for frame in &frames {
2153 assert_eq!(frame["type"], "control_request");
2154 assert_eq!(frame["request"]["subtype"], "interrupt");
2155 let id = frame["request_id"].as_str().expect("frame carries an id");
2156 assert!(!id.is_empty());
2157 ids.push(id.to_string());
2158 }
2159 assert_ne!(ids[0], ids[1], "request ids must be unique per call");
2160 }
2161
2162 #[cfg(unix)]
2167 #[tokio::test]
2168 async fn claude_interrupt_with_no_turn_in_flight_is_acknowledged_and_the_session_survives() {
2169 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ACKS, Duration::from_secs(5)).await;
2170
2171 fake.connection.interrupt().await.unwrap();
2172 fake.connection
2173 .send_input(RuntimeInput {
2174 text: String::new(),
2175 image_urls: vec!["data:image/png;base64,aGVsbG8=".into()],
2176 })
2177 .await
2178 .unwrap();
2179
2180 let mut kinds = Vec::new();
2185 while kinds.len() < 2 {
2186 let event = fake.connection.next_event().await.unwrap().unwrap();
2187 assert_ne!(event.kind, "control_response");
2188 kinds.push(event.kind);
2189 }
2190 assert_eq!(kinds, vec!["system".to_string(), "assistant".to_string()]);
2191
2192 let frames = fake.written_frames();
2193 assert_eq!(frames[0]["type"], "control_request");
2194 assert_eq!(
2195 frames[1]["type"], "user",
2196 "a send issued after an interrupt must reach the harness, in order"
2197 );
2198 assert_eq!(
2199 frames[1]["message"]["content"][0]["source"],
2200 json!({"type":"base64", "media_type":"image/png", "data":"aGVsbG8="}),
2201 "an image-only turn must remain native without a synthetic text block"
2202 );
2203 }
2204
2205 #[cfg(unix)]
2206 #[tokio::test]
2207 async fn claude_interrupt_times_out_with_a_structured_error_instead_of_hanging() {
2208 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_NEVER_ACKS, Duration::from_millis(250)).await;
2209
2210 let started = std::time::Instant::now();
2211 let error = fake.connection.interrupt().await.unwrap_err();
2212
2213 assert!(
2214 started.elapsed() < Duration::from_secs(5),
2215 "interrupt must return on its own bound, not hang"
2216 );
2217 assert!(
2218 error
2219 .to_string()
2220 .contains("did not acknowledge the interrupt"),
2221 "unexpected error: {error}"
2222 );
2223 assert_eq!(fake.written_frames().len(), 1);
2224 }
2225
2226 #[cfg(unix)]
2227 #[tokio::test]
2228 async fn claude_interrupt_surfaces_a_rejecting_control_response_as_an_error() {
2229 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_REJECTS, Duration::from_secs(5)).await;
2230
2231 let error = fake.connection.interrupt().await.unwrap_err();
2232
2233 assert!(
2234 error.to_string().contains("no active worker"),
2235 "unexpected error: {error}"
2236 );
2237 }
2238
2239 #[test]
2240 fn claude_code_runtime_advertises_mid_turn_controls() {
2241 let capabilities = ClaudeCodeRuntimeBackend::new().capabilities();
2242 assert!(capabilities.interrupt);
2243 assert!(capabilities.steer);
2244 assert!(capabilities.respond_to_requests);
2245 }
2246
2247 #[test]
2253 fn claude_code_launches_as_the_cli_permission_handler() {
2254 let backend = ClaudeCodeRuntimeBackend::new();
2255 let arguments = backend.launch.arguments.join(" ");
2256 assert!(
2257 arguments.contains("--permission-prompt-tool stdio"),
2258 "the default launch must register supercode as the permission handler: {arguments}"
2259 );
2260 assert!(arguments.contains("--input-format stream-json"));
2261 assert!(arguments.contains("--output-format stream-json"));
2262 }
2263
2264 #[cfg(unix)]
2270 #[tokio::test]
2271 async fn claude_permission_request_surfaces_and_respond_allows_the_blocked_tool() {
2272 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2273
2274 let request = fake.connection.next_event().await.unwrap().unwrap();
2275 assert_eq!(request.kind, "control_request");
2276 assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2277 let request_id = request.payload["request_id"].clone();
2278
2279 fake.connection
2280 .respond(request_id.clone(), json!({"behavior": "allow"}))
2281 .await
2282 .unwrap();
2283
2284 let result = fake.connection.next_event().await.unwrap().unwrap();
2285 assert_eq!(result.kind, "user");
2286 assert_eq!(
2287 result.payload["message"]["content"][0]["is_error"],
2288 json!(false),
2289 "the allowed tool must have run: {}",
2290 result.payload
2291 );
2292
2293 let frames = fake.written_frames();
2294 assert_eq!(frames.len(), 1, "one answer per request: {frames:?}");
2295 assert_eq!(
2296 frames[0],
2297 json!({
2298 "type": "control_response",
2299 "response": {
2300 "subtype": "success",
2301 "request_id": request_id,
2302 "response": {"behavior": "allow"},
2303 },
2304 }),
2305 "the answer must be the envelope claude 2.1.258 accepts"
2306 );
2307 }
2308
2309 #[cfg(unix)]
2313 #[tokio::test]
2314 async fn claude_permission_deny_blocks_the_tool_and_always_carries_a_message() {
2315 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2316
2317 let request = fake.connection.next_event().await.unwrap().unwrap();
2318 fake.connection
2319 .respond(
2320 request.payload["request_id"].clone(),
2321 json!({"behavior": "deny"}),
2322 )
2323 .await
2324 .unwrap();
2325
2326 let result = fake.connection.next_event().await.unwrap().unwrap();
2327 assert_eq!(
2328 result.payload["message"]["content"][0]["is_error"],
2329 json!(true),
2330 "a denied tool must not run: {}",
2331 result.payload
2332 );
2333
2334 let frames = fake.written_frames();
2335 let message = frames[0]["response"]["response"]["message"]
2336 .as_str()
2337 .expect("deny must carry a message");
2338 assert!(!message.is_empty(), "{frames:?}");
2339 assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2340 }
2341
2342 #[cfg(unix)]
2346 #[tokio::test]
2347 async fn claude_permission_answers_outside_the_protocol_are_refused_by_name() {
2348 let mut fake = FakeClaude::spawn(FAKE_CLAUDE_ASKS_PERMISSION, Duration::from_secs(5)).await;
2349 let request = fake.connection.next_event().await.unwrap().unwrap();
2350 let request_id = request.payload["request_id"].clone();
2351
2352 let error = fake
2353 .connection
2354 .respond(request_id.clone(), json!({"outcome": "selected"}))
2355 .await
2356 .unwrap_err();
2357 assert!(error.to_string().contains("`allow`"), "{error}");
2358 assert!(error.to_string().contains("`deny`"), "{error}");
2359
2360 let error = fake
2361 .connection
2362 .respond(json!("not-a-live-request"), json!({"behavior": "allow"}))
2363 .await
2364 .unwrap_err();
2365 assert!(error.to_string().contains("not-a-live-request"), "{error}");
2366
2367 assert!(fake.written_frames().is_empty());
2369 fake.connection
2370 .respond(request_id, json!({"behavior": "allow"}))
2371 .await
2372 .unwrap();
2373 let result = fake.connection.next_event().await.unwrap().unwrap();
2376 assert_eq!(result.payload["message"]["content"][0]["is_error"], false);
2377 assert_eq!(fake.written_frames().len(), 1);
2378 }
2379
2380 #[cfg(unix)]
2384 #[tokio::test]
2385 async fn an_unanswered_claude_permission_request_is_denied_on_the_adapter_bound() {
2386 let mut fake = FakeClaude::spawn_with(
2387 FAKE_CLAUDE_ASKS_PERMISSION,
2388 Duration::from_secs(5),
2389 Duration::from_millis(250),
2390 )
2391 .await;
2392
2393 let request = fake.connection.next_event().await.unwrap().unwrap();
2394 assert_eq!(request.payload["request"]["subtype"], "can_use_tool");
2395
2396 let result = tokio::time::timeout(Duration::from_secs(5), fake.connection.next_event())
2397 .await
2398 .expect("the adapter must deny on its own bound rather than hang")
2399 .unwrap()
2400 .unwrap();
2401 assert_eq!(
2402 result.payload["message"]["content"][0]["is_error"],
2403 json!(true),
2404 "an unanswered request must deny: {}",
2405 result.payload
2406 );
2407
2408 let frames = fake.written_frames();
2409 assert_eq!(frames.len(), 1, "{frames:?}");
2410 assert_eq!(frames[0]["response"]["response"]["behavior"], "deny");
2411 assert!(frames[0]["response"]["response"]["message"]
2412 .as_str()
2413 .unwrap()
2414 .contains("timeout"));
2415 }
2416
2417 #[test]
2418 fn capability_reports_distinguish_resume_from_process_attach() {
2419 assert!(
2420 !PiRuntimeBackend::new()
2421 .capabilities()
2422 .attach_existing_process
2423 );
2424 assert!(
2425 !ClaudeCodeRuntimeBackend::new()
2426 .capabilities()
2427 .attach_existing_process
2428 );
2429 assert!(
2430 !OpenCodeRuntimeBackend::new()
2431 .capabilities()
2432 .attach_existing_process
2433 );
2434 assert!(
2435 OpenCodeRuntimeBackend::connect("http://127.0.0.1:4096")
2436 .capabilities()
2437 .attach_existing_process
2438 );
2439 }
2440
2441 #[test]
2442 fn generated_ids_are_uuid_shaped_and_unique() {
2443 let first = generated_session_id();
2444 let second = generated_session_id();
2445 assert_eq!(first.len(), 36);
2446 assert_ne!(first, second);
2447 }
2448
2449 #[test]
2450 fn opencode_event_session_id_covers_current_event_shapes() {
2451 assert_eq!(
2452 opencode_event_session_id(&json!({
2453 "type": "session.status",
2454 "properties": {"sessionID": "session-direct", "status": {"type": "busy"}}
2455 })),
2456 Some("session-direct")
2457 );
2458 assert_eq!(
2459 opencode_event_session_id(&json!({
2460 "type": "message.part.updated",
2461 "properties": {"part": {"sessionID": "session-part", "type": "text"}}
2462 })),
2463 Some("session-part")
2464 );
2465 assert_eq!(
2466 opencode_event_session_id(&json!({
2467 "type": "message.updated",
2468 "properties": {"info": {"sessionID": "session-info", "role": "assistant"}}
2469 })),
2470 Some("session-info")
2471 );
2472 assert_eq!(
2473 opencode_event_session_id(&json!({"type": "server.connected"})),
2474 None
2475 );
2476 }
2477
2478 #[tokio::test]
2479 async fn opencode_runtime_skips_events_for_other_sessions() {
2480 let (sender, receiver) = mpsc::unbounded_channel();
2481 sender
2482 .send(json!({
2483 "type": "session.idle",
2484 "properties": {"sessionID": "foreign-session"}
2485 }))
2486 .unwrap();
2487 sender
2488 .send(json!({
2489 "type": "message.part.delta",
2490 "properties": {"sessionID": "local-session", "delta": "hello"}
2491 }))
2492 .unwrap();
2493 let mut connection = OpenCodeRuntimeConnection {
2494 handle: RuntimeHandle {
2495 harness: HarnessId::from(HarnessId::OPENCODE),
2496 runtime_id: "local-session".into(),
2497 endpoint: RuntimeEndpoint::Http {
2498 base_url: "http://127.0.0.1:1".into(),
2499 protocol: "opencode-http".into(),
2500 },
2501 },
2502 base_url: "http://127.0.0.1:1".into(),
2503 cwd: "/tmp".into(),
2504 client: reqwest::Client::new(),
2505 receiver,
2506 child: None,
2507 };
2508
2509 let event = connection.next_event().await.unwrap().unwrap();
2510
2511 assert_eq!(event.kind, "message.part.delta");
2512 assert_eq!(event.payload["properties"]["sessionID"], "local-session");
2513 }
2514
2515 #[cfg(unix)]
2516 #[tokio::test]
2517 async fn opencode_shutdown_reaps_a_launcher_process_group() {
2518 let mut command = Command::new("/bin/sh");
2519 command
2520 .args(["-c", "sleep 30 & wait"])
2521 .stdin(Stdio::null())
2522 .stdout(Stdio::null())
2523 .stderr(Stdio::null())
2524 .kill_on_drop(true)
2525 .process_group(0);
2526 let mut child = command.spawn().unwrap();
2527 let pid = child.id().unwrap();
2528
2529 terminate_opencode_server(&mut child).await.unwrap();
2530
2531 assert!(child.try_wait().unwrap().is_some());
2532 let group_still_exists = unsafe { libc::kill(-(pid as libc::pid_t), 0) } == 0;
2533 assert!(
2534 !group_still_exists,
2535 "OpenCode worker process group survived close"
2536 );
2537 }
2538
2539 #[cfg(unix)]
2540 #[tokio::test]
2541 async fn opencode_shutdown_reaps_workers_after_launcher_exit() {
2542 let mut command = Command::new("/bin/sh");
2543 command
2544 .args(["-c", "sleep 30 & exit 0"])
2545 .stdin(Stdio::null())
2546 .stdout(Stdio::null())
2547 .stderr(Stdio::null())
2548 .kill_on_drop(true)
2549 .process_group(0);
2550 let mut child = command.spawn().unwrap();
2551 let pid = child.id().unwrap();
2552 tokio::time::sleep(Duration::from_millis(200)).await;
2553
2554 terminate_opencode_server(&mut child).await.unwrap();
2555
2556 assert!(child.try_wait().unwrap().is_some());
2557 assert!(
2558 !process_group_exists(pid),
2559 "OpenCode worker process group survived its exited launcher"
2560 );
2561 }
2562
2563 #[tokio::test]
2564 async fn opencode_health_probe_is_bounded_when_a_socket_never_responds() {
2565 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2566 let address = listener.local_addr().unwrap();
2567 let server = tokio::spawn(async move {
2568 let (_socket, _) = listener.accept().await.unwrap();
2569 tokio::time::sleep(Duration::from_secs(30)).await;
2570 });
2571 let started = tokio::time::Instant::now();
2572
2573 let error = wait_for_health_for(
2574 &reqwest::Client::new(),
2575 &format!("http://{address}"),
2576 Duration::from_millis(200),
2577 )
2578 .await
2579 .unwrap_err();
2580
2581 assert!(error.to_string().contains("health request timed out"));
2582 assert!(started.elapsed() < Duration::from_secs(1));
2583 server.abort();
2584 }
2585
2586 #[cfg(unix)]
2587 #[tokio::test]
2588 async fn acp_adapter_negotiates_starts_and_streams_without_blocking_prompt() {
2589 let script = r#"
2590 i=0
2591 while IFS= read -r line; do
2592 i=$((i + 1))
2593 case "$i" in
2594 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[]}}' ;;
2595 2) printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"acp_mock"}}' ;;
2596 3)
2597 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"acp_mock","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}'
2598 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2599 ;;
2600 esac
2601 done
2602 "#;
2603 let backend = AcpRuntimeBackend::new(
2604 HarnessId::from("mock-acp"),
2605 RuntimeLaunch {
2606 program: "/bin/sh".into(),
2607 arguments: vec!["-c".into(), script.into()],
2608 env: BTreeMap::new(),
2609 },
2610 );
2611 let mut connection = backend
2612 .start(RuntimeStartRequest {
2613 cwd: std::env::current_dir().unwrap(),
2614 launch: None,
2615 mcp_servers: Vec::new(),
2616 })
2617 .await
2618 .unwrap();
2619 assert_eq!(connection.handle().runtime_id, "acp_mock");
2620 assert_eq!(
2621 connection
2622 .send_input(RuntimeInput {
2623 text: "hi".into(),
2624 image_urls: Vec::new(),
2625 })
2626 .await
2627 .unwrap()
2628 .as_deref(),
2629 Some("3")
2630 );
2631 assert_eq!(
2632 connection.next_event().await.unwrap().unwrap().kind,
2633 "session/update"
2634 );
2635 assert_eq!(
2636 connection.next_event().await.unwrap().unwrap().kind,
2637 "supercode/acp_request_completed"
2638 );
2639 connection.close().await.unwrap();
2640 }
2641
2642 #[cfg(unix)]
2646 #[tokio::test]
2647 async fn acp_start_forwards_mcp_servers_into_session_new() {
2648 let capture = std::env::temp_dir().join(format!(
2649 "supercode-acp-mcp-{}-{}.json",
2650 std::process::id(),
2651 std::time::SystemTime::now()
2652 .duration_since(std::time::UNIX_EPOCH)
2653 .unwrap()
2654 .as_nanos()
2655 ));
2656 let script = format!(
2657 r#"
2658 i=0
2659 while IFS= read -r line; do
2660 i=$((i + 1))
2661 case "$i" in
2662 1) printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":1,"agentCapabilities":{{}},"authMethods":[]}}}}' ;;
2663 2)
2664 printf '%s\n' "$line" > {capture}
2665 printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"sessionId":"acp_mock"}}}}'
2666 ;;
2667 esac
2668 done
2669 "#,
2670 capture = capture.display()
2671 );
2672 let backend = AcpRuntimeBackend::new(
2673 HarnessId::from("mock-acp"),
2674 RuntimeLaunch {
2675 program: "/bin/sh".into(),
2676 arguments: vec!["-c".into(), script],
2677 env: BTreeMap::new(),
2678 },
2679 );
2680 let mut connection = backend
2681 .start(RuntimeStartRequest {
2682 cwd: std::env::current_dir().unwrap(),
2683 launch: None,
2684 mcp_servers: vec![McpServerLaunch {
2685 name: "orchestrator".into(),
2686 command: "/usr/bin/node".into(),
2687 arguments: vec!["/tmp/server.mjs".into()],
2688 env: BTreeMap::from([(
2689 "SUPERCODE_ORCHESTRATOR_PROFILE".into(),
2690 "coder".into(),
2691 )]),
2692 }],
2693 })
2694 .await
2695 .unwrap();
2696 connection.close().await.unwrap();
2697
2698 let sent: Value =
2699 serde_json::from_str(&std::fs::read_to_string(&capture).unwrap()).unwrap();
2700 let _ = std::fs::remove_file(&capture);
2701 assert_eq!(sent["method"], "session/new");
2702 assert_eq!(
2703 sent["params"]["mcpServers"],
2704 json!([{
2705 "name": "orchestrator",
2706 "command": "/usr/bin/node",
2707 "args": ["/tmp/server.mjs"],
2708 "env": [{"name": "SUPERCODE_ORCHESTRATOR_PROFILE", "value": "coder"}],
2709 }])
2710 );
2711 }
2712
2713 #[cfg(unix)]
2714 #[tokio::test]
2715 async fn acp_uses_an_existing_login_before_trying_an_advertised_auth_method() {
2716 let script = r#"
2717 i=0
2718 while IFS= read -r line; do
2719 i=$((i + 1))
2720 if [ "$i" -eq 1 ]; then
2721 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{},"authMethods":[{"id":"cached_token"}]}}'
2722 elif printf '%s' "$line" | grep -q 'session/new'; then
2723 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"sessionId":"existing_login"}}'
2724 else
2725 exit 9
2726 fi
2727 done
2728 "#;
2729 let backend = AcpRuntimeBackend::new(
2730 HarnessId::from("mock-acp"),
2731 RuntimeLaunch {
2732 program: "/bin/sh".into(),
2733 arguments: vec!["-c".into(), script.into()],
2734 env: BTreeMap::new(),
2735 },
2736 );
2737 let mut connection = backend
2738 .start(RuntimeStartRequest {
2739 cwd: std::env::current_dir().unwrap(),
2740 launch: None,
2741 mcp_servers: Vec::new(),
2742 })
2743 .await
2744 .unwrap();
2745 assert_eq!(connection.handle().runtime_id, "existing_login");
2746 connection.close().await.unwrap();
2747 }
2748
2749 #[cfg(unix)]
2750 #[tokio::test]
2751 async fn known_acp_agent_reports_and_uses_load_session_for_resume() {
2752 let script = r#"
2753 i=0
2754 while IFS= read -r line; do
2755 i=$((i + 1))
2756 case "$i" in
2757 1) printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true},"authMethods":[]}}' ;;
2758 2)
2759 case "$line" in
2760 *'"method":"session/load"'*'"sessionId":"existing-session"'*)
2761 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"historical replay"}}}}'
2762 printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{}}'
2763 ;;
2764 *) exit 42 ;;
2765 esac
2766 ;;
2767 3)
2768 printf '%s\n' '{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"existing-session","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"fresh output"}}}}'
2769 printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}'
2770 ;;
2771 esac
2772 done
2773 "#;
2774 let backend = AcpRuntimeBackend::new(
2775 HarnessId::from("known-acp"),
2776 RuntimeLaunch {
2777 program: "/bin/sh".into(),
2778 arguments: vec!["-c".into(), script.into()],
2779 env: BTreeMap::new(),
2780 },
2781 )
2782 .with_resume_support(true);
2783 assert!(backend.capabilities().resume_session);
2784 let mut connection = backend
2785 .attach(RuntimeAttachRequest {
2786 runtime_id: "existing-session".into(),
2787 cwd: Some(std::env::current_dir().unwrap()),
2788 launch: None,
2789 mcp_servers: Vec::new(),
2790 })
2791 .await
2792 .unwrap();
2793 assert_eq!(connection.handle().runtime_id, "existing-session");
2794 assert_eq!(
2795 connection
2796 .send_input(RuntimeInput {
2797 text: "continue".into(),
2798 image_urls: Vec::new(),
2799 })
2800 .await
2801 .unwrap()
2802 .as_deref(),
2803 Some("3")
2804 );
2805 let event = connection.next_event().await.unwrap().unwrap();
2806 assert_eq!(event.kind, "session/update");
2807 assert_eq!(
2808 event
2809 .payload
2810 .pointer("/params/update/content/text")
2811 .and_then(Value::as_str),
2812 Some("fresh output")
2813 );
2814 assert_eq!(
2815 connection.next_event().await.unwrap().unwrap().kind,
2816 "supercode/acp_request_completed"
2817 );
2818 connection.close().await.unwrap();
2819 }
2820}