diff --git a/crates/codegen/xai-grok-pager-bin/src/main.rs b/crates/codegen/xai-grok-pager-bin/src/main.rs index b2c7d74..bb7cb28 100644 --- a/crates/codegen/xai-grok-pager-bin/src/main.rs +++ b/crates/codegen/xai-grok-pager-bin/src/main.rs @@ -1498,43 +1498,171 @@ fn flag_dashboard_at_startup_if_requested(args: &mut PagerArgs) -> Result<()> { Ok(()) } -/// Start the headless Simplicio AgentHost on demand for the default Code TUI. -/// The AgentHost is the backend; Code remains the only interactive surface. -fn ensure_simplicio_agent_host() { - let program = env::var_os("SIMPLICIO_AGENT_BIN").unwrap_or_else(|| "simplicio-agent".into()); - let daemon_ready = || { - std::process::Command::new(&program) - .args(["daemon", "status"]) - .output() - .ok() - .and_then(|output| serde_json::from_slice::(&output.stdout).ok()) - .and_then(|status| status.get("ok").and_then(serde_json::Value::as_bool)) - .unwrap_or(false) - }; - if daemon_ready() { - return; +fn shell_quote(value: &std::ffi::OsStr) -> String { + format!("'{}'", value.to_string_lossy().replace('\'', "'\"'\"'")) +} + +fn args_without_cwd() -> Vec { + let mut filtered = Vec::new(); + let mut args = env::args_os().skip(1); + while let Some(arg) = args.next() { + if arg == "--cwd" { + let _ = args.next(); + continue; + } + if arg.to_string_lossy().starts_with("--cwd=") { + continue; + } + filtered.push(arg); } + filtered +} - let Ok(_child) = std::process::Command::new(&program) - .args(["daemon", "start", "--warm-profile", "desktop"]) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - else { - return; - }; +fn simplicio_code_bridge_file(bridge_id: &str) -> Result { + let root = env::var_os("SIMPLICIO_AGENT_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + env::var_os("HOME") + .map(std::path::PathBuf::from) + .map(|home| home.join(".simplicio_agent")) + }) + .unwrap_or_else(std::env::temp_dir); + let directory = root.join("code-bridges"); + std::fs::create_dir_all(&directory)?; + Ok(directory.join(format!("{bridge_id}.jsonl"))) +} - // AgentHost preloads the model/provider catalog before binding its socket; - // allow that cold start to finish instead of falling through to a fake - // disconnected Code session. - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); - while std::time::Instant::now() < deadline { - if daemon_ready() { - return; +fn simplicio_agent_terminal_command(bridge_id: &str, bridge_file: &std::path::Path) -> String { + let program = env::var_os("SIMPLICIO_AGENT_BIN").unwrap_or_else(|| "simplicio_agent".into()); + format!( + "exec env SIMPLICIO_CODE_COPILOT=1 SIMPLICIO_CODE_BRIDGE_ID={} \ + SIMPLICIO_CODE_BRIDGE_FILE={} {}", + shell_quote(std::ffi::OsStr::new(bridge_id)), + shell_quote(bridge_file.as_os_str()), + shell_quote(&program) + ) +} + +const SIMPLICIO_AGENT_PANE_PERCENT: &str = "25"; + +/// Put a literal `simplicio_agent` terminal beside Code. Outside tmux this +/// creates a small two-pane session; inside tmux it only splits the current +/// pane. The right pane is exactly 25% wide and shares Code's working folder. +/// +/// Returns `true` when this process became the outer tmux launcher and should +/// exit after the tmux session detaches. +fn maybe_launch_simplicio_code_split(is_interactive: bool) -> Result { + if !is_interactive { + return Ok(false); + } + + if env::var_os("SIMPLICIO_CODE_SPLIT_CHILD").is_some() { + return Ok(false); + } + + let cwd = env::current_dir()?; + let started_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let bridge_id = format!("code-{}-{started_at}", std::process::id()); + let bridge_file = simplicio_code_bridge_file(&bridge_id)?; + let right_command = simplicio_agent_terminal_command(&bridge_id, &bridge_file); + unsafe { + env::set_var("SIMPLICIO_CODE_BRIDGE_ID", &bridge_id); + env::set_var("SIMPLICIO_CODE_BRIDGE_FILE", &bridge_file); + env::set_var("SIMPLICIO_CODE_DIRECT_LLM", "1"); + } + + if env::var_os("TMUX").is_some() { + let split = std::process::Command::new("tmux") + .args([ + "split-window", + "-h", + "-p", + SIMPLICIO_AGENT_PANE_PERCENT, + "-c", + ]) + .arg(&cwd) + .arg(&right_command) + .status()?; + if !split.success() { + anyhow::bail!("tmux could not open the simplicio_agent side terminal"); + } + let _ = std::process::Command::new("tmux") + .args(["set-option", "mouse", "on"]) + .status(); + let _ = std::process::Command::new("tmux") + .args(["select-pane", "-L"]) + .status(); + unsafe { + env::set_var("SIMPLICIO_CODE_SPLIT_CHILD", "1"); } - std::thread::sleep(std::time::Duration::from_millis(100)); + return Ok(false); + } + + let executable = env::current_exe()?; + let mut left_command = format!( + "exec env SIMPLICIO_CODE_SPLIT_CHILD=1 SIMPLICIO_CODE_DIRECT_AGENT=1 \ + SIMPLICIO_CODE_DIRECT_LLM=1 SIMPLICIO_CODE_BRIDGE_ID={} \ + SIMPLICIO_CODE_BRIDGE_FILE={} {}", + shell_quote(std::ffi::OsStr::new(&bridge_id)), + shell_quote(bridge_file.as_os_str()), + shell_quote(executable.as_os_str()) + ); + for arg in args_without_cwd() { + left_command.push(' '); + left_command.push_str(&shell_quote(&arg)); + } + + let session = format!("simplicio-code-{}", std::process::id()); + let created = std::process::Command::new("tmux") + .args(["new-session", "-d", "-s", &session, "-c"]) + .arg(&cwd) + .arg(&left_command) + .status()?; + if !created.success() { + anyhow::bail!("tmux could not create the Simplicio Code workspace"); + } + + let split = std::process::Command::new("tmux") + .args([ + "split-window", + "-h", + "-p", + SIMPLICIO_AGENT_PANE_PERCENT, + "-t", + ]) + .arg(format!("{session}:0.0")) + .args(["-c"]) + .arg(&cwd) + .arg(&right_command) + .status()?; + if !split.success() { + let _ = std::process::Command::new("tmux") + .args(["kill-session", "-t", &session]) + .status(); + anyhow::bail!("tmux could not open the simplicio_agent side terminal"); + } + + let _ = std::process::Command::new("tmux") + .args(["set-option", "-t", &session, "status", "off"]) + .status(); + let _ = std::process::Command::new("tmux") + .args(["set-option", "-t", &session, "mouse", "on"]) + .status(); + let _ = std::process::Command::new("tmux") + .args(["select-pane", "-t"]) + .arg(format!("{session}:0.0")) + .status(); + + let attached = std::process::Command::new("tmux") + .args(["attach-session", "-t", &session]) + .status()?; + if !attached.success() { + anyhow::bail!("tmux Simplicio Code workspace exited with {attached}"); } + Ok(true) } const RUNTIME_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(2); @@ -1819,9 +1947,14 @@ async fn async_main() -> Result<()> { .and_then(|path| path.file_name().map(|name| name.to_owned())) .and_then(|name| name.to_str().map(str::to_owned)) .is_some_and(|name| name == "simplicio-code" || name == "simplicio_code"); - if simplicio_code_entrypoint && is_interactive { - ensure_simplicio_agent_host(); - unsafe { env::set_var("SIMPLICIO_CODE_AGENT_FIRST", "1") }; + if simplicio_code_entrypoint { + unsafe { + env::set_var("SIMPLICIO_CODE_DIRECT_AGENT", "1"); + env::remove_var("SIMPLICIO_CODE_AGENT_FIRST"); + } + if maybe_launch_simplicio_code_split(is_interactive)? { + return Ok(()); + } } let opens_cockpit = is_interactive && args.resume_session.is_none() @@ -1833,13 +1966,7 @@ async fn async_main() -> Result<()> { // The first screen is Code's native `/dashboard`, not a second // cockpit process. This keeps the dashboard's own navigation, tabs, // terminal/session actions, and scrollback in one TUI. - ensure_simplicio_agent_host(); unsafe { env::set_var("GROK_OPEN_DASHBOARD_AT_STARTUP", "1") }; - - // Keep the Code visual session, but make its productive prompt path - // AgentHost-owned. The legacy in-process ACP remains only as the - // rendering/session shell and cannot select a second provider. - unsafe { env::set_var("SIMPLICIO_CODE_AGENT_FIRST", "1") }; } if let Some(command) = args.command.take() { match command { @@ -2391,6 +2518,16 @@ async fn signal_leaders_to_relaunch(installed_version: &str) { #[cfg(test)] mod tests { use super::*; + + #[test] + fn shell_quote_preserves_spaces_and_single_quotes() { + assert_eq!( + shell_quote(std::ffi::OsStr::new("agent's workspace")), + "'agent'\"'\"'s workspace'" + ); + assert_eq!(SIMPLICIO_AGENT_PANE_PERCENT, "25"); + } + #[cfg(all(feature = "jemalloc", unix))] struct TempHeapDump(std::path::PathBuf); #[cfg(all(feature = "jemalloc", unix))] diff --git a/crates/codegen/xai-grok-pager/src/acp/mod.rs b/crates/codegen/xai-grok-pager/src/acp/mod.rs index 064430c..d5db281 100644 --- a/crates/codegen/xai-grok-pager/src/acp/mod.rs +++ b/crates/codegen/xai-grok-pager/src/acp/mod.rs @@ -155,6 +155,17 @@ pub struct ConnectFlags { pub default_auto_mode: bool, } +/// Whether Code should use the installed Simplicio Agent as its direct ACP +/// provider/model backend instead of bootstrapping the legacy Grok agent. +pub fn simplicio_agent_acp_enabled() -> bool { + std::env::var("SIMPLICIO_CODE_DIRECT_AGENT").is_ok_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + /// Connect to an agent: spawn, initialize, authenticate. /// /// This is the main entry point for establishing an ACP connection. @@ -199,8 +210,12 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result< apply_config_writes(&flags); // Spawn the agent - let memory_config = agent_config.memory_config.clone(); - let spawned = spawn::spawn_grok_shell(agent_config, cancel, memory_config).await?; + let spawned = if simplicio_agent_acp_enabled() { + spawn::spawn_simplicio_agent(agent_config, cancel).await? + } else { + let memory_config = agent_config.memory_config.clone(); + spawn::spawn_grok_shell(agent_config, cancel, memory_config).await? + }; let auth_manager = spawned.auth_manager.clone(); let (tx, rx) = (spawned.channel.tx, spawned.channel.rx); diff --git a/crates/codegen/xai-grok-pager/src/acp/spawn.rs b/crates/codegen/xai-grok-pager/src/acp/spawn.rs index 3f2d552..30272c9 100644 --- a/crates/codegen/xai-grok-pager/src/acp/spawn.rs +++ b/crates/codegen/xai-grok-pager/src/acp/spawn.rs @@ -3,10 +3,14 @@ //! Simplified to only support GrokShell (in-process) mode. //! Subprocess and remote modes can be added later if needed. +use std::process::Stdio; use std::rc::Rc; use std::thread; +use std::time::Duration; +use agent_client_protocol as acp; use anyhow::Result; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use tokio_util::sync::CancellationToken; use xai_acp_lib::{ @@ -96,6 +100,106 @@ pub async fn spawn_grok_shell( }) } +/// Spawn the installed `simplicio_agent` ACP adapter as a persistent subprocess. +/// +/// This is the direct Code prompt path: one warm ACP connection, no AgentHost +/// daemon, no Grok bootstrap, and no second copilot request per user prompt. +pub async fn spawn_simplicio_agent( + agent_config: AgentConfig, + cancel: &CancellationToken, +) -> Result { + let auth_manager = + std::sync::Arc::new(AuthManager::new(&grok_home(), agent_config.grok_com_config)); + let auth_manager_for_pager = auth_manager.clone(); + let agent_cancel = cancel.child_token(); + let (acp_client, acp_agent) = acp_channels(); + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::>(1); + let thread_cancel = agent_cancel.clone(); + + let handle = thread::Builder::new() + .name("simplicio-agent-acp-worker".into()) + .spawn(move || -> Result<()> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let local = tokio::task::LocalSet::new(); + local.block_on(&rt, async move { + let program = std::env::var_os("SIMPLICIO_AGENT_BIN") + .unwrap_or_else(|| "simplicio_agent".into()); + let mut child = match tokio::process::Command::new(&program) + .arg("acp") + .env("SIMPLICIO_CODE_DIRECT_LLM", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + { + Ok(child) => child, + Err(error) => { + let message = + format!("failed to start {} acp: {error}", program.to_string_lossy()); + let _ = ready_tx.send(Err(message.clone())); + anyhow::bail!(message); + } + }; + + let outgoing = child + .stdin + .take() + .ok_or_else(|| anyhow::anyhow!("simplicio_agent ACP stdin unavailable"))? + .compat_write(); + let incoming = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("simplicio_agent ACP stdout unavailable"))? + .compat(); + + let client = AcpGatewaySender::::new(acp_agent.tx.clone()) + .with_tracing(true); + let (connection, handle_io) = + acp::ClientSideConnection::new(client, outgoing, incoming, |future| { + tokio::task::spawn_local(future); + }); + tokio::task::spawn_local(handle_io); + let gateway = + AcpGatewayReceiver::::new(acp_agent.rx, connection) + .with_tracing(true); + tokio::task::spawn_local(gateway.run()); + tokio::task::yield_now().await; + let _ = ready_tx.send(Ok(())); + + tokio::select! { + _ = thread_cancel.cancelled() => { + let _ = child.kill().await; + Ok(()) + } + status = child.wait() => { + let status = status?; + if status.success() { + Ok(()) + } else { + anyhow::bail!("simplicio_agent ACP exited with {status}") + } + } + } + }) + })?; + + match ready_rx.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(())) => {} + Ok(Err(message)) => anyhow::bail!(message), + Err(error) => anyhow::bail!("simplicio_agent ACP startup timed out: {error}"), + } + + Ok(SpawnedAgent { + _thread_handle: handle, + channel: acp_client, + cancel: agent_cancel, + auth_manager: auth_manager_for_pager, + }) +} + /// Spawn an agent in a dedicated thread with direct RPC dispatch. /// /// The agent runs on a single-threaded tokio LocalSet runtime. diff --git a/crates/codegen/xai-grok-pager/src/app/app_view.rs b/crates/codegen/xai-grok-pager/src/app/app_view.rs index 08740b1..2899ee8 100644 --- a/crates/codegen/xai-grok-pager/src/app/app_view.rs +++ b/crates/codegen/xai-grok-pager/src/app/app_view.rs @@ -3876,8 +3876,7 @@ impl AppView { }; let zdr_blocked_for_draw = self.is_zdr_blocked(); let has_access = self.has_access(); - let simplicio_code_agent_first = std::env::var("SIMPLICIO_CODE_AGENT_FIRST") - .is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true")); + let ecosystem_billing_enabled = crate::app::dispatch::ecosystem_billing_enabled(); let voice_available = self.voice_available(); let voice_on_surface = self.voice_target_on_active_surface(); let voice_listening = voice_on_surface && self.voice_listening(); @@ -4021,13 +4020,13 @@ impl AppView { session_picker_grouped: self.session_picker_grouped, session_picker_source_filter: self.session_picker_source_filter, chat_mode: self.chat_mode, - credit_balance: (!simplicio_code_agent_first) + credit_balance: ecosystem_billing_enabled .then_some(self.credit_balance.as_ref()) .flatten(), - auto_topup: (!simplicio_code_agent_first) + auto_topup: ecosystem_billing_enabled .then_some(self.auto_topup.as_ref()) .flatten(), - usage_visible: self.usage_visible && !simplicio_code_agent_first, + usage_visible: self.usage_visible && ecosystem_billing_enabled, is_api_key_auth: self.is_api_key_auth, changelog_bullets: &self.changelog_bullets, changelog_has_full_notes: self.changelog_markdown.is_some(), @@ -4210,9 +4209,8 @@ impl AppView { d.overlay_next_hit.set(header.and_then(|c| c.next_rect)); } if let Some(agent) = agents.get_mut(&id) { - let (agent_content_area, agent_panel_area) = - crate::views::agent_attention_panel::split_agent_area(agent_area); - agent_attention.panel_area = agent_panel_area; + let agent_content_area = agent_area; + agent_attention.panel_area = None; let announcement_banner_h = crate::views::announcements::session_banner_height( &self.active_announcements, @@ -4251,13 +4249,6 @@ impl AppView { voice_listening, voice_interim.as_deref(), ); - if let Some(panel_area) = agent_panel_area { - crate::views::agent_attention_panel::render_agent_attention_panel( - panel_area, - f.buffer_mut(), - agent_attention, - ); - } if let Some(modal) = self.import_claude_modal.as_mut() { let theme = crate::theme::Theme::current(); crate::views::import_claude_modal::render_import_claude_modal( @@ -4315,9 +4306,8 @@ impl AppView { caption: crate::views::announcements::usable_cta_caption(owner), }, ); - let (dashboard_area, agent_panel_area) = - crate::views::agent_attention_panel::split_agent_area(view_area); - agent_attention.panel_area = agent_panel_area; + let dashboard_area = view_area; + agent_attention.panel_area = None; let dash_cursor = crate::views::dashboard::render_dashboard( f.buffer_mut(), dashboard_area, @@ -4379,13 +4369,6 @@ impl AppView { Self::dashboard_stale_image_clears(agents, drawn_popup_agent); let popup_post_flush = Self::merge_post_flush(stale_clears, popup_post_flush); - if let Some(panel_area) = agent_panel_area { - crate::views::agent_attention_panel::render_agent_attention_panel( - panel_area, - f.buffer_mut(), - agent_attention, - ); - } if let Some(fps) = &fps_overlay { fps.render(full_area, f.buffer_mut()); } diff --git a/crates/codegen/xai-grok-pager/src/app/event_loop.rs b/crates/codegen/xai-grok-pager/src/app/event_loop.rs index bdc163f..4136f0e 100644 --- a/crates/codegen/xai-grok-pager/src/app/event_loop.rs +++ b/crates/codegen/xai-grok-pager/src/app/event_loop.rs @@ -1210,11 +1210,10 @@ pub(crate) async fn run( const GATE_POLL_INTERVAL: Duration = Duration::from_secs(30); let mut gate_poll_at: Option = None; - // Reuse the central select!/JoinSet scheduler. The state model rejects a - // second in-flight poll, and the cancellation guard owns teardown. Minimal - // mode has no side panel, so it does no invisible Agent polling. - let mut agent_attention_poll_at: Option = - app.screen_mode.is_fullscreen().then(Instant::now); + // Simplicio Code now uses a real side terminal instead of the embedded + // AgentHost panel. Keep its polling scheduler dormant so foreground + // prompts never compete with an invisible copilot request. + let mut agent_attention_poll_at: Option = None; // Free→paid subscription watch (see `app::subscription`). let mut subscription_watch_at: Option = if app.subscription_watch_wanted() { diff --git a/crates/codegen/xai-grok-pager/src/app/mod.rs b/crates/codegen/xai-grok-pager/src/app/mod.rs index 27f1238..3484e78 100644 --- a/crates/codegen/xai-grok-pager/src/app/mod.rs +++ b/crates/codegen/xai-grok-pager/src/app/mod.rs @@ -435,27 +435,32 @@ pub async fn run( let screen_mode_override = screen_mode_relaunch::take_screen_mode_env_override(); let cancel = CancellationToken::new(); let startup_start = std::time::Instant::now(); - let raw_config = xai_grok_shell::config::load_effective_config() - .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; - let grok_com_config = - match xai_grok_shell::agent::config::Config::new_from_toml_cfg(&raw_config) { - Ok(c) => c.grok_com_config, - Err(e) => { - tracing::warn!( - error = % e, "failed to parse config for auth refresh, using defaults" - ); - xai_grok_shell::auth::GrokComConfig::default() - } - }; - let refreshed_auth = xai_grok_shell::auth::try_ensure_fresh_auth(&grok_com_config).await; - let early_prefetch = - xai_grok_shell::agent::models::start_early_prefetch_with_auth(refreshed_auth); - xai_grok_shell::agent::mvp_agent::warm_async_http_client(); + let direct_simplicio_agent = crate::acp::simplicio_agent_acp_enabled(); tokio::task::spawn_blocking(|| {}); if let Ok(cwd) = std::env::current_dir() { crate::git_info::populate_from_cwd_async(cwd); } - let remote_settings = join_early_prefetch(early_prefetch); + let remote_settings = if direct_simplicio_agent { + None + } else { + let raw_config = xai_grok_shell::config::load_effective_config() + .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; + let grok_com_config = + match xai_grok_shell::agent::config::Config::new_from_toml_cfg(&raw_config) { + Ok(c) => c.grok_com_config, + Err(e) => { + tracing::warn!( + error = % e, "failed to parse config for auth refresh, using defaults" + ); + xai_grok_shell::auth::GrokComConfig::default() + } + }; + let refreshed_auth = xai_grok_shell::auth::try_ensure_fresh_auth(&grok_com_config).await; + let early_prefetch = + xai_grok_shell::agent::models::start_early_prefetch_with_auth(refreshed_auth); + xai_grok_shell::agent::mvp_agent::warm_async_http_client(); + join_early_prefetch(early_prefetch) + }; xai_grok_shell::util::config::cache_remote_auto_mode( remote_settings.as_ref().and_then(|s| s.auto_mode.clone()), ); @@ -463,13 +468,17 @@ pub async fn run( let raw_config = xai_grok_shell::config::load_effective_config() .map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?; let prefetch_elapsed = startup_start.elapsed(); - let (use_leader, policy_disable_reason) = resolve_use_leader( - args.leader, - args.no_leader, - &raw_config, - remote_settings.as_ref(), - true, - ); + let (use_leader, policy_disable_reason) = if direct_simplicio_agent { + (false, None) + } else { + resolve_use_leader( + args.leader, + args.no_leader, + &raw_config, + remote_settings.as_ref(), + true, + ) + }; tracing::info!( use_leader, ?policy_disable_reason,