diff --git a/crates/forge_app/src/agent_executor.rs b/crates/forge_app/src/agent_executor.rs index fe92b7c7d4..6c065b2209 100644 --- a/crates/forge_app/src/agent_executor.rs +++ b/crates/forge_app/src/agent_executor.rs @@ -75,12 +75,12 @@ impl> AgentEx .await?; conversation }; - // Execute the request through the ForgeApp + // Execute the request through the ForgeApp, propagating silent mode. let app = crate::ForgeApp::new(self.services.clone()); let mut response_stream = app .chat( agent_id.clone(), - ChatRequest::new(Event::new(task.clone()), conversation.id), + ChatRequest::new(Event::new(task.clone()), conversation.id).tool_silent(ctx.silent), ) .await?; diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index d53b3c5b7e..8a811500bc 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -180,7 +180,8 @@ impl> ForgeAp .error_tracker(ToolErrorTracker::new(max_tool_failure_per_turn)) .tool_definitions(tool_definitions) .models(models) - .hook(Arc::new(hook)); + .hook(Arc::new(hook)) + .tool_silent(chat.tool_silent); // Create and return the stream let stream = MpscStream::spawn( diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index e63ce75f1e..06b35da769 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -26,6 +26,13 @@ pub struct Orchestrator { error_tracker: ToolErrorTracker, hook: Arc, config: forge_config::ForgeConfig, + /// When `true`, shell tool output is suppressed on stdout to avoid + /// contaminating the ACP JSON-RPC transport. + /// + /// Set from [`ChatRequest::tool_silent`] during `ForgeApp::chat()`. This field + /// is consumed in [`Orchestrator::run()`] when constructing the + /// [`ToolCallContext`]. + tool_silent: bool, } impl> Orchestrator { @@ -45,6 +52,7 @@ impl> Orc models: Default::default(), error_tracker: Default::default(), hook: Arc::new(Hook::default()), + tool_silent: false, } } @@ -262,8 +270,11 @@ impl> Orc // Retrieve the number of requests allowed per tick. let max_requests_per_turn = self.agent.max_requests_per_turn; - let tool_context = - ToolCallContext::new(self.conversation.metrics.clone()).sender(self.sender.clone()); + // Propagate silent mode to ToolCallContext. + // See designs/acp-silent-mode-propagation.md. + let tool_context = ToolCallContext::new(self.conversation.metrics.clone()) + .sender(self.sender.clone()) + .silent(self.tool_silent); while !should_yield { // Set context for the current loop iteration diff --git a/crates/forge_app/src/tool_executor.rs b/crates/forge_app/src/tool_executor.rs index e409fb4a2c..be35bcb345 100644 --- a/crates/forge_app/src/tool_executor.rs +++ b/crates/forge_app/src/tool_executor.rs @@ -277,7 +277,7 @@ impl< input.command.clone(), PathBuf::from(normalized_cwd), input.keep_ansi, - false, + context.silent, input.env.clone(), input.description.clone(), ) diff --git a/crates/forge_domain/src/chat_request.rs b/crates/forge_domain/src/chat_request.rs index 2b9b582494..245579817e 100644 --- a/crates/forge_domain/src/chat_request.rs +++ b/crates/forge_domain/src/chat_request.rs @@ -8,10 +8,14 @@ use crate::{ConversationId, Event}; pub struct ChatRequest { pub event: Event, pub conversation_id: ConversationId, + /// When `true`, shell tool output is suppressed on stdout (routed to + /// `io::sink()`) to protect the ACP JSON-RPC transport. + /// See `designs/acp-silent-mode-propagation.md`. + pub tool_silent: bool, } impl ChatRequest { pub fn new(content: Event, conversation_id: ConversationId) -> Self { - Self { event: content, conversation_id } + Self { event: content, conversation_id, tool_silent: false } } } diff --git a/crates/forge_domain/src/tools/call/context.rs b/crates/forge_domain/src/tools/call/context.rs index b9625e7fb6..f05f0f5b05 100644 --- a/crates/forge_domain/src/tools/call/context.rs +++ b/crates/forge_domain/src/tools/call/context.rs @@ -9,12 +9,21 @@ use crate::{ArcSender, ChatResponse, Metrics, TitleFormat, Todo, TodoItem}; pub struct ToolCallContext { sender: Option, metrics: Arc>, + /// When set to true, shell tool output is streamed to io::sink() instead + /// of io::stdout() to avoid contaminating the ACP JSON-RPC transport + /// channel. The field is intentionally NOT in ForgeConfig — it is a + /// per-invocation runtime behaviour flag, not persisted state. + pub silent: bool, } impl ToolCallContext { /// Creates a new ToolCallContext with default values pub fn new(metrics: Metrics) -> Self { - Self { sender: None, metrics: Arc::new(Mutex::new(metrics)) } + Self { + sender: None, + metrics: Arc::new(Mutex::new(metrics)), + silent: false, + } } /// Send a message through the sender if available diff --git a/crates/forge_infra/src/executor.rs b/crates/forge_infra/src/executor.rs index af8718738d..9cec9f3430 100644 --- a/crates/forge_infra/src/executor.rs +++ b/crates/forge_infra/src/executor.rs @@ -108,7 +108,8 @@ impl ForgeCommandExecutorService { let mut stdout_pipe = child.stdout.take(); let mut stderr_pipe = child.stderr.take(); - // Stream the output of the command to stdout and stderr concurrently + // Suppress stdout in headless mode to avoid contaminating the JSON-RPC + // transport. let (status, stdout_buffer, stderr_buffer) = if silent { tokio::try_join!( child.wait(), diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index a4c859bcd7..11a5f78253 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -75,6 +75,15 @@ impl Cli { pub fn is_interactive(&self) -> bool { self.prompt.is_none() && self.piped_input.is_none() && self.subcommands.is_none() } + + /// Checks if the subcommand owns stdin and should skip the piped-input + /// pre-read. + /// + /// Commands like `forge select` interactively read from stdin and would + /// hang if the startup pipeline consumed stdin first. + pub fn uses_stdin(&self) -> bool { + matches!(&self.subcommands, Some(TopLevelCommand::Select(_))) + } } #[derive(Subcommand, Debug, Clone)] diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index b0815a8799..074e8e9711 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -75,7 +75,7 @@ impl Section { /// # Output Format /// /// ```text -/// +/// /// CONFIGURATION /// model gpt-4 /// provider openai diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index 7ad2b39be1..9f98bcf915 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -7,7 +7,7 @@ use clap::Parser; use forge_api::ForgeAPI; use forge_config::ForgeConfig; use forge_domain::TitleFormat; -use forge_main::{Cli, Sandbox, TitleDisplayExt, TopLevelCommand, UI, tracker}; +use forge_main::{Cli, Sandbox, TitleDisplayExt, UI, tracker}; /// Enables ENABLE_VIRTUAL_TERMINAL_PROCESSING on the stdout console handle. /// @@ -82,7 +82,7 @@ async fn run() -> Result<()> { "Unexpected error occurred".to_string() }; - println!("{}", TitleFormat::error(message.to_string()).display()); + eprintln!("{}", TitleFormat::error(message.to_string()).display()); tracker::error_blocking(message); std::process::exit(1); })); @@ -90,10 +90,9 @@ async fn run() -> Result<()> { // Initialize and run the UI let mut cli = Cli::parse(); - // Check if there's piped input, but skip for `forge select` since that - // command uses stdin for its item list. - let is_select = matches!(cli.subcommands, Some(TopLevelCommand::Select(_))); - if !is_select && !std::io::stdin().is_terminal() { + // Check if there's piped input, but skip for commands that use stdin + // for their own protocol (e.g. `forge select`). + if !cli.uses_stdin() && !std::io::stdin().is_terminal() { let mut stdin_content = String::new(); std::io::stdin().read_to_string(&mut stdin_content)?; let trimmed_content = stdin_content.trim(); diff --git a/crates/forge_main/src/sandbox.rs b/crates/forge_main/src/sandbox.rs index eee8daeadd..231b70e9c7 100644 --- a/crates/forge_main/src/sandbox.rs +++ b/crates/forge_main/src/sandbox.rs @@ -69,7 +69,7 @@ impl<'a> Sandbox<'a> { .context("Failed to check if target directory is a git worktree")?; if worktree_check.status.success() { - println!( + eprintln!( "{}", TitleFormat::info("Worktree [Reused]") .sub_title(worktree_path.display().to_string()) @@ -125,7 +125,7 @@ impl<'a> Sandbox<'a> { bail!("Failed to create git worktree: {stderr}"); } - println!( + eprintln!( "{}", TitleFormat::info("Worktree [Created]") .sub_title(worktree_path.display().to_string())