diff --git a/crates/op-cli/src/app_control_cli.rs b/crates/op-cli/src/app_control_cli.rs index b3abb1c90..f0a2d09a0 100644 --- a/crates/op-cli/src/app_control_cli.rs +++ b/crates/op-cli/src/app_control_cli.rs @@ -228,10 +228,14 @@ fn open_in_browser(url: &str) { }; #[cfg(target_os = "windows")] let mut command = { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; let mut c = Command::new("cmd"); - // The empty "" is `start`'s window-title slot — without it a quoted - // URL would be consumed as the title. - c.args(["/C", "start", "", url]); + // Raw arg keeps the URL inside double quotes so cmd doesn't + // split it at `&`; the empty "" is `start`'s window-title slot — + // without it the quoted URL would be consumed as the title. + c.raw_arg(windows_start_args(url)); + c.creation_flags(CREATE_NO_WINDOW); c }; #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -243,6 +247,14 @@ fn open_in_browser(url: &str) { let _ = spawn_null(&mut command); } +/// Args for `cmd /C start "" ""` with the URL double-quoted so cmd +/// doesn't split it at `&`. `"` is illegal in URLs — stripped +/// defensively. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn windows_start_args(url: &str) -> String { + format!("/C start \"\" \"{}\"", url.replace('"', "%22")) +} + /// `op start --web` success JSON: the headless `start_json` shape plus /// `mode:"web"` (and the non-default bind host when one was requested, so /// scripts can derive the LAN URL). diff --git a/crates/op-host-desktop/src/main.rs b/crates/op-host-desktop/src/main.rs index 3b8e82a99..5122a9a53 100644 --- a/crates/op-host-desktop/src/main.rs +++ b/crates/op-host-desktop/src/main.rs @@ -2,6 +2,13 @@ //! Owns the event loop, GL surface, DPI, animation timer + cursor input. #![cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +// Detach from the console subsystem in release builds so launching from +// Explorer / the Start menu doesn't park a console window behind the GUI. +// Debug builds keep the console — tracing writes to stderr (init_tracing). +#![cfg_attr( + all(target_os = "windows", not(debug_assertions)), + windows_subsystem = "windows" +)] mod a11y; mod acp_agent_probe_host; diff --git a/crates/op-host-desktop/src/update_check.rs b/crates/op-host-desktop/src/update_check.rs index 143c41fb5..cd94b532d 100644 --- a/crates/op-host-desktop/src/update_check.rs +++ b/crates/op-host-desktop/src/update_check.rs @@ -245,15 +245,35 @@ fn download_and_open_blocking(version: &str) -> bool { open_installer_path(&dest) } +/// Args for `cmd /C start "" ""` with the target double-quoted +/// so cmd doesn't split it at `&` (OAuth / query-string URLs) or at +/// spaces (installer paths). Inside double quotes cmd keeps its +/// metacharacters literal; `"` itself is illegal in URLs and Windows +/// paths but is stripped defensively. `%VAR%` expansion remains +/// possible in pathological inputs — accepted, matches the `open` +/// crate's behaviour. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn windows_start_args(target: &str) -> String { + format!("/C start \"\" \"{}\"", target.replace('"', "%22")) +} + +/// Spawn `cmd /C start "" ""` without flashing a console. +#[cfg(target_os = "windows")] +fn windows_start(target: &str) -> std::io::Result { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut c = std::process::Command::new("cmd"); + c.raw_arg(windows_start_args(target)); + c.creation_flags(CREATE_NO_WINDOW); + c.spawn() +} + /// Open the downloaded installer with the platform launcher. fn open_installer_path(path: &std::path::Path) -> bool { #[cfg(target_os = "macos")] let result = std::process::Command::new("open").arg(path).spawn(); #[cfg(target_os = "windows")] - let result = std::process::Command::new("cmd") - .args(["/C", "start", ""]) - .arg(path) - .spawn(); + let result = windows_start(&path.display().to_string()); #[cfg(target_os = "linux")] let result = std::process::Command::new("xdg-open").arg(path).spawn(); result.is_ok() @@ -294,9 +314,7 @@ pub fn open_url(url: &str) { #[cfg(target_os = "linux")] let result = std::process::Command::new("xdg-open").arg(url).spawn(); #[cfg(target_os = "windows")] - let result = std::process::Command::new("cmd") - .args(["/C", "start", "", url]) - .spawn(); + let result = windows_start(url); if let Err(e) = result { eprintln!("openpencil-desktop: open_url({url}) failed: {e}"); } @@ -306,6 +324,16 @@ pub fn open_url(url: &str) { mod tests { use super::*; + #[test] + fn windows_start_args_quotes_urls_with_query_params() { + assert_eq!( + windows_start_args("https://a.example/auth?b=1&c=2"), + "/C start \"\" \"https://a.example/auth?b=1&c=2\"" + ); + // Double quotes are illegal in URLs/paths — stripped defensively. + assert_eq!(windows_start_args("x\"y"), "/C start \"\" \"x%22y\""); + } + #[test] fn is_newer_compares_dotted_numbers() { assert!(is_newer("0.9.0", "0.8.0")); diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index 6b6ca8570..ed8625162 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -919,13 +919,27 @@ fn open_external_url(url: &str) { #[cfg(target_os = "macos")] let _ = std::process::Command::new("open").arg(url).spawn(); #[cfg(target_os = "windows")] - let _ = std::process::Command::new("cmd") - .args(["/C", "start", "", url]) - .spawn(); + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut c = std::process::Command::new("cmd"); + c.raw_arg(windows_start_args(url)); + c.creation_flags(CREATE_NO_WINDOW); + let _ = c.spawn(); + } #[cfg(not(any(target_os = "macos", target_os = "windows")))] let _ = std::process::Command::new("xdg-open").arg(url).spawn(); } +/// Args for `cmd /C start "" ""` with the URL double-quoted so +/// cmd doesn't split it at `&` (query-string URLs like the Openverse +/// OAuth registration link). `"` is illegal in URLs — stripped +/// defensively. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +fn windows_start_args(url: &str) -> String { + format!("/C start \"\" \"{}\"", url.replace('"', "%22")) +} + #[cfg(test)] mod tests { use super::create_initial_size_for_tool; diff --git a/crates/op-host-services/src/chat_spawn.rs b/crates/op-host-services/src/chat_spawn.rs index a2f189d8d..96c8fef73 100644 --- a/crates/op-host-services/src/chat_spawn.rs +++ b/crates/op-host-services/src/chat_spawn.rs @@ -170,6 +170,26 @@ pub fn build_command(binary: &str, args: &[String]) -> Command { } } +/// Apply CREATE_NO_WINDOW to a blocking `std::process::Command` so +/// background CLI probes (model discovery, provider version checks) +/// don't flash console windows once the desktop binary runs detached +/// from the console subsystem (`windows_subsystem = "windows"`). +/// No-op off Windows. +pub(crate) fn hide_console_window(cmd: &mut std::process::Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NO_WINDOW from winbase.h — same flag build_command + // applies to the streaming tokio commands. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + #[cfg(not(windows))] + { + let _ = cmd; + } +} + /// Stringify an `ExitStatus` for chat error reporting. Cross-platform: /// on Unix `.code()` is `None` when killed by signal — show the signal /// number instead; on Windows `.code()` is always populated. diff --git a/crates/op-host-services/src/model_discovery.rs b/crates/op-host-services/src/model_discovery.rs index 33ad4521c..e8a7092ab 100644 --- a/crates/op-host-services/src/model_discovery.rs +++ b/crates/op-host-services/src/model_discovery.rs @@ -278,13 +278,13 @@ fn discover_codex() -> Vec { /// falls back to the on-disk cache. pub fn codex_models_from_app_server() -> Option> { let exe = resolve_cli("codex")?; - let mut child = Command::new(exe) - .arg("app-server") + let mut cmd = Command::new(exe); + cmd.arg("app-server") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; + .stderr(Stdio::null()); + crate::chat_spawn::hide_console_window(&mut cmd); + let mut child = cmd.spawn().ok()?; let mut stdin = child.stdin.take()?; let stdout = child.stdout.take()?; @@ -440,13 +440,13 @@ fn discover_copilot() -> Vec { /// Rust 1.94). Returns `None` on any failure. fn copilot_models_from_stdio() -> Option> { let exe = resolve_cli("copilot")?; - let mut child = Command::new(exe) - .arg("--stdio") + let mut cmd = Command::new(exe); + cmd.arg("--stdio") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; + .stderr(Stdio::null()); + crate::chat_spawn::hide_console_window(&mut cmd); + let mut child = cmd.spawn().ok()?; let mut stdin = child.stdin.take()?; let stdout = child.stdout.take()?; @@ -574,7 +574,10 @@ pub fn discover_opencode() -> Vec { let Some(exe) = resolve_cli("opencode") else { return Vec::new(); }; - let Ok(output) = Command::new(exe).arg("models").output() else { + let mut cmd = Command::new(exe); + cmd.arg("models"); + crate::chat_spawn::hide_console_window(&mut cmd); + let Ok(output) = cmd.output() else { return Vec::new(); }; if !output.status.success() { diff --git a/crates/op-host-services/src/provider_probe.rs b/crates/op-host-services/src/provider_probe.rs index 466fb465f..082d3d0e8 100644 --- a/crates/op-host-services/src/provider_probe.rs +++ b/crates/op-host-services/src/provider_probe.rs @@ -183,13 +183,13 @@ fn decode_jwt_payload(token: &str) -> Option { /// with `2>&1`). `None` on spawn failure, non-zero exit, or /// timeout — the "CLI not responding" path. fn cli_version(exe: &Path, timeout: Duration) -> Option { - let mut child = Command::new(exe) - .arg("--version") + let mut cmd = Command::new(exe); + cmd.arg("--version") .stdin(Stdio::null()) .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .ok()?; + .stderr(Stdio::piped()); + crate::chat_spawn::hide_console_window(&mut cmd); + let mut child = cmd.spawn().ok()?; let deadline = Instant::now() + timeout; loop { match child.try_wait() { @@ -578,13 +578,13 @@ fn copilot_connection_info(auth: Option<&CopilotAuth>) -> String { /// when the model list never answered; auth is best-effort (TS /// logs and continues when `getAuthStatus` fails). fn copilot_probe_stdio(exe: &Path) -> Option<(Vec, Option)> { - let mut child = Command::new(exe) - .arg("--stdio") + let mut cmd = Command::new(exe); + cmd.arg("--stdio") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; + .stderr(Stdio::null()); + crate::chat_spawn::hide_console_window(&mut cmd); + let mut child = cmd.spawn().ok()?; let mut stdin = child.stdin.take()?; let stdout = child.stdout.take()?; diff --git a/crates/op-host-services/src/provider_probe_models.rs b/crates/op-host-services/src/provider_probe_models.rs index f55fc6551..d2a27ef6e 100644 --- a/crates/op-host-services/src/provider_probe_models.rs +++ b/crates/op-host-services/src/provider_probe_models.rs @@ -94,6 +94,7 @@ pub fn claude_initialize_query() -> ClaudeInitResult { cmd.env("CLAUDE_CODE_ENTRYPOINT", "sdk-ts"); } cmd.env_remove("NODE_OPTIONS"); + crate::chat_spawn::hide_console_window(&mut cmd); let Ok(mut child) = cmd.spawn() else { return ClaudeInitResult::NoAnswer; }; diff --git a/vendor/agent b/vendor/agent index c0804d906..a6f1897c3 160000 --- a/vendor/agent +++ b/vendor/agent @@ -1 +1 @@ -Subproject commit c0804d9061323bd86c9efa68f177d6006875b7a5 +Subproject commit a6f1897c3a23b59b5539c9bc1a6b28f6d5da2a72 diff --git a/vendor/anthropic-agent-sdk/src/auth/oauth.rs b/vendor/anthropic-agent-sdk/src/auth/oauth.rs index 951eecd93..e62a70465 100644 --- a/vendor/anthropic-agent-sdk/src/auth/oauth.rs +++ b/vendor/anthropic-agent-sdk/src/auth/oauth.rs @@ -522,9 +522,15 @@ impl OAuthClient { #[cfg(target_os = "windows")] { - std::process::Command::new("cmd") - .args(["/C", "start", "", url]) - .spawn() + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let mut c = std::process::Command::new("cmd"); + // Double-quote the URL so cmd doesn't split it at `&` — the + // OAuth authorize URL always carries multiple query params. + // `"` is illegal in URLs; strip it defensively. + c.raw_arg(format!("/C start \"\" \"{}\"", url.replace('"', "%22"))); + c.creation_flags(CREATE_NO_WINDOW); + c.spawn() .map_err(|e| OAuthError::BrowserOpen(e.to_string()))?; } diff --git a/vendor/anthropic-agent-sdk/src/transport/mod.rs b/vendor/anthropic-agent-sdk/src/transport/mod.rs index 93b968838..9fcf4f314 100644 --- a/vendor/anthropic-agent-sdk/src/transport/mod.rs +++ b/vendor/anthropic-agent-sdk/src/transport/mod.rs @@ -66,8 +66,15 @@ pub trait Transport: Send + Sync { pub async fn check_claude_version(cli_path: &std::path::Path) -> crate::Result { use tokio::process::Command; - let output = Command::new(cli_path) - .arg("--version") + let mut cmd = Command::new(cli_path); + cmd.arg("--version"); + #[cfg(windows)] + { + // CREATE_NO_WINDOW — version probes run behind the GUI; don't + // flash a console window per probe. + cmd.creation_flags(0x0800_0000); + } + let output = cmd .output() .await .map_err(|e| crate::ClaudeError::connection(format!("Failed to get CLI version: {e}")))?; diff --git a/vendor/anthropic-agent-sdk/src/transport/subprocess.rs b/vendor/anthropic-agent-sdk/src/transport/subprocess.rs index 7dcae521e..334e01370 100644 --- a/vendor/anthropic-agent-sdk/src/transport/subprocess.rs +++ b/vendor/anthropic-agent-sdk/src/transport/subprocess.rs @@ -172,6 +172,14 @@ impl SubprocessTransport { fn build_command(&self) -> Result { let mut cmd = Command::new(&self.cli_path); + #[cfg(windows)] + { + // CREATE_NO_WINDOW — every routed chat turn spawns the CLI; + // without this each spawn flashes a console window when the + // host GUI runs detached from the console subsystem. + cmd.creation_flags(0x0800_0000); + } + // Always use --print for non-interactive mode to avoid terminal manipulation cmd.arg("--print");