1use std::io::{BufRead, BufReader, Read, Write};
20use std::os::unix::process::CommandExt;
21use std::path::{Path, PathBuf};
22use std::process::{Child, Command, ExitStatus, Stdio};
23use std::sync::{atomic::AtomicBool, mpsc, Arc};
24use std::time::{Duration, Instant};
25
26use anyhow::{anyhow, Context, Result};
27use serde_json::Value;
28
29use crate::color::Style;
30use crate::config::{Backend, ModelConfig};
31use crate::interrupt;
32use crate::model_timeout::ModelTurnDeadline;
33use crate::usage::Usage;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Mode {
38 Plan,
41 Edit,
43}
44
45struct StreamPrinter {
51 enabled: bool,
52 color: Style,
53 started: bool,
54 last_was_newline: bool,
55}
56
57impl StreamPrinter {
58 fn new(enabled: bool, color: Style) -> Self {
59 Self {
60 enabled,
61 color,
62 started: false,
63 last_was_newline: true,
64 }
65 }
66
67 fn emit(&mut self, to_print: &str, raw: &str) -> Result<()> {
68 if raw.is_empty() {
69 return Ok(());
70 }
71 if !self.enabled {
72 return Ok(());
73 }
74 print!("{to_print}");
75 let _ = std::io::stdout().flush();
76 self.started = true;
77 self.last_was_newline = raw.ends_with('\n');
78 Ok(())
79 }
80
81 fn text(&mut self, s: &str) -> Result<()> {
83 self.emit(s, s)
84 }
85
86 fn reasoning(&mut self, s: &str) -> Result<()> {
89 if s.is_empty() {
90 return Ok(());
91 }
92 let styled = self.color.dim(s);
93 self.emit(&styled, s)
94 }
95
96 fn newline(&mut self) {
100 if self.enabled && self.started && !self.last_was_newline {
101 println!();
102 self.last_was_newline = true;
103 }
104 }
105
106 fn finish(&mut self) {
107 self.newline();
108 }
109}
110
111#[allow(clippy::too_many_arguments)]
119pub async fn run(
120 model: &ModelConfig,
121 mode: Mode,
122 stream_stdout: bool,
123 system: &str,
124 user: &str,
125 cwd: &Path,
126 verbose: bool,
127 color: Style,
128 usage: &mut Usage,
129 turn_timeout: Duration,
130 interrupted: Arc<AtomicBool>,
131) -> Result<String> {
132 let backend = model.backend;
133 let model_id = model.model_id.clone();
134 let system = system.to_string();
135 let user = user.to_string();
136 let cwd = cwd.to_path_buf();
137
138 let join = tokio::task::spawn_blocking(move || match backend {
139 Backend::Claude => invoke_claude(
140 &model_id,
141 mode,
142 stream_stdout,
143 &system,
144 &user,
145 &cwd,
146 verbose,
147 color,
148 turn_timeout,
149 interrupted,
150 ),
151 Backend::Opencode => invoke_opencode(
152 &model_id,
153 mode,
154 stream_stdout,
155 &system,
156 &user,
157 &cwd,
158 verbose,
159 color,
160 turn_timeout,
161 interrupted,
162 ),
163 Backend::Codex => invoke_codex(
164 &model_id,
165 mode,
166 stream_stdout,
167 &system,
168 &user,
169 &cwd,
170 verbose,
171 color,
172 turn_timeout,
173 interrupted,
174 ),
175 Backend::Cursor => invoke_cursor(
176 &model_id,
177 mode,
178 stream_stdout,
179 &system,
180 &user,
181 &cwd,
182 verbose,
183 color,
184 turn_timeout,
185 interrupted,
186 ),
187 Backend::OpenAi => unreachable!("openai backend does not use agent_cli"),
188 })
189 .await;
190
191 match join {
192 Ok(Ok((text, u))) => {
193 usage.add(&u);
194 Ok(text)
195 }
196 Ok(Err(e)) => Err(e),
197 Err(e) => Err(anyhow!("{} worker task failed: {e}", backend.as_str())),
198 }
199}
200
201fn first_line(s: &str) -> &str {
202 s.lines()
203 .find(|l| !l.trim().is_empty())
204 .unwrap_or("(no stderr)")
205}
206
207fn terminate_process_group(child: &mut Child, pid: i32) {
208 unsafe {
209 libc::kill(-pid, libc::SIGINT);
210 }
211 let deadline = Instant::now() + Duration::from_secs(2);
212 loop {
213 match child.try_wait() {
214 Ok(Some(_)) | Err(_) => return,
215 Ok(None) if Instant::now() >= deadline => break,
216 Ok(None) => std::thread::sleep(Duration::from_millis(50)),
217 }
218 }
219 unsafe {
220 libc::kill(-pid, libc::SIGKILL);
221 }
222 let _ = child.wait();
223}
224
225fn run_streaming(
229 mut cmd: Command,
230 label: &str,
231 stdin_payload: &str,
232 verbose: bool,
233 color: Style,
234 turn_timeout: Duration,
235 interrupted: Arc<AtomicBool>,
236 mut on_event: impl FnMut(&Value) -> Result<()>,
237) -> Result<(ExitStatus, String)> {
238 let deadline = ModelTurnDeadline::new(turn_timeout);
239 cmd.stdin(Stdio::piped())
240 .stdout(Stdio::piped())
241 .stderr(Stdio::piped());
242 unsafe {
243 cmd.pre_exec(|| {
244 libc::setsid();
245 Ok(())
246 });
247 }
248 let mut child = cmd
249 .spawn()
250 .with_context(|| format!("spawn `{label}` CLI"))?;
251 let pid = child.id() as i32;
252 {
253 let stdin = child
254 .stdin
255 .as_mut()
256 .with_context(|| format!("{label} CLI: stdin not captured"))?;
257 stdin
258 .write_all(stdin_payload.as_bytes())
259 .with_context(|| format!("{label} CLI: write prompt to stdin"))?;
260 }
261 drop(child.stdin.take());
262
263 let stdout = child
264 .stdout
265 .take()
266 .with_context(|| format!("{label} CLI: stdout not captured"))?;
267 let mut stderr_pipe = child
268 .stderr
269 .take()
270 .with_context(|| format!("{label} CLI: stderr not captured"))?;
271 let stderr_thread = std::thread::spawn(move || {
274 let mut buf = String::new();
275 let _ = stderr_pipe.read_to_string(&mut buf);
276 buf
277 });
278
279 let (tx, rx) = mpsc::channel();
280 let stdout_thread = std::thread::spawn(move || {
281 let reader = BufReader::new(stdout);
282 for line_res in reader.lines() {
283 if tx.send(line_res).is_err() {
284 break;
285 }
286 }
287 });
288
289 let mut handle_line = |line_res: std::io::Result<String>| -> Result<()> {
290 let line = line_res.with_context(|| format!("{label} CLI: read stdout line"))?;
291 if line.trim().is_empty() {
292 return Ok(());
293 }
294 if verbose {
295 eprintln!("{} {line}", color.dim(format!("[{label}] <-")));
296 }
297 if let Ok(v) = serde_json::from_str::<Value>(&line) {
298 on_event(&v)?;
299 }
300 Ok(())
301 };
302
303 let cleanup_after_abort =
304 |child: &mut Child,
305 pid: i32,
306 stdout_thread: std::thread::JoinHandle<()>,
307 stderr_thread: std::thread::JoinHandle<String>| {
308 terminate_process_group(child, pid);
309 let _ = stdout_thread.join();
310 let _ = stderr_thread.join();
311 };
312
313 let status = loop {
314 if interrupt::requested(&interrupted) {
315 terminate_process_group(&mut child, pid);
316 let _ = stdout_thread.join();
317 let _ = stderr_thread.join();
318 return Err(interrupt::err());
319 }
320
321 while let Ok(line_res) = rx.try_recv() {
322 if deadline.expired() {
323 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
324 return Err(deadline.timeout().into());
325 }
326 if let Err(e) = handle_line(line_res) {
327 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
328 return Err(e);
329 }
330 }
331
332 if let Some(status) = child
333 .try_wait()
334 .with_context(|| format!("{label} CLI: poll for completion"))?
335 {
336 break status;
337 }
338
339 if deadline.expired() {
340 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
341 return Err(deadline.timeout().into());
342 }
343
344 match rx.recv_timeout(deadline.remaining().min(Duration::from_millis(100))) {
345 Ok(line_res) => {
346 if let Err(e) = handle_line(line_res) {
347 cleanup_after_abort(&mut child, pid, stdout_thread, stderr_thread);
348 return Err(e);
349 }
350 }
351 Err(mpsc::RecvTimeoutError::Timeout) => {}
352 Err(mpsc::RecvTimeoutError::Disconnected) => {
353 if let Some(status) = child
354 .try_wait()
355 .with_context(|| format!("{label} CLI: poll for completion"))?
356 {
357 break status;
358 }
359 }
360 }
361 };
362
363 let _ = stdout_thread.join();
364 while let Ok(line_res) = rx.try_recv() {
365 handle_line(line_res)?;
366 }
367 let stderr_buf = stderr_thread.join().unwrap_or_default();
368 Ok((status, stderr_buf))
369}
370
371fn print_tool(name: &str, color: Style) {
373 eprintln!("{} {}", color.dim(" ->"), color.cyan(name));
374}
375
376fn arg_hint(input: Option<&Value>) -> Option<String> {
379 let input = input?;
380 if let Some(c) = input.get("command") {
381 let c = format_command(c);
382 if !c.is_empty() {
383 return Some(truncate(&c, 100));
384 }
385 }
386 for key in [
387 "file_path",
388 "filePath",
389 "path",
390 "relativePath",
391 "pattern",
392 "query",
393 ] {
394 if let Some(s) = input.get(key).and_then(|v| v.as_str()) {
395 if !s.is_empty() {
396 return Some(truncate(s, 100));
397 }
398 }
399 }
400 None
401}
402
403fn tool_label(name: &str, input: Option<&Value>) -> String {
405 match arg_hint(input) {
406 Some(h) => format!("{name}({h})"),
407 None => name.to_string(),
408 }
409}
410
411fn claude_supports_partial_messages() -> bool {
418 static SUPPORTED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
419 *SUPPORTED.get_or_init(|| {
420 Command::new("claude")
421 .arg("--help")
422 .output()
423 .ok()
424 .map(|o| {
425 let out = String::from_utf8_lossy(&o.stdout);
426 let err = String::from_utf8_lossy(&o.stderr);
427 out.contains("--include-partial-messages")
428 || err.contains("--include-partial-messages")
429 })
430 .unwrap_or(false)
431 })
432}
433
434#[derive(Default)]
435struct ClaudePlanCapture {
436 exit_plan: Option<String>,
437 write_plan: Option<String>,
438 plan_path: Option<PathBuf>,
439}
440
441fn nonempty_string(v: Option<&Value>) -> Option<String> {
442 v.and_then(|v| v.as_str())
443 .filter(|s| !s.trim().is_empty())
444 .map(ToString::to_string)
445}
446
447fn claude_plan_path(input: &Value) -> Option<PathBuf> {
448 for key in ["planFilePath", "plan_file_path", "file_path", "path"] {
449 if let Some(path) = input.get(key).and_then(|v| v.as_str()) {
450 let path = PathBuf::from(path);
451 if is_claude_plan_path(&path) {
452 return Some(path);
453 }
454 }
455 }
456 None
457}
458
459fn is_claude_plan_path(path: &Path) -> bool {
460 if path.extension().and_then(|e| e.to_str()) != Some("md") {
461 return false;
462 }
463
464 let mut prev_was_claude = false;
465 for component in path.components() {
466 let Some(component) = component.as_os_str().to_str() else {
467 prev_was_claude = false;
468 continue;
469 };
470 if prev_was_claude && component == "plans" {
471 return true;
472 }
473 prev_was_claude = component == ".claude";
474 }
475 false
476}
477
478impl ClaudePlanCapture {
479 fn observe_tool_use(&mut self, name: &str, input: Option<&Value>) {
480 let Some(input) = input else {
481 return;
482 };
483
484 if name.eq_ignore_ascii_case("ExitPlanMode") {
485 if let Some(plan) = nonempty_string(input.get("plan")) {
486 self.exit_plan = Some(plan);
487 }
488 if let Some(path) = claude_plan_path(input) {
489 self.plan_path = Some(path);
490 }
491 } else if name.eq_ignore_ascii_case("Write") {
492 if let Some(path) = claude_plan_path(input) {
493 self.plan_path = Some(path);
494 if let Some(plan) = nonempty_string(input.get("content")) {
495 self.write_plan = Some(plan);
496 }
497 }
498 }
499 }
500
501 fn resolve(&self) -> Result<Option<String>> {
502 if let Some(plan) = self.exit_plan.as_deref().filter(|s| !s.trim().is_empty()) {
503 return Ok(Some(plan.to_string()));
504 }
505 if let Some(plan) = self.write_plan.as_deref().filter(|s| !s.trim().is_empty()) {
506 return Ok(Some(plan.to_string()));
507 }
508 if let Some(path) = &self.plan_path {
509 let plan = std::fs::read_to_string(path)
510 .with_context(|| format!("read claude plan file {}", path.display()))?;
511 if !plan.trim().is_empty() {
512 return Ok(Some(plan));
513 }
514 }
515 Ok(None)
516 }
517}
518
519fn print_recovered_claude_plan(
520 printer: &mut StreamPrinter,
521 stream_stdout: bool,
522 plan: &str,
523) -> Result<()> {
524 if stream_stdout {
525 printer.text(plan)?;
526 printer.newline();
527 }
528 Ok(())
529}
530
531#[allow(clippy::too_many_arguments)]
532fn invoke_claude(
533 model_id: &str,
534 mode: Mode,
535 stream_stdout: bool,
536 system: &str,
537 user: &str,
538 cwd: &PathBuf,
539 verbose: bool,
540 color: Style,
541 turn_timeout: Duration,
542 interrupted: Arc<AtomicBool>,
543) -> Result<(String, Usage)> {
544 let mut cmd = Command::new("claude");
545 cmd.current_dir(cwd)
546 .arg("--print")
547 .arg("--output-format")
548 .arg("stream-json")
549 .arg("--verbose");
551 if stream_stdout && claude_supports_partial_messages() {
557 cmd.arg("--include-partial-messages");
558 }
559 match mode {
560 Mode::Plan => {
563 cmd.arg("--permission-mode").arg("plan");
564 }
565 Mode::Edit => {
566 cmd.arg("--dangerously-skip-permissions");
567 }
568 }
569 if !model_id.is_empty() {
570 cmd.arg("--model").arg(model_id);
571 }
572 let payload = if system.is_empty() {
578 user.to_string()
579 } else {
580 format!("{system}\n\n{user}")
581 };
582
583 let mut final_text: Option<String> = None;
584 let mut error: Option<String> = None;
585 let mut usage = Usage::default();
586 let mut plan_capture = ClaudePlanCapture::default();
587 let mut printer = StreamPrinter::new(stream_stdout, color);
588 let (status, stderr_buf) = run_streaming(
589 cmd,
590 "claude",
591 &payload,
592 verbose,
593 color,
594 turn_timeout,
595 interrupted,
596 |v| -> Result<()> {
597 match v.get("type").and_then(|t| t.as_str()) {
598 Some("stream_event") => {
601 let event = v.get("event");
602 let etype = event.and_then(|e| e.get("type")).and_then(|t| t.as_str());
603 match etype {
604 Some("content_block_delta") => {
605 if let Some(delta) = event.and_then(|e| e.get("delta")) {
606 if let Some(t) = delta.get("text").and_then(|t| t.as_str()) {
607 printer.text(t)?;
608 } else if let Some(t) =
609 delta.get("thinking").and_then(|t| t.as_str())
610 {
611 printer.reasoning(t)?;
612 }
613 }
614 }
615 Some("content_block_stop") => printer.newline(),
618 _ => {}
619 }
620 }
621 Some("assistant") => {
624 if let Some(blocks) = v
625 .get("message")
626 .and_then(|m| m.get("content"))
627 .and_then(|c| c.as_array())
628 {
629 for b in blocks {
630 if b.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
631 let name = b.get("name").and_then(|n| n.as_str()).unwrap_or("tool");
632 let input = b.get("input");
633 print_tool(&tool_label(name, input), color);
634 if mode == Mode::Plan {
635 plan_capture.observe_tool_use(name, input);
636 }
637 }
638 }
639 }
640 }
641 Some("result") => {
642 if let Some(u) = v.get("usage") {
644 usage = Usage::from_anthropic(u);
645 }
646 if v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false) {
647 error = Some(
648 v.get("result")
649 .and_then(|x| x.as_str())
650 .unwrap_or("(no message)")
651 .to_string(),
652 );
653 } else {
654 let result = v
655 .get("result")
656 .and_then(|x| x.as_str())
657 .unwrap_or("")
658 .to_string();
659 if !printer.started {
663 printer.text(&result)?;
664 printer.newline();
665 }
666 final_text = Some(result);
667 }
668 }
669 _ => {}
670 }
671 Ok(())
672 },
673 )?;
674 printer.finish();
675 printer.finish();
676
677 if let Some(e) = error {
678 anyhow::bail!("claude CLI reported error: {e}");
679 }
680 let recovered_plan = if mode == Mode::Plan {
681 plan_capture.resolve()?
682 } else {
683 None
684 };
685 let Some(mut text) = final_text else {
686 if let Some(plan) = recovered_plan {
687 print_recovered_claude_plan(&mut printer, stream_stdout, &plan)?;
688 return Ok((plan, usage));
689 }
690 anyhow::bail!(
691 "claude CLI exited with {status} without a result event; stderr: {}",
692 first_line(&stderr_buf)
693 );
694 };
695 if text.trim().is_empty() {
696 if let Some(plan) = recovered_plan {
697 print_recovered_claude_plan(&mut printer, stream_stdout, &plan)?;
698 text = plan;
699 }
700 }
701 Ok((text, usage))
702}
703
704#[allow(clippy::too_many_arguments)]
707fn invoke_opencode(
708 model_id: &str,
709 mode: Mode,
710 stream_stdout: bool,
711 system: &str,
712 user: &str,
713 cwd: &PathBuf,
714 verbose: bool,
715 color: Style,
716 turn_timeout: Duration,
717 interrupted: Arc<AtomicBool>,
718) -> Result<(String, Usage)> {
719 let mut cmd = Command::new("opencode");
720 cmd.current_dir(cwd).arg("run").arg("--format").arg("json");
721 if mode == Mode::Plan {
722 cmd.arg("--agent").arg("plan");
726 }
727 if !model_id.is_empty() {
728 cmd.arg("-m").arg(model_id);
729 }
730 let payload = if system.is_empty() {
732 user.to_string()
733 } else {
734 format!("{system}\n\n{user}")
735 };
736
737 let mut text_parts: Vec<String> = Vec::new();
738 let mut saw_event = false;
739 let mut usage = Usage::default();
740 let mut printer = StreamPrinter::new(stream_stdout, color);
741 let (status, stderr_buf) = run_streaming(
742 cmd,
743 "opencode",
744 &payload,
745 verbose,
746 color,
747 turn_timeout,
748 interrupted,
749 |v| -> Result<()> {
750 saw_event = true;
751 if let Some(u) = v.get("usage") {
753 usage = Usage::from_any(u);
754 }
755 match v.get("type").and_then(|t| t.as_str()) {
756 Some("tool_use") => {
757 let part = v.get("part");
758 let name = part
759 .and_then(|p| p.get("tool"))
760 .and_then(|t| t.as_str())
761 .unwrap_or("tool");
762 let input = part
765 .and_then(|p| p.get("state").and_then(|s| s.get("input")))
766 .or_else(|| part.and_then(|p| p.get("input")));
767 print_tool(&tool_label(name, input), color);
768 }
769 Some("text") => {
770 if let Some(part) = v.get("part") {
771 let synthetic = part
772 .get("synthetic")
773 .and_then(|x| x.as_bool())
774 .unwrap_or(false)
775 || part
776 .get("metadata")
777 .and_then(|m| m.get("compaction_continue"))
778 .and_then(|x| x.as_bool())
779 .unwrap_or(false);
780 if !synthetic {
781 if let Some(t) = part.get("text").and_then(|t| t.as_str()) {
782 if !t.is_empty() {
783 text_parts.push(t.to_string());
784 printer.text(t)?;
785 printer.newline();
786 }
787 }
788 }
789 }
790 }
791 Some("reasoning") => {
792 if let Some(t) = v
793 .get("part")
794 .and_then(|p| p.get("text"))
795 .and_then(|t| t.as_str())
796 {
797 printer.reasoning(t)?;
798 printer.newline();
799 }
800 }
801 _ => {}
802 }
803 Ok(())
804 },
805 )?;
806 printer.finish();
807
808 if !saw_event {
809 anyhow::bail!(
810 "opencode CLI exited with {status} without any events; stderr: {}",
811 first_line(&stderr_buf)
812 );
813 }
814 Ok((text_parts.join("\n\n"), usage))
815}
816
817fn truncate(s: &str, max: usize) -> String {
820 let s = s.trim();
821 if s.chars().count() > max {
822 let head: String = s.chars().take(max).collect();
823 format!("{head}...")
824 } else {
825 s.to_string()
826 }
827}
828
829fn format_command(v: &Value) -> String {
831 match v {
832 Value::String(s) => s.replace('\n', " "),
833 Value::Array(parts) => parts
834 .iter()
835 .filter_map(|p| p.as_str())
836 .collect::<Vec<_>>()
837 .join(" "),
838 _ => String::new(),
839 }
840}
841
842fn codex_file_paths(item: &Value) -> Vec<String> {
844 if let Some(p) = item.get("path").and_then(|p| p.as_str()) {
845 return vec![p.to_string()];
846 }
847 for key in ["changes", "files"] {
848 if let Some(arr) = item.get(key).and_then(|c| c.as_array()) {
849 let paths: Vec<String> = arr
850 .iter()
851 .filter_map(|c| {
852 c.get("path")
853 .and_then(|p| p.as_str())
854 .or_else(|| c.as_str())
855 .map(|s| s.to_string())
856 })
857 .collect();
858 if !paths.is_empty() {
859 return paths;
860 }
861 }
862 }
863 Vec::new()
864}
865
866fn codex_item_label(item: &Value) -> Option<String> {
869 let ty = item
870 .get("type")
871 .or_else(|| item.get("item_type"))
872 .and_then(|t| t.as_str())?;
873 match ty {
874 "agent_message" | "reasoning" => None,
875 "command_execution" => {
876 let cmd = item.get("command").map(format_command).unwrap_or_default();
877 Some(if cmd.is_empty() {
878 ty.to_string()
879 } else {
880 format!("{ty}: {}", truncate(&cmd, 120))
881 })
882 }
883 "file_change" => {
884 let paths = codex_file_paths(item);
885 Some(if paths.is_empty() {
886 ty.to_string()
887 } else {
888 format!("{ty}: {}", paths.join(", "))
889 })
890 }
891 other => {
893 let name = item
894 .get("name")
895 .or_else(|| item.get("tool"))
896 .and_then(|n| n.as_str());
897 Some(match name {
898 Some(n) if !n.is_empty() => format!("{other}: {n}"),
899 _ => other.to_string(),
900 })
901 }
902 }
903}
904
905#[allow(clippy::too_many_arguments)]
906fn invoke_codex(
907 model_id: &str,
908 mode: Mode,
909 stream_stdout: bool,
910 system: &str,
911 user: &str,
912 cwd: &PathBuf,
913 verbose: bool,
914 color: Style,
915 turn_timeout: Duration,
916 interrupted: Arc<AtomicBool>,
917) -> Result<(String, Usage)> {
918 let out_path = std::env::temp_dir().join(format!("scx-forge-codex-{}.txt", std::process::id()));
920 let _ = std::fs::remove_file(&out_path);
921
922 let mut cmd = Command::new("codex");
923 cmd.current_dir(cwd)
924 .arg("--ask-for-approval")
925 .arg("never")
926 .arg("exec")
927 .arg("--json")
928 .arg("--color")
929 .arg("never")
930 .arg("--output-last-message")
931 .arg(&out_path)
932 .arg("-C")
933 .arg(cwd);
934 match mode {
935 Mode::Plan => {
938 cmd.arg("--sandbox").arg("read-only");
939 }
940 Mode::Edit => {
941 cmd.arg("--dangerously-bypass-approvals-and-sandbox");
942 }
943 }
944 cmd.arg("--skip-git-repo-check");
945 if !model_id.is_empty() {
946 cmd.arg("--model").arg(model_id);
947 }
948 cmd.arg("-");
950
951 let payload = if system.is_empty() {
952 user.to_string()
953 } else {
954 format!("{system}\n\n{user}")
955 };
956
957 let mut usage = Usage::default();
958 let mut printer = StreamPrinter::new(stream_stdout, color);
959 let (status, stderr_buf) = run_streaming(
960 cmd,
961 "codex",
962 &payload,
963 verbose,
964 color,
965 turn_timeout,
966 interrupted,
967 |v| -> Result<()> {
968 let u = v
972 .get("usage")
973 .or_else(|| v.get("info").and_then(|i| i.get("total_token_usage")))
974 .or_else(|| v.get("info").and_then(|i| i.get("usage")));
975 if let Some(u) = u {
976 usage = Usage::from_any(u);
977 }
978 if v.get("type").and_then(|t| t.as_str()) == Some("item.completed") {
982 if let Some(item) = v.get("item") {
983 let ty = item
984 .get("type")
985 .or_else(|| item.get("item_type"))
986 .and_then(|t| t.as_str());
987 match ty {
988 Some("agent_message") => {
989 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
990 printer.text(t)?;
991 printer.newline();
992 }
993 }
994 Some("reasoning") => {
995 if let Some(t) = item.get("text").and_then(|t| t.as_str()) {
996 printer.reasoning(t)?;
997 printer.newline();
998 }
999 }
1000 _ => {
1001 if let Some(label) = codex_item_label(item) {
1002 print_tool(&label, color);
1003 }
1004 }
1005 }
1006 }
1007 }
1008 Ok(())
1009 },
1010 )?;
1011 printer.finish();
1012
1013 let final_text = std::fs::read_to_string(&out_path).unwrap_or_default();
1014 let _ = std::fs::remove_file(&out_path);
1015
1016 if !status.success() {
1017 anyhow::bail!(
1018 "codex CLI exited with {status}; stderr: {}",
1019 first_line(&stderr_buf)
1020 );
1021 }
1022 if final_text.trim().is_empty() {
1023 anyhow::bail!(
1024 "codex CLI produced no final message; stderr: {}",
1025 first_line(&stderr_buf)
1026 );
1027 }
1028 Ok((final_text, usage))
1029}
1030
1031fn cursor_tool_label(tc: &Value) -> String {
1040 if let Some((key, body)) = tc
1041 .as_object()
1042 .and_then(|m| m.iter().find(|(k, _)| k.ends_with("ToolCall")))
1043 {
1044 let name = key.strip_suffix("ToolCall").unwrap_or(key);
1045 return tool_label(name, body.get("args"));
1046 }
1047 "tool".to_string()
1048}
1049
1050fn cursor_is_new_fragment(segment: &mut String, text: &str) -> bool {
1057 if !segment.is_empty() && text == *segment {
1058 segment.clear();
1059 false
1060 } else {
1061 segment.push_str(text);
1062 true
1063 }
1064}
1065
1066#[allow(clippy::too_many_arguments)]
1067fn invoke_cursor(
1068 model_id: &str,
1069 mode: Mode,
1070 stream_stdout: bool,
1071 system: &str,
1072 user: &str,
1073 cwd: &PathBuf,
1074 verbose: bool,
1075 color: Style,
1076 turn_timeout: Duration,
1077 interrupted: Arc<AtomicBool>,
1078) -> Result<(String, Usage)> {
1079 let mut cmd = Command::new("cursor-agent");
1080 cmd.current_dir(cwd)
1081 .arg("--print")
1082 .arg("--output-format")
1083 .arg("stream-json")
1084 .arg("--workspace")
1087 .arg(cwd)
1088 .arg("--trust")
1089 .arg("--approve-mcps");
1092 if stream_stdout {
1095 cmd.arg("--stream-partial-output");
1096 }
1097 match mode {
1098 Mode::Plan => {
1101 cmd.arg("--mode").arg("plan");
1102 }
1103 Mode::Edit => {
1105 cmd.arg("--force");
1106 }
1107 }
1108 if !model_id.is_empty() {
1109 cmd.arg("--model").arg(model_id);
1110 }
1111 let payload = if system.is_empty() {
1114 user.to_string()
1115 } else {
1116 format!("{system}\n\n{user}")
1117 };
1118
1119 let mut final_text: Option<String> = None;
1120 let mut error: Option<String> = None;
1121 let mut usage = Usage::default();
1122 let mut printer = StreamPrinter::new(stream_stdout, color);
1123 let mut text_seg = String::new();
1126 let mut think_seg = String::new();
1127 let (status, stderr_buf) = run_streaming(
1128 cmd,
1129 "cursor-agent",
1130 &payload,
1131 verbose,
1132 color,
1133 turn_timeout,
1134 interrupted,
1135 |v| -> Result<()> {
1136 match v.get("type").and_then(|t| t.as_str()) {
1137 Some("assistant") => {
1142 if let Some(blocks) = v
1143 .get("message")
1144 .and_then(|m| m.get("content"))
1145 .and_then(|c| c.as_array())
1146 {
1147 for b in blocks {
1148 let text = b.get("text").and_then(|t| t.as_str()).unwrap_or("");
1149 if text.is_empty() {
1150 continue;
1151 }
1152 match b.get("type").and_then(|t| t.as_str()) {
1153 Some("text") => {
1154 if cursor_is_new_fragment(&mut text_seg, text) {
1155 printer.text(text)?;
1156 }
1157 }
1158 Some("thinking") | Some("reasoning") => {
1159 if cursor_is_new_fragment(&mut think_seg, text) {
1160 printer.reasoning(text)?;
1161 }
1162 }
1163 _ => {}
1164 }
1165 }
1166 }
1167 }
1168 Some("tool_call") => {
1170 if v.get("subtype").and_then(|s| s.as_str()) == Some("started") {
1171 if let Some(tc) = v.get("tool_call") {
1172 print_tool(&cursor_tool_label(tc), color);
1173 }
1174 }
1175 }
1176 Some("result") => {
1177 if let Some(u) = v.get("usage") {
1178 usage = Usage::from_cursor(u);
1179 }
1180 if v.get("is_error").and_then(|x| x.as_bool()).unwrap_or(false) {
1181 error = Some(
1182 v.get("result")
1183 .and_then(|x| x.as_str())
1184 .unwrap_or("(no message)")
1185 .to_string(),
1186 );
1187 } else {
1188 let result = v
1189 .get("result")
1190 .and_then(|x| x.as_str())
1191 .unwrap_or("")
1192 .to_string();
1193 if !printer.started {
1196 printer.text(&result)?;
1197 printer.newline();
1198 }
1199 final_text = Some(result);
1200 }
1201 }
1202 _ => {}
1203 }
1204 Ok(())
1205 },
1206 )?;
1207 printer.finish();
1208
1209 if let Some(e) = error {
1210 anyhow::bail!("cursor-agent CLI reported error: {e}");
1211 }
1212 if !status.success() {
1213 anyhow::bail!(
1214 "cursor-agent CLI exited with {status}; stderr: {}",
1215 first_line(&stderr_buf)
1216 );
1217 }
1218 let text = final_text.ok_or_else(|| {
1219 anyhow!(
1220 "cursor-agent CLI exited with {status} without a result event; stderr: {}",
1221 first_line(&stderr_buf)
1222 )
1223 })?;
1224 Ok((text, usage))
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230 use serde_json::json;
1231
1232 #[test]
1233 fn codex_command_string_label() {
1234 let item = json!({"type": "command_execution", "command": "cargo build -p scx_forge"});
1235 assert_eq!(
1236 codex_item_label(&item).unwrap(),
1237 "command_execution: cargo build -p scx_forge"
1238 );
1239 }
1240
1241 #[test]
1242 fn codex_command_argv_label() {
1243 let item = json!({"type": "command_execution", "command": ["grep", "-n", "foo", "x.c"]});
1244 assert_eq!(
1245 codex_item_label(&item).unwrap(),
1246 "command_execution: grep -n foo x.c"
1247 );
1248 }
1249
1250 #[test]
1251 fn codex_file_change_path_label() {
1252 let item = json!({"type": "file_change", "path": "src/bpf/main.bpf.c"});
1253 assert_eq!(
1254 codex_item_label(&item).unwrap(),
1255 "file_change: src/bpf/main.bpf.c"
1256 );
1257 }
1258
1259 #[test]
1260 fn codex_file_change_changes_array_label() {
1261 let item = json!({"type": "file_change",
1262 "changes": [{"path": "a.c", "kind": "modify"}, {"path": "b.h"}]});
1263 assert_eq!(codex_item_label(&item).unwrap(), "file_change: a.c, b.h");
1264 }
1265
1266 #[test]
1267 fn codex_agent_message_has_no_label() {
1268 let item = json!({"type": "agent_message", "text": "done"});
1269 assert_eq!(codex_item_label(&item), None);
1270 }
1271
1272 #[test]
1273 fn codex_long_command_truncated() {
1274 let item = json!({"type": "command_execution", "command": "x".repeat(200)});
1275 let label = codex_item_label(&item).unwrap();
1276 assert!(label.ends_with("..."));
1277 assert!(label.len() < 160);
1278 }
1279
1280 #[test]
1281 fn tool_label_uses_file_path() {
1282 let input = json!({"file_path": "src/bpf/main.bpf.c", "old_string": "a"});
1283 assert_eq!(tool_label("Edit", Some(&input)), "Edit(src/bpf/main.bpf.c)");
1284 }
1285
1286 #[test]
1287 fn tool_label_uses_command_then_pattern() {
1288 assert_eq!(
1289 tool_label("Bash", Some(&json!({"command": "cargo build"}))),
1290 "Bash(cargo build)"
1291 );
1292 assert_eq!(
1293 tool_label("Grep", Some(&json!({"pattern": "budget_slice"}))),
1294 "Grep(budget_slice)"
1295 );
1296 }
1297
1298 #[test]
1299 fn tool_label_without_args_is_bare_name() {
1300 assert_eq!(tool_label("read", None), "read");
1301 assert_eq!(tool_label("read", Some(&json!({}))), "read");
1302 }
1303
1304 #[test]
1305 fn claude_plan_capture_prefers_exit_plan_text() {
1306 let mut capture = ClaudePlanCapture::default();
1307 capture.observe_tool_use(
1308 "Write",
1309 Some(&json!({
1310 "file_path": "/home/me/.claude/plans/example.md",
1311 "content": "plan from write"
1312 })),
1313 );
1314 capture.observe_tool_use(
1315 "ExitPlanMode",
1316 Some(&json!({
1317 "plan": "plan from exit",
1318 "planFilePath": "/home/me/.claude/plans/example.md"
1319 })),
1320 );
1321
1322 assert_eq!(capture.resolve().unwrap().unwrap(), "plan from exit");
1323 }
1324
1325 #[test]
1326 fn claude_plan_capture_uses_write_content_for_plan_file() {
1327 let mut capture = ClaudePlanCapture::default();
1328 capture.observe_tool_use(
1329 "Write",
1330 Some(&json!({
1331 "file_path": "/home/me/.claude/plans/example.md",
1332 "content": "plan from write"
1333 })),
1334 );
1335
1336 assert_eq!(capture.resolve().unwrap().unwrap(), "plan from write");
1337 }
1338
1339 #[test]
1340 fn claude_plan_capture_reads_plan_file_when_content_missing() {
1341 let unique = std::time::SystemTime::now()
1342 .duration_since(std::time::UNIX_EPOCH)
1343 .unwrap()
1344 .as_nanos();
1345 let root = std::env::temp_dir().join(format!(
1346 "scx-forge-agent-claude-plan-test-{}-{unique}",
1347 std::process::id()
1348 ));
1349 let plan_dir = root.join(".claude").join("plans");
1350 let plan_path = plan_dir.join("example.md");
1351 std::fs::create_dir_all(&plan_dir).unwrap();
1352 std::fs::write(&plan_path, "plan from file").unwrap();
1353
1354 let mut capture = ClaudePlanCapture::default();
1355 capture.observe_tool_use(
1356 "ExitPlanMode",
1357 Some(&json!({
1358 "planFilePath": plan_path
1359 })),
1360 );
1361
1362 assert_eq!(capture.resolve().unwrap().unwrap(), "plan from file");
1363 let _ = std::fs::remove_dir_all(root);
1364 }
1365
1366 #[test]
1367 fn claude_plan_capture_ignores_non_plan_writes() {
1368 let mut capture = ClaudePlanCapture::default();
1369 capture.observe_tool_use(
1370 "Write",
1371 Some(&json!({
1372 "file_path": "/tmp/not-a-claude-plan.md",
1373 "content": "not a plan file"
1374 })),
1375 );
1376
1377 assert_eq!(capture.resolve().unwrap(), None);
1378 }
1379
1380 #[test]
1381 fn cursor_shell_tool_label() {
1382 let tc = json!({"shellToolCall": {"args": {"command": "cargo build -p scx_forge"}}});
1383 assert_eq!(cursor_tool_label(&tc), "shell(cargo build -p scx_forge)");
1384 }
1385
1386 #[test]
1387 fn cursor_edit_tool_label_uses_path() {
1388 let tc = json!({"editToolCall": {"args": {"relativePath": "src/bpf/main.bpf.c"}}});
1389 assert_eq!(cursor_tool_label(&tc), "edit(src/bpf/main.bpf.c)");
1390 }
1391
1392 #[test]
1393 fn cursor_tool_label_without_args_is_bare_name() {
1394 let tc = json!({"lsToolCall": {}});
1395 assert_eq!(cursor_tool_label(&tc), "ls");
1396 }
1397
1398 #[test]
1399 fn cursor_tool_label_ignores_bookkeeping_keys() {
1400 let tc = json!({
1404 "hookAdditionalContexts": [],
1405 "startedAtMs": "123",
1406 "toolCallId": "abc",
1407 "readToolCall": {"args": {"path": "src/main.rs"}},
1408 });
1409 assert_eq!(cursor_tool_label(&tc), "read(src/main.rs)");
1410
1411 let tc = json!({
1412 "hookAdditionalContexts": [],
1413 "shellToolCall": {"args": {"command": "cargo test"}},
1414 "toolCallId": "x",
1415 });
1416 assert_eq!(cursor_tool_label(&tc), "shell(cargo test)");
1417 }
1418
1419 #[test]
1420 fn cursor_fragment_dedup_drops_consolidation() {
1421 let mut seg = String::new();
1422 assert!(cursor_is_new_fragment(&mut seg, "Hello"));
1424 assert!(cursor_is_new_fragment(&mut seg, " world"));
1425 assert!(!cursor_is_new_fragment(&mut seg, "Hello world"));
1427 assert!(seg.is_empty());
1428 assert!(cursor_is_new_fragment(&mut seg, "Hello world"));
1430 assert!(cursor_is_new_fragment(&mut seg, "!"));
1431 assert!(!cursor_is_new_fragment(&mut seg, "Hello world!"));
1432 }
1433}