1use std::path::{Path, PathBuf};
8use std::process::Stdio;
9
10use tokio::io::AsyncReadExt;
11use tokio::process::{Child, Command};
12
13use crate::config::{ExecutorConfig, ExecutorMode, ALLOWED_EXECUTOR_TYPES};
14use crate::protocol::C2cPlan;
15
16const MAX_CAPTURE_BYTES: usize = 64 * 1024;
17const MAX_SUMMARY_CHARS: usize = 2_000;
18
19#[derive(Debug, thiserror::Error)]
20pub enum ExecutorError {
21 #[error(
22 "OpenCode is not installed or not found on PATH (looked for `{0}`). \
23 Install OpenCode and ensure the executable is available."
24 )]
25 NotInstalled(String),
26 #[error("executor type `{0}` is not allowed (allowlist: opencode, codex, claude)")]
27 TypeNotAllowed(String),
28 #[error("executor type `{0}` is not implemented; V0.2 only supports OpenCode")]
29 TypeNotImplemented(String),
30 #[error("an executor task is already running (task_id={0})")]
31 AlreadyRunning(String),
32 #[error("no running executor task")]
33 NotRunning,
34 #[error("executor command is invalid: {0}")]
35 InvalidCommand(String),
36 #[error("executor workspace is invalid: {0}")]
37 InvalidWorkspace(String),
38 #[error("failed to start OpenCode: {0}")]
39 Spawn(String),
40 #[error("executor crashed: {0}")]
41 Crashed(String),
42 #[error("executor was cancelled")]
43 Cancelled,
44 #[error("{0}")]
45 Other(String),
46}
47
48pub trait Executor: Send + Sync {
50 fn name(&self) -> &'static str;
51 fn detect(&self) -> Result<PathBuf, ExecutorError>;
52 fn start_task(&self, plan: &C2cPlan, workspace: &Path) -> Result<SpawnedTask, ExecutorError>;
53 fn status(&self, task_id: &str) -> Result<ExecutorSnapshot, ExecutorError>;
54 fn result(&self, task_id: &str) -> Result<ExecutorSnapshot, ExecutorError>;
55 fn cancel(&self, task_id: &str) -> Result<(), ExecutorError>;
56}
57
58pub struct SpawnedTask {
61 pub child: Child,
62 pub pid: u32,
63 pub executable: PathBuf,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ExecutorSnapshot {
68 pub task_id: String,
69 pub status: String,
70 pub summary: Option<String>,
71 pub exit_code: Option<i32>,
72 pub tests: Option<String>,
73 pub changed_files: Vec<String>,
74 pub error: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ExecutorOutcome {
79 pub exit_code: Option<i32>,
80 pub summary: String,
81 pub tests_excerpt: Option<String>,
82 pub cancelled: bool,
83 pub error: Option<String>,
84}
85
86#[derive(Debug, Clone)]
87pub struct OpenCodeExecutor {
88 command: String,
89 mode: ExecutorMode,
90}
91
92impl OpenCodeExecutor {
93 pub fn from_config(cfg: &ExecutorConfig) -> Result<Self, ExecutorError> {
94 validate_executor_type(&cfg.kind)?;
95 let command = cfg.command.trim();
96 if command.is_empty() {
97 return Err(ExecutorError::InvalidCommand(
98 "command must not be empty".into(),
99 ));
100 }
101 if command.contains('\0') {
102 return Err(ExecutorError::InvalidCommand("command contains NUL".into()));
103 }
104 Ok(Self {
105 command: command.to_string(),
106 mode: cfg.mode,
107 })
108 }
109
110 pub fn command(&self) -> &str {
111 &self.command
112 }
113
114 pub fn mode(&self) -> ExecutorMode {
115 self.mode
116 }
117}
118
119impl Executor for OpenCodeExecutor {
120 fn name(&self) -> &'static str {
121 "opencode"
122 }
123
124 fn detect(&self) -> Result<PathBuf, ExecutorError> {
125 find_executable(&self.command)
126 .ok_or_else(|| ExecutorError::NotInstalled(self.command.clone()))
127 }
128
129 fn start_task(&self, plan: &C2cPlan, workspace: &Path) -> Result<SpawnedTask, ExecutorError> {
130 plan.validate()
131 .map_err(|e| ExecutorError::Other(e.to_string()))?;
132 if !workspace.is_dir() {
133 return Err(ExecutorError::InvalidWorkspace(
134 workspace.display().to_string(),
135 ));
136 }
137 let exe = self.detect()?;
138 let prompt = executor_argv_prompt();
139 let child = spawn_opencode(&exe, workspace, prompt)?;
140 let pid = child
141 .id()
142 .ok_or_else(|| ExecutorError::Spawn("OpenCode process has no pid".into()))?;
143 Ok(SpawnedTask {
144 child,
145 pid,
146 executable: exe,
147 })
148 }
149
150 fn status(&self, _task_id: &str) -> Result<ExecutorSnapshot, ExecutorError> {
151 Err(ExecutorError::Other(
152 "status is provided by TaskRuntime".into(),
153 ))
154 }
155
156 fn result(&self, _task_id: &str) -> Result<ExecutorSnapshot, ExecutorError> {
157 Err(ExecutorError::Other(
158 "result is provided by TaskRuntime".into(),
159 ))
160 }
161
162 fn cancel(&self, _task_id: &str) -> Result<(), ExecutorError> {
163 Err(ExecutorError::Other(
164 "cancel is provided by TaskRuntime".into(),
165 ))
166 }
167}
168
169pub fn validate_executor_type(kind: &str) -> Result<(), ExecutorError> {
170 let kind = kind.trim().to_ascii_lowercase();
171 if !ALLOWED_EXECUTOR_TYPES
172 .iter()
173 .any(|allowed| *allowed == kind)
174 {
175 return Err(ExecutorError::TypeNotAllowed(kind));
176 }
177 if kind != "opencode" {
178 return Err(ExecutorError::TypeNotImplemented(kind));
179 }
180 Ok(())
181}
182
183pub fn find_executable(command: &str) -> Option<PathBuf> {
185 let command = command.trim();
186 if command.is_empty() || command.contains('\0') {
187 return None;
188 }
189 let path = Path::new(command);
190 if path.is_absolute() {
191 return existing_file(path);
192 }
193 if command.contains("..") {
194 return None;
195 }
196 if path.components().count() > 1 {
197 return existing_file(path);
198 }
199
200 let path_var = std::env::var_os("PATH")?;
201 for dir in std::env::split_paths(&path_var) {
202 for candidate in candidates_in_dir(&dir, command) {
203 if let Some(found) = existing_file(&candidate) {
204 return Some(found);
205 }
206 }
207 }
208 None
209}
210
211fn existing_file(path: &Path) -> Option<PathBuf> {
212 if path.is_file() {
213 return Some(path.to_path_buf());
214 }
215 #[cfg(windows)]
216 {
217 for ext in pathext() {
218 let with_ext = if has_extension(path) {
219 path.to_path_buf()
220 } else {
221 let mut p = path.as_os_str().to_os_string();
222 p.push(&ext);
223 PathBuf::from(p)
224 };
225 if with_ext.is_file() {
226 return Some(with_ext);
227 }
228 }
229 }
230 None
231}
232
233fn candidates_in_dir(dir: &Path, command: &str) -> Vec<PathBuf> {
234 let base = dir.join(command);
235 let mut out = vec![base.clone()];
236 #[cfg(windows)]
237 {
238 if !has_extension(&base) {
239 for ext in pathext() {
240 let mut p = base.as_os_str().to_os_string();
241 p.push(&ext);
242 out.push(PathBuf::from(p));
243 }
244 }
245 }
246 out
247}
248
249#[cfg(windows)]
250fn has_extension(path: &Path) -> bool {
251 path.extension().is_some()
252}
253
254#[cfg(windows)]
255fn pathext() -> Vec<String> {
256 let raw = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
257 raw.split(';')
258 .map(|s| s.trim().to_string())
259 .filter(|s| !s.is_empty())
260 .collect()
261}
262
263pub fn opencode_version(command: &str) -> Option<String> {
264 let exe = find_executable(command)?;
265 let output = std::process::Command::new(&exe)
266 .arg("--version")
267 .stdout(Stdio::piped())
268 .stderr(Stdio::piped())
269 .output()
270 .ok()?;
271 let stdout = String::from_utf8_lossy(&output.stdout);
272 let line = stdout
273 .lines()
274 .map(str::trim)
275 .find(|l| !l.is_empty())
276 .map(ToOwned::to_owned);
277 if let Some(line) = line {
278 return Some(line);
279 }
280 Some(exe.display().to_string())
281}
282
283fn executor_argv_prompt() -> &'static str {
286 "Read .agentbridge/current.c2c and implement that PLAN. Stay inside this working directory. Run the TESTS. Print a short summary of changed files and test results. Do not paste source or internal reasoning."
287}
288
289fn spawn_opencode(exe: &Path, workspace: &Path, prompt: &str) -> Result<Child, ExecutorError> {
290 let mut cmd = Command::new(exe);
291 cmd.arg("run")
292 .arg("--auto")
293 .arg(prompt)
294 .current_dir(workspace)
295 .stdin(Stdio::null())
296 .stdout(Stdio::piped())
297 .stderr(Stdio::piped())
298 .kill_on_drop(true)
299 .env_remove("AGENTBRIDGE_AUTH_TOKEN");
300
301 #[cfg(windows)]
302 {
303 const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
304 cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
305 }
306
307 cmd.spawn().map_err(|err| {
308 if err.kind() == std::io::ErrorKind::NotFound {
309 ExecutorError::NotInstalled(exe.display().to_string())
310 } else {
311 ExecutorError::Spawn(err.to_string())
312 }
313 })
314}
315
316pub async fn run_spawned(
318 mut child: Child,
319 cancel: tokio_util::sync::CancellationToken,
320 mode: ExecutorMode,
321) -> ExecutorOutcome {
322 let stream_to_stdout = mode == ExecutorMode::Stream;
323 let stdout = child.stdout.take();
324 let stderr = child.stderr.take();
325 let out_buf = tokio::spawn(read_limited(stdout, stream_to_stdout));
326 let err_buf = tokio::spawn(read_limited(stderr, stream_to_stdout));
327
328 enum Finish {
329 Status(std::io::Result<std::process::ExitStatus>),
330 Cancelled,
331 }
332
333 let finish = tokio::select! {
334 _ = cancel.cancelled() => {
335 if let Some(pid) = child.id() {
336 let _ = kill_process_tree(pid);
337 }
338 let _ = child.kill().await;
339 Finish::Cancelled
340 }
341 status = child.wait() => Finish::Status(status),
342 };
343
344 if matches!(finish, Finish::Cancelled) {
345 let _ = child.wait().await;
346 }
347
348 let stdout_bytes = out_buf.await.unwrap_or_default();
349 let stderr_bytes = err_buf.await.unwrap_or_default();
350 let stdout = String::from_utf8_lossy(&stdout_bytes);
351 let stderr = String::from_utf8_lossy(&stderr_bytes);
352 let stdout = strip_reasoning(&stdout);
353 let stderr = strip_reasoning(&stderr);
354
355 match finish {
356 Finish::Cancelled => ExecutorOutcome {
357 exit_code: None,
358 summary: "Executor was cancelled.".into(),
359 tests_excerpt: extract_tests_excerpt(&stdout),
360 cancelled: true,
361 error: None,
362 },
363 Finish::Status(Ok(status)) => {
364 let code = status.code();
365 let crashed = code.is_none() && !status.success();
366 let summary = sanitize_summary(&stdout, &stderr, code, crashed);
367 let error = if crashed {
368 Some("OpenCode process crashed (no exit code).".into())
369 } else if code.unwrap_or(0) != 0 {
370 Some(format!("OpenCode exited with code {}.", code.unwrap_or(-1)))
371 } else {
372 None
373 };
374 ExecutorOutcome {
375 exit_code: code,
376 summary,
377 tests_excerpt: extract_tests_excerpt(&stdout),
378 cancelled: false,
379 error,
380 }
381 }
382 Finish::Status(Err(err)) => ExecutorOutcome {
383 exit_code: None,
384 summary: format!("Executor crashed: {err}"),
385 tests_excerpt: extract_tests_excerpt(&stdout),
386 cancelled: false,
387 error: Some(err.to_string()),
388 },
389 }
390}
391
392async fn read_limited<R: tokio::io::AsyncRead + Unpin + Send + 'static>(
393 reader: Option<R>,
394 stream_to_stdout: bool,
395) -> Vec<u8> {
396 let Some(mut reader) = reader else {
397 return Vec::new();
398 };
399 let mut buf = Vec::new();
400 let mut chunk = [0u8; 4096];
401 loop {
402 match reader.read(&mut chunk).await {
403 Ok(0) => break,
404 Ok(n) => {
405 if stream_to_stdout {
406 use std::io::Write;
407 let _ = std::io::stdout().write_all(&chunk[..n]);
408 let _ = std::io::stdout().flush();
409 }
410 if buf.len() < MAX_CAPTURE_BYTES {
411 let take = n.min(MAX_CAPTURE_BYTES - buf.len());
412 buf.extend_from_slice(&chunk[..take]);
413 }
414 }
415 Err(_) => break,
416 }
417 }
418 buf
419}
420
421pub fn kill_process_tree(pid: u32) -> Result<(), ExecutorError> {
422 #[cfg(windows)]
423 {
424 let _ = std::process::Command::new("taskkill")
425 .args(["/PID", &pid.to_string(), "/T", "/F"])
426 .stdout(Stdio::null())
427 .stderr(Stdio::null())
428 .status();
429 Ok(())
430 }
431 #[cfg(unix)]
432 {
433 let _ = std::process::Command::new("kill")
434 .args(["-TERM", &format!("-{pid}")])
435 .stdout(Stdio::null())
436 .stderr(Stdio::null())
437 .status();
438 let _ = std::process::Command::new("kill")
439 .args(["-TERM", &pid.to_string()])
440 .stdout(Stdio::null())
441 .stderr(Stdio::null())
442 .status();
443 Ok(())
444 }
445}
446
447pub fn process_is_alive(pid: u32) -> bool {
448 if pid == 0 {
449 return false;
450 }
451 #[cfg(windows)]
452 {
453 let output = std::process::Command::new("tasklist")
454 .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
455 .stdout(Stdio::piped())
456 .stderr(Stdio::null())
457 .output();
458 match output {
459 Ok(o) => {
460 let s = String::from_utf8_lossy(&o.stdout);
461 s.split(',')
462 .any(|col| col.trim_matches('"') == pid.to_string())
463 || s.contains(&pid.to_string())
464 }
465 Err(_) => false,
466 }
467 }
468 #[cfg(unix)]
469 {
470 std::process::Command::new("kill")
471 .args(["-0", &pid.to_string()])
472 .stdout(Stdio::null())
473 .stderr(Stdio::null())
474 .status()
475 .map(|s| s.success())
476 .unwrap_or(false)
477 }
478}
479
480fn strip_reasoning(text: &str) -> String {
481 let mut out = String::new();
482 let mut in_think = false;
483 for line in text.lines() {
484 let trimmed = line.trim();
485 if trimmed.contains("<think>") || trimmed.contains("<thinking>") {
486 in_think = true;
487 }
488 if in_think {
489 if trimmed.contains("</think>") || trimmed.contains("</thinking>") {
490 in_think = false;
491 }
492 continue;
493 }
494 if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
495 if is_reasoning_event(&value) {
496 continue;
497 }
498 if let Some(text) = json_text(&value) {
499 if !out.is_empty() {
500 out.push('\n');
501 }
502 out.push_str(&text);
503 continue;
504 }
505 }
506 if trimmed.eq_ignore_ascii_case("thinking")
507 || trimmed.starts_with("thinking:")
508 || trimmed.starts_with("Reasoning:")
509 {
510 continue;
511 }
512 if !out.is_empty() {
513 out.push('\n');
514 }
515 out.push_str(line);
516 }
517 out
518}
519
520fn is_reasoning_event(value: &serde_json::Value) -> bool {
521 let ty = value
522 .get("type")
523 .or_else(|| value.get("kind"))
524 .and_then(|v| v.as_str())
525 .unwrap_or("")
526 .to_ascii_lowercase();
527 matches!(
528 ty.as_str(),
529 "thinking" | "reasoning" | "think" | "internal" | "thought"
530 )
531}
532
533fn json_text(value: &serde_json::Value) -> Option<String> {
534 if let Some(s) = value.get("text").and_then(|v| v.as_str()) {
535 return Some(s.to_string());
536 }
537 if let Some(s) = value.get("response").and_then(|v| v.as_str()) {
538 return Some(s.to_string());
539 }
540 if let Some(s) = value
541 .get("part")
542 .and_then(|v| v.get("text"))
543 .and_then(|v| v.as_str())
544 {
545 return Some(s.to_string());
546 }
547 None
548}
549
550fn sanitize_summary(stdout: &str, stderr: &str, exit_code: Option<i32>, crashed: bool) -> String {
551 if crashed {
552 return truncate_chars(
553 &format!(
554 "OpenCode crashed. {}",
555 stderr.trim().lines().next().unwrap_or("")
556 ),
557 MAX_SUMMARY_CHARS,
558 );
559 }
560 let body = stdout.trim();
561 if !body.is_empty() {
562 return truncate_chars(body, MAX_SUMMARY_CHARS);
563 }
564 let err = stderr.trim();
565 if !err.is_empty() {
566 return truncate_chars(err, MAX_SUMMARY_CHARS);
567 }
568 match exit_code {
569 Some(0) => "OpenCode finished successfully.".into(),
570 Some(code) => format!("OpenCode exited with code {code}."),
571 None => "OpenCode finished.".into(),
572 }
573}
574
575fn extract_tests_excerpt(stdout: &str) -> Option<String> {
576 let mut hits = Vec::new();
577 for line in stdout.lines() {
578 let l = line.trim();
579 let lower = l.to_ascii_lowercase();
580 if lower.contains("test result:")
581 || lower.contains("passed")
582 || lower.contains("failed")
583 || lower.contains("cargo test")
584 || lower.contains("npm test")
585 || lower.contains("pytest")
586 {
587 hits.push(l.to_string());
588 }
589 if hits.len() >= 8 {
590 break;
591 }
592 }
593 if hits.is_empty() {
594 None
595 } else {
596 Some(hits.join("\n"))
597 }
598}
599
600fn truncate_chars(s: &str, max: usize) -> String {
601 if s.chars().count() <= max {
602 return s.to_string();
603 }
604 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
605 out.push('…');
606 out
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn detects_missing_binary() {
615 let cfg = ExecutorConfig {
616 kind: "opencode".into(),
617 command: "opencode-not-installed-agentbridge-xyz".into(),
618 mode: ExecutorMode::Silent,
619 };
620 let exec = OpenCodeExecutor::from_config(&cfg).unwrap();
621 let err = exec.detect().unwrap_err();
622 assert!(matches!(err, ExecutorError::NotInstalled(_)));
623 assert!(err.to_string().contains("not installed"));
624 }
625
626 #[test]
627 fn rejects_unimplemented_types() {
628 let err = validate_executor_type("codex").unwrap_err();
629 assert!(matches!(err, ExecutorError::TypeNotImplemented(_)));
630 let err = validate_executor_type("not-a-real-agent").unwrap_err();
631 assert!(matches!(err, ExecutorError::TypeNotAllowed(_)));
632 validate_executor_type("opencode").unwrap();
633 }
634
635 #[test]
636 fn finds_git_on_path() {
637 assert!(find_executable("git").is_some());
638 }
639
640 #[test]
641 fn strip_reasoning_drops_think_blocks() {
642 let raw = "<think>\nsecret chain of thought\n</think>\ncreated TEST.md\n";
643 let out = strip_reasoning(raw);
644 assert!(!out.contains("chain of thought"));
645 assert!(out.contains("created TEST.md"));
646 }
647}