Skip to main content

scx_forge_agent/
agent_cli.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! Subprocess backends: drive a round's edits by shelling out to the `claude`,
4//! `opencode`, `codex`, or `cursor-agent` CLI in non-interactive mode, run with
5//! the scheduler crate directory as the cwd so the CLI's own tools edit the files
6//! in place. The controller then builds/validates the resulting diff exactly as
7//! it does for the built-in `openai` backend, so keep/revert/dedup work
8//! unchanged.
9//!
10//! Each CLI runs in one of two [`Mode`]s. In [`Mode::Edit`] it edits the crate
11//! in place with its approval/permission prompts disabled and full write access,
12//! so only run that where it is acceptable. In [`Mode::Plan`] it runs read-only
13//! and returns a textual plan that is handed to the coder role: `claude` uses its
14//! `plan` permission mode, `codex` a `read-only` sandbox, and `cursor-agent` its
15//! `plan` mode (all genuinely cannot write); `opencode` uses its built-in `plan`
16//! agent on a best-effort basis and may still touch files (its edits are just
17//! validated/reverted like any other round).
18
19use 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/// How a subprocess backend runs this turn.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Mode {
38    /// Read-only planning turn: produce a textual plan, do not edit (best-effort
39    /// for `opencode`).
40    Plan,
41    /// Make edits in place (approvals/sandbox disabled).
42    Edit,
43}
44
45/// Streams a subprocess backend's assistant text and reasoning to stdout as the
46/// CLI emits it, mirroring the openai backend. This is chunk-level: each CLI
47/// event (text block / part / item) is printed whole as it arrives, not
48/// token-by-token. Reasoning is dimmed to set it apart from the answer. Tool-call
49/// lines are printed separately to stderr and are not handled here.
50struct 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    /// Assistant answer/plan text, printed verbatim.
82    fn text(&mut self, s: &str) -> Result<()> {
83        self.emit(s, s)
84    }
85
86    /// Reasoning / thinking, dimmed to distinguish it from the answer. Trailing
87    /// newline is tracked from the raw text, not the styled (ANSI-wrapped) string.
88    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    /// End the current line if the last chunk did not already. Use after a
97    /// whole-block event (claude/codex) so consecutive blocks don't run together;
98    /// also called once at the end via [`finish`].
99    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/// Run one prompt through the configured subprocess backend in `cwd`, returning
112/// the CLI's final text. In [`Mode::Edit`] this is the round's change summary
113/// (the CLI edits `cwd` directly); in [`Mode::Plan`] it is the plan handed to the
114/// coder. When `stream_stdout` is set the assistant's text and reasoning are
115/// streamed to stdout as the CLI emits them (chunk-level, mirroring the openai
116/// backend). Token usage reported by the CLI is summed into `usage` (reliably for
117/// `claude`; `opencode`/`codex` only when they emit it).
118#[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
225/// Spawn `cmd` with `stdin_payload` piped in, stream stdout line-by-line, call
226/// `on_event` for each JSON line (echoing raw lines to stderr when verbose), and
227/// return the exit status plus captured stderr.
228fn 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    // Drain stderr on a side thread so a chatty stderr can't fill its pipe and
272    // deadlock the process while we read stdout.
273    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
371/// Print a concise tool-call line (matches the openai backend's non-verbose output).
372fn print_tool(name: &str, color: Style) {
373    eprintln!("{} {}", color.dim("  ->"), color.cyan(name));
374}
375
376/// Pick the most informative argument from a tool-call input object (the command
377/// run, or the file/path/pattern it targets), truncated for one-line display.
378fn 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
403/// `name(hint)` when a salient argument is available, else just `name`.
404fn 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
411// --- claude -----------------------------------------------------------------
412
413/// Whether the installed `claude` advertises `--include-partial-messages` (added
414/// in newer versions). Detected once from `claude --help` and cached: older
415/// builds reject unknown flags, so we must not pass it blindly. When absent we
416/// fall back to chunk-level output (the result-event text printed at turn end).
417fn 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        // stream-json with --print needs --verbose to emit per-turn events.
550        .arg("--verbose");
551    // When streaming, ask claude for token-level partial-message events
552    // (content_block_delta) so text appears incrementally instead of as one block
553    // at the end of the turn. Only pass the flag when this claude advertises it -
554    // older builds reject unknown flags; without it we fall back to printing the
555    // result text once at turn end (the safety net below).
556    if stream_stdout && claude_supports_partial_messages() {
557        cmd.arg("--include-partial-messages");
558    }
559    match mode {
560        // Plan mode reads/greps the tree with read-only tools and refuses edits;
561        // the headless `--print` run returns the plan as its result text.
562        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    // NOTE: do NOT pass the system prompt via --append-system-prompt. The claude
573    // CLI re-tokenizes that option's value and parses embedded `--flags` / `---`
574    // out of it, which our prompt (markdown, code with `--`) trips, making claude
575    // print its usage and exit. Prepend it to the stdin payload instead - stdin
576    // is not arg-parsed.
577    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                // Token-level deltas (with --include-partial-messages): stream text
599                // and reasoning to stdout as they are produced.
600                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                        // End the streamed block's line so the next block (or tool
616                        // call) starts fresh.
617                        Some("content_block_stop") => printer.newline(),
618                        _ => {}
619                    }
620                }
621                // The recap assistant message carries tool_use blocks; any text was
622                // already streamed via deltas above, so only surface tool calls here.
623                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                    // The result event carries cumulative token usage for the turn.
643                    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                        // Safety net: if nothing was streamed (deltas unavailable,
660                        // e.g. an older claude without --include-partial-messages),
661                        // print the answer once so the plan/summary is still shown.
662                        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// --- opencode ---------------------------------------------------------------
705
706#[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        // opencode ships a built-in read-only `plan` agent. Best-effort: if a
723        // given install lacks it the run may still edit, which is acceptable -
724        // the controller validates/reverts the diff like any other round.
725        cmd.arg("--agent").arg("plan");
726    }
727    if !model_id.is_empty() {
728        cmd.arg("-m").arg(model_id);
729    }
730    // opencode has no system-prompt flag; prepend it to the user message.
731    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            // Best-effort: capture a `usage` object if the CLI emits one (shape varies).
752            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                    // opencode carries the call args under part.state.input
763                    // (older builds: part.input).
764                    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
817// --- codex ------------------------------------------------------------------
818
819fn 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
829/// codex `command` is a string or an argv array; render it as a single line.
830fn 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
842/// Best-effort changed-file paths from a codex `file_change` item.
843fn 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
866/// A concise label for a codex `item.completed` item, or None for non-tool items
867/// (assistant text / reasoning). Includes the command run or files changed.
868fn 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        // tool_call / mcp_tool_call / function_call and anything else tool-ish.
892        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    // codex writes the final message to a file; use a unique path in the temp dir.
919    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        // A read-only sandbox lets codex inspect the tree but blocks writes; the
936        // final message is the plan.
937        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    // "-" so codex reads the prompt from stdin rather than argv.
949    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            // Best-effort: codex reports cumulative token usage on a token_count /
969            // turn event (top-level `usage`, or `info.total_token_usage`). Keep the
970            // last one seen.
971            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            // Stream assistant text/reasoning to stdout and surface command/tool
979            // execution items (with their command / changed paths) to stderr as
980            // they complete.
981            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
1031// --- cursor-agent -----------------------------------------------------------
1032
1033/// A concise label for a cursor-agent `tool_call` event. The `tool_call` object
1034/// carries the tool under a single `<name>ToolCall` key (e.g. `shellToolCall`,
1035/// `readToolCall`, `editToolCall`) alongside bookkeeping keys like
1036/// `hookAdditionalContexts` / `toolCallId`. serde_json orders object keys
1037/// alphabetically, so pick the `*ToolCall` key explicitly rather than the first
1038/// one; strip the suffix and pull a salient argument (command / path) from `args`.
1039fn 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
1050/// cursor streams a message as fragment deltas (with `--stream-partial-output`),
1051/// then repeats the whole message as one consolidation event whose text equals
1052/// the accumulated fragments. Track the running segment so that consolidation is
1053/// dropped instead of printed a second time. Returns true if `text` is a new
1054/// fragment to print, false if it is the repeat (and the segment is reset, ready
1055/// for the next message).
1056fn 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        // Pin the workspace to the crate dir and trust it so the headless run
1085        // never blocks on a workspace-trust prompt.
1086        .arg("--workspace")
1087        .arg(cwd)
1088        .arg("--trust")
1089        // Auto-approve any configured MCP servers so a headless run never stalls
1090        // waiting to approve them.
1091        .arg("--approve-mcps");
1092    // With --stream-partial-output cursor emits assistant text as incremental
1093    // deltas (each its own assistant event) instead of one block at turn end.
1094    if stream_stdout {
1095        cmd.arg("--stream-partial-output");
1096    }
1097    match mode {
1098        // plan mode is read-only (analyze, propose plans, no edits); the result
1099        // event carries the plan text.
1100        Mode::Plan => {
1101            cmd.arg("--mode").arg("plan");
1102        }
1103        // --force allows every tool call (incl. write/shell) without prompting.
1104        Mode::Edit => {
1105            cmd.arg("--force");
1106        }
1107    }
1108    if !model_id.is_empty() {
1109        cmd.arg("--model").arg(model_id);
1110    }
1111    // cursor-agent reads the prompt from stdin when no positional prompt is given,
1112    // which avoids arg-parsing our markdown/`--`-laden prompt.
1113    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    // Running text/thinking segments, used to drop cursor's per-message
1124    // consolidation events (which repeat the accumulated fragments verbatim).
1125    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                // Assistant text/thinking. With --stream-partial-output cursor
1138                // emits fragment deltas, then repeats the whole message as one
1139                // consolidation event; cursor_is_new_fragment drops that repeat so
1140                // each piece is printed exactly once.
1141                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                // Surface each tool call once, when it starts.
1169                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                        // Safety net: if nothing streamed (deltas disabled), print
1194                        // the answer once so the plan/summary is still shown.
1195                        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        // serde_json sorts keys alphabetically; a tool whose name sorts after
1401        // "hookAdditionalContexts" (e.g. readToolCall/shellToolCall) must still be
1402        // picked over the bookkeeping keys.
1403        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        // Fragment deltas are new text to print.
1423        assert!(cursor_is_new_fragment(&mut seg, "Hello"));
1424        assert!(cursor_is_new_fragment(&mut seg, " world"));
1425        // The consolidation repeats the accumulated text: dropped, segment reset.
1426        assert!(!cursor_is_new_fragment(&mut seg, "Hello world"));
1427        assert!(seg.is_empty());
1428        // The next message starts fresh and prints again.
1429        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}