diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index ec5a611..60bc846 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -1474,7 +1474,7 @@ pub(crate) fn inspect_managed_toml_model_marker( Ok(provider_exists && catalog_has_selected) } -pub(crate) fn find_codex_app_installation(home: &Path) -> Option { +pub(crate) fn find_codex_app_installation(home: &Path) -> Option { #[cfg(target_os = "macos")] { [PathBuf::from("/Applications"), home.join("Applications")] @@ -1490,7 +1490,7 @@ pub(crate) fn find_codex_app_installation(home: &Path) -> Option .map(move |name| directory.join(name)) }) .find(|path| path.is_dir()) - .map(CodexAppTarget::Application) + .map(DesktopAppTarget::Application) } #[cfg(target_os = "windows")] @@ -1995,19 +1995,19 @@ pub(crate) fn read_claude_desktop_version(home: &Path) -> Option { } pub(crate) fn read_codex_app_installation_version( - installation: &CodexAppTarget, + installation: &DesktopAppTarget, _home: &Path, ) -> Option { #[cfg(target_os = "windows")] { match installation { - CodexAppTarget::WindowsAppId(app_id) => read_windows_codex_store_version(app_id), - CodexAppTarget::Application(path) => read_windows_codex_desktop_version(path), + DesktopAppTarget::WindowsAppId(app_id) => read_windows_codex_store_version(app_id), + DesktopAppTarget::Application(path) => read_windows_codex_desktop_version(path), } } #[cfg(target_os = "macos")] { - let CodexAppTarget::Application(path) = installation; + let DesktopAppTarget::Application(path) = installation; read_macos_app_version(path) } #[cfg(not(any(target_os = "windows", target_os = "macos")))] @@ -2166,10 +2166,10 @@ pub(crate) fn windows_command_processor() -> PathBuf { } #[cfg(target_os = "windows")] -pub(crate) fn find_windows_codex_app_installation(home: &Path) -> Option { +pub(crate) fn find_windows_codex_app_installation(home: &Path) -> Option { find_windows_registered_codex_app_installation() - .or_else(|| find_windows_codex_app_id_via_registry().map(CodexAppTarget::WindowsAppId)) - .or_else(|| find_windows_codex_app_executable(home).map(CodexAppTarget::Application)) + .or_else(|| find_windows_codex_app_id_via_registry().map(DesktopAppTarget::WindowsAppId)) + .or_else(|| find_windows_codex_app_executable(home).map(DesktopAppTarget::Application)) } #[cfg(target_os = "windows")] @@ -2227,8 +2227,8 @@ pub(crate) fn read_windows_codex_store_version(app_id: &str) -> Option { return None; } match parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout))? { - CodexAppTarget::Application(path) => read_windows_codex_desktop_version(&path), - CodexAppTarget::WindowsAppId(_) => None, + DesktopAppTarget::Application(path) => read_windows_codex_desktop_version(&path), + DesktopAppTarget::WindowsAppId(_) => None, } } @@ -2345,7 +2345,7 @@ pub(crate) fn read_codex_asar_version(path: &Path) -> Option { } #[cfg(target_os = "windows")] -pub(crate) fn find_windows_registered_codex_app_installation() -> Option { +pub(crate) fn find_windows_registered_codex_app_installation() -> Option { const DISCOVERY_SCRIPT: &str = r#" $ErrorActionPreference = 'SilentlyContinue' $ProgressPreference = 'SilentlyContinue' @@ -2430,7 +2430,7 @@ foreach ($shortcutFile in (Get-ChildItem -LiteralPath $shortcutRoots -Filter '*. } parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout)).and_then( |target| match &target { - CodexAppTarget::Application(path) if !path.is_file() => None, + DesktopAppTarget::Application(path) if !path.is_file() => None, _ => Some(target), }, ) @@ -2485,16 +2485,16 @@ if ($version) {{ } #[cfg(target_os = "windows")] -pub(crate) fn parse_windows_codex_app_discovery_output(output: &str) -> Option { +pub(crate) fn parse_windows_codex_app_discovery_output(output: &str) -> Option { output.lines().find_map(|line| { let line = line.trim(); if let Some(app_id) = line.strip_prefix("APPID:").map(str::trim) { - return (!app_id.is_empty()).then(|| CodexAppTarget::WindowsAppId(app_id.to_string())); + return (!app_id.is_empty()).then(|| DesktopAppTarget::WindowsAppId(app_id.to_string())); } line.strip_prefix("EXE:") .map(str::trim) .filter(|path| !path.is_empty()) - .map(|path| CodexAppTarget::Application(PathBuf::from(path))) + .map(|path| DesktopAppTarget::Application(PathBuf::from(path))) }) } diff --git a/src-tauri/src/agents/launch.rs b/src-tauri/src/agents/launch.rs index 0323fff..932d473 100644 --- a/src-tauri/src/agents/launch.rs +++ b/src-tauri/src/agents/launch.rs @@ -385,10 +385,89 @@ fn launch_managed_deepseek_harness( *process = Some(ManagedDeepSeekHarnessProcess { child, mode: mode.to_string(), + launch: DeepSeekHarnessLaunchSnapshot { + executable: executable.to_path_buf(), + working_directory: working_directory.to_path_buf(), + arguments: arguments.to_vec(), + options: options.cloned(), + }, }); Ok(()) } +#[tauri::command] +pub(crate) async fn restart_deepseek_harness_process( + app: tauri::AppHandle, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + restart_managed_deepseek_harness(app.state::().inner()) + }) + .await + .map_err(|error| format!("重启 DeepSeek Harness Web 任务失败: {error}"))? +} + +fn restart_managed_deepseek_harness( + process_state: &DeepSeekHarnessProcessState, +) -> Result { + restart_managed_deepseek_harness_with( + process_state, + terminate_deepseek_harness_process_tree, + spawn_managed_deepseek_harness, + ) +} + +fn restart_managed_deepseek_harness_with( + process_state: &DeepSeekHarnessProcessState, + stop: impl FnOnce(&mut Child) -> Result<(), String>, + spawn: impl FnOnce(&Path, &Path, &[String]) -> Result, +) -> Result { + // Keep the lock across stop and spawn so launch/stop/status cannot interleave. + let mut process = process_state + .process + .lock() + .map_err(|_| "DeepSeek Harness 进程状态锁已损坏".to_string())?; + let managed = process + .as_mut() + .ok_or_else(|| "没有正在运行的 DeepSeek Harness Web 服务".to_string())?; + if managed + .child + .try_wait() + .map_err(|error| format!("检查 DeepSeek Harness 进程状态失败: {error}"))? + .is_some() + { + *process = None; + return Err("DeepSeek Harness Web 已退出,请重新启动".to_string()); + } + if managed.mode != "web" { + return Err("只有 Web 模式支持重启".to_string()); + } + let launch = managed.launch.clone(); + // Validate paths before shutting down a working service. + if !launch.executable.is_file() || !launch.working_directory.is_dir() { + return Err("DeepSeek Harness 启动程序或工作目录已不存在".to_string()); + } + stop(&mut managed.child)?; + *process = None; + // The old process must release its endpoint before the replacement starts. + ensure_deepseek_harness_web_endpoint_available(launch.options.as_ref())?; + let child = spawn( + &launch.executable, + &launch.working_directory, + &launch.arguments, + )?; + let status = DeepSeekHarnessProcessStatus { + running: true, + pid: Some(child.id()), + mode: Some("web".to_string()), + }; + *process = Some(ManagedDeepSeekHarnessProcess { + child, + mode: "web".to_string(), + launch, + }); + Ok(status) +} + fn ensure_deepseek_harness_web_endpoint_available( options: Option<&DeepSeekHarnessLaunchOptions>, ) -> Result<(), String> { @@ -431,7 +510,13 @@ fn spawn_managed_deepseek_harness( command .args(arguments) .current_dir(working_directory) - .creation_flags(CREATE_NEW_CONSOLE | CREATE_NEW_PROCESS_GROUP); + .creation_flags( + (if cfg!(test) { + 0x0800_0000 + } else { + CREATE_NEW_CONSOLE + }) | CREATE_NEW_PROCESS_GROUP, + ); return command .spawn() .map_err(|error| format!("启动 DeepSeek Harness 失败: {error}")); @@ -585,37 +670,153 @@ fn validate_deepseek_harness_argument(value: &str, label: &str) -> Result Result<(), String> { - let home = app - .path() - .home_dir() - .map_err(|error| format!("无法获取用户目录: {error}"))?; - let target = find_codex_app_installation(&home) - .ok_or_else(|| "未检测到 Codex 桌面应用,请重新检测或改用 Codex CLI".to_string())?; - - tauri::async_runtime::spawn_blocking(move || { - stop_codex_desktop(&target)?; - launch_codex_target(&target) - }) - .await - .map_err(|error| format!("重启 Codex App 任务失败: {error}"))? + restart_agent_app(app, "codex".to_string()).await } #[tauri::command] pub(crate) async fn restart_opencode_app(app: tauri::AppHandle) -> Result<(), String> { + restart_agent_app(app, "opencode".to_string()).await +} + +#[tauri::command] +pub(crate) async fn restart_agent_app(app: tauri::AppHandle, client: String) -> Result<(), String> { + let client = AgentClient::parse(&client)?; + if !matches!( + client, + AgentClient::Codex + | AgentClient::OpenCode + | AgentClient::ClaudeDesktop + | AgentClient::ZCode + ) { + return Err(format!("{} 不支持桌面应用重启", client.name())); + } let home = app .path() .home_dir() .map_err(|error| format!("无法获取用户目录: {error}"))?; - let application = find_opencode_desktop_application(&home).ok_or_else(|| { - "未检测到 OpenCode Desktop 应用,请重新检测或改用 OpenCode CLI".to_string() - })?; - tauri::async_runtime::spawn_blocking(move || { - stop_opencode_desktop(&application)?; - launch_desktop_agent(&application, "OpenCode Desktop") + let target = find_desktop_restart_target(client, &home)?; + match client { + AgentClient::Codex => stop_codex_desktop(&target)?, + AgentClient::OpenCode => { + let DesktopAppTarget::Application(path) = &target else { + return Err("OpenCode 桌面安装类型无效".to_string()); + }; + stop_opencode_desktop(path)?; + } + _ => stop_other_desktop(&target, client.name())?, + } + match &target { + DesktopAppTarget::Application(path) => launch_desktop_agent(path, client.name()), + #[cfg(target_os = "windows")] + DesktopAppTarget::WindowsAppId(app_id) => { + launch_windows_store_app(app_id, client.name()) + } + } }) .await - .map_err(|error| format!("重启 OpenCode Desktop 任务失败: {error}"))? + .map_err(|error| format!("重启桌面应用任务失败: {error}"))? +} + +fn find_desktop_restart_target( + client: AgentClient, + home: &Path, +) -> Result { + let target = match client { + AgentClient::Codex => find_codex_app_installation(home), + AgentClient::OpenCode => { + find_opencode_desktop_application(home).map(DesktopAppTarget::Application) + } + AgentClient::ZCode => { + find_zcode_desktop_executable(home).map(DesktopAppTarget::Application) + } + AgentClient::ClaudeDesktop => { + let executable = + find_claude_desktop_executable(home).map(DesktopAppTarget::Application); + #[cfg(target_os = "windows")] + { + executable + .or_else(|| find_windows_claude_app_id().map(DesktopAppTarget::WindowsAppId)) + } + #[cfg(not(target_os = "windows"))] + { + executable + } + } + _ => None, + }; + target.ok_or_else(|| format!("未检测到 {} 桌面应用,请重新检测", client.name())) +} + +#[cfg(target_os = "windows")] +fn find_windows_claude_app_id() -> Option { + let script = "@(Get-StartApps) | Where-Object { $_.AppID -like 'Claude_*!*' -or $_.AppID -like 'Anthropic.Claude_*!*' } | Select-Object -First 1 -ExpandProperty AppID"; + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + &windows_powershell_encoded_command(script), + ]); + configure_background_command(&mut command); + let output = command_output_with_timeout(&mut command, Duration::from_secs(5)).ok()??; + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + (output.status.success() && !id.is_empty()).then_some(id) +} + +#[cfg(target_os = "windows")] +fn stop_other_desktop(target: &DesktopAppTarget, label: &str) -> Result<(), String> { + run_windows_desktop_stop_script(&windows_desktop_stop_script(target), label) +} + +#[cfg(target_os = "windows")] +fn windows_desktop_stop_script(target: &DesktopAppTarget) -> String { + let selector = match target { + DesktopAppTarget::Application(path) => format!("$targetExecutable = {}\n$targetRoot = $null", windows_powershell_single_quoted_literal(&path_to_string(path))), + DesktopAppTarget::WindowsAppId(app_id) => format!( + "$targetExecutable = $null\n$packageFamily = {}\n$package = @(Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq $packageFamily }}) | Select-Object -First 1\n$targetRoot = if ($package) {{ $package.InstallLocation }} else {{ $null }}\nif (-not $targetRoot) {{ throw 'Application package directory was not found' }}", + windows_powershell_single_quoted_literal(app_id.split('!').next().unwrap_or(app_id))), + }; + format!( + r#"$ErrorActionPreference = 'Stop' +{selector} +function Get-TargetProcesses {{ + @(Get-CimInstance Win32_Process | Where-Object {{ + $_.ExecutablePath -and + (($targetExecutable -and [string]::Equals($_.ExecutablePath, $targetExecutable, [System.StringComparison]::OrdinalIgnoreCase)) -or + ($targetRoot -and $_.ExecutablePath.StartsWith(($targetRoot.TrimEnd('\') + '\'), [System.StringComparison]::OrdinalIgnoreCase))) + }}) +}} +foreach ($process in @(Get-TargetProcesses)) {{ + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue +}} +$deadline = [DateTime]::UtcNow.AddSeconds(5) +do {{ + $remaining = @(Get-TargetProcesses) + if ($remaining.Count -eq 0) {{ exit 0 }} + Start-Sleep -Milliseconds 100 +}} while ([DateTime]::UtcNow -lt $deadline) +throw "Application did not exit; remaining process IDs: $($remaining.ProcessId -join ', ')" +"# + ) +} + +#[cfg(target_os = "macos")] +fn stop_other_desktop(target: &DesktopAppTarget, label: &str) -> Result<(), String> { + let DesktopAppTarget::Application(path) = target; + stop_macos_desktop_application(path, label) +} + +#[cfg(target_os = "linux")] +fn stop_other_desktop(target: &DesktopAppTarget, label: &str) -> Result<(), String> { + let DesktopAppTarget::Application(path) = target; + stop_linux_desktop_application(path, label) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +fn stop_other_desktop(_target: &DesktopAppTarget, label: &str) -> Result<(), String> { + Err(format!("当前平台不支持重启 {label}")) } fn resolve_launch_directory(value: Option<&str>, fallback: &Path) -> Result { @@ -641,16 +842,16 @@ fn launch_codex_desktop(home: &Path) -> Result<(), String> { launch_codex_target(&target) } -fn launch_codex_target(target: &CodexAppTarget) -> Result<(), String> { +fn launch_codex_target(target: &DesktopAppTarget) -> Result<(), String> { match target { #[cfg(target_os = "windows")] - CodexAppTarget::WindowsAppId(app_id) => launch_windows_store_app(app_id, "Codex App"), - CodexAppTarget::Application(path) => launch_desktop_agent(path, "Codex App"), + DesktopAppTarget::WindowsAppId(app_id) => launch_windows_store_app(app_id, "Codex App"), + DesktopAppTarget::Application(path) => launch_desktop_agent(path, "Codex App"), } } #[cfg(target_os = "windows")] -fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { +fn stop_codex_desktop(target: &DesktopAppTarget) -> Result<(), String> { let script = windows_codex_stop_script(target); run_windows_desktop_stop_script(&script, "Codex App") } @@ -696,13 +897,13 @@ fn run_windows_desktop_stop_script(script: &str, label: &str) -> Result<(), Stri } #[cfg(target_os = "windows")] -fn windows_codex_stop_script(target: &CodexAppTarget) -> String { +fn windows_codex_stop_script(target: &DesktopAppTarget) -> String { let selector = match target { - CodexAppTarget::Application(path) => format!( + DesktopAppTarget::Application(path) => format!( "$targetExecutable = {}\n$targetRoot = $null", windows_powershell_single_quoted_literal(&path_to_string(path)) ), - CodexAppTarget::WindowsAppId(app_id) => { + DesktopAppTarget::WindowsAppId(app_id) => { let package_family = app_id.split('!').next().unwrap_or(app_id); format!( concat!( @@ -771,8 +972,8 @@ throw "OpenCode Desktop did not exit; remaining process IDs: $($remaining.Proces } #[cfg(target_os = "macos")] -fn stop_codex_desktop(target: &CodexAppTarget) -> Result<(), String> { - let CodexAppTarget::Application(application) = target; +fn stop_codex_desktop(target: &DesktopAppTarget) -> Result<(), String> { + let DesktopAppTarget::Application(application) = target; stop_macos_desktop_application(application, "Codex App") } @@ -781,57 +982,87 @@ fn stop_opencode_desktop(application: &Path) -> Result<(), String> { stop_macos_desktop_application(application, "OpenCode Desktop") } -#[cfg(target_os = "macos")] -fn stop_macos_desktop_application(application: &Path, label: &str) -> Result<(), String> { - let application_bundle = application +#[cfg(any(target_os = "macos", test))] +fn desktop_process_ids_from_ps(output: &str, application: &Path) -> Vec { + let bundle = application .ancestors() - .find(|path| path.extension().and_then(|value| value.to_str()) == Some("app")) - .unwrap_or(application); - let process_name = application_bundle - .file_stem() - .and_then(|name| name.to_str()) - .filter(|name| !name.is_empty()) - .ok_or_else(|| format!("无法识别 {label} 进程名称"))?; - let application_name = application_bundle - .file_stem() - .and_then(|name| name.to_str()) - .unwrap_or(process_name); - let escaped_name = application_name.replace('\\', "\\\\").replace('"', "\\\""); - let _ = Command::new("osascript") - .args([ - "-e", - &format!("tell application \"{escaped_name}\" to quit"), - ]) - .status(); - wait_for_unix_process_exit(process_name, label) + .find(|path| path.extension().and_then(|value| value.to_str()) == Some("app")); + output + .lines() + .filter_map(|line| { + let line = line.trim(); + let split = line.find(char::is_whitespace)?; + let pid = line[..split].parse::().ok()?; + if pid <= 0 || pid == std::process::id() as i32 { + return None; + } + let executable = Path::new(line[split..].trim()); + (executable == application || bundle.is_some_and(|root| executable.starts_with(root))) + .then_some(pid) + }) + .collect() } #[cfg(target_os = "macos")] -fn wait_for_unix_process_exit(process_name: &str, label: &str) -> Result<(), String> { +fn macos_processes_for_application(application: &Path) -> Result, String> { + let output = Command::new("ps") + .args(["-axww", "-o", "pid=", "-o", "comm="]) + .output() + .map_err(|error| format!("读取桌面应用进程失败: {error}"))?; + if !output.status.success() { + return Err("读取桌面应用进程失败".to_string()); + } + Ok(desktop_process_ids_from_ps( + &String::from_utf8_lossy(&output.stdout), + application, + )) +} + +#[cfg(target_os = "macos")] +fn stop_macos_desktop_application(application: &Path, label: &str) -> Result<(), String> { + if macos_processes_for_application(application)?.is_empty() { + return Ok(()); + } + let bundle = application + .ancestors() + .find(|path| path.extension().and_then(|value| value.to_str()) == Some("app")) + .unwrap_or(application); + let escaped = path_to_string(bundle) + .replace('\\', "\\\\") + .replace('"', "\\\""); + let mut quit = Command::new("osascript"); + quit.args(["-e", &format!("tell application \"{escaped}\" to quit")]); + let _ = command_output_with_timeout(&mut quit, Duration::from_secs(5)); for _ in 0..50 { - let status = Command::new("pgrep").args(["-x", process_name]).status(); - if status.is_ok_and(|status| !status.success()) { + if macos_processes_for_application(application)?.is_empty() { return Ok(()); } thread::sleep(Duration::from_millis(100)); } - let _ = Command::new("pkill") - .args(["-KILL", "-x", process_name]) - .status(); - thread::sleep(Duration::from_millis(100)); - let status = Command::new("pgrep") - .args(["-x", process_name]) - .status() - .map_err(|error| format!("检查 {label} 进程失败: {error}"))?; - if status.success() { - Err(format!("{label} 未能完全关闭")) - } else { - Ok(()) + for pid in macos_processes_for_application(application)? { + if unsafe { libc::kill(pid, libc::SIGKILL) } != 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(format!("关闭 {label} 进程 {pid} 失败: {error}")); + } + } } + for _ in 0..10 { + if macos_processes_for_application(application)?.is_empty() { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + Err(format!("{label} 未能完全关闭")) } #[cfg(target_os = "linux")] fn stop_opencode_desktop(application: &Path) -> Result<(), String> { + stop_linux_desktop_application(application, "OpenCode Desktop") +} + +#[cfg(target_os = "linux")] +fn stop_linux_desktop_application(application: &Path, label: &str) -> Result<(), String> { let application = fs::canonicalize(application).unwrap_or_else(|_| application.to_path_buf()); let processes = linux_processes_for_application(&application); let mut signal_error = None; @@ -868,7 +1099,7 @@ fn stop_opencode_desktop(application: &Path) -> Result<(), String> { Err(error) } else { Err(format!( - "OpenCode Desktop 未能完全关闭;剩余进程 ID: {}", + "{label} 未能完全关闭;剩余进程 ID: {}", remaining .iter() .map(i32::to_string) @@ -913,8 +1144,11 @@ fn linux_process_matches_application(process_id: i32, application: &Path) -> boo .split(|byte| *byte == 0) .filter(|argument| !argument.is_empty()) .map(|argument| Path::new(OsStr::from_bytes(argument))) - .any(|argument| { - linux_process_path_matches(argument, application) + .enumerate() + .any(|(index, argument)| { + // An unrelated CLI may mention the desktop executable as an input file. + // Only argv[0] identifies a launcher; retain Nix wrapper discovery below. + (index == 0 && linux_process_path_matches(argument, application)) || installation_root.is_some_and(|root| { fs::canonicalize(argument) .ok() @@ -970,14 +1204,12 @@ fn signal_linux_process(process_id: i32, signal: i32) -> Result<(), String> { if error.raw_os_error() == Some(libc::ESRCH) { Ok(()) } else { - Err(format!( - "关闭 OpenCode Desktop 进程 {process_id} 失败: {error}" - )) + Err(format!("关闭桌面应用进程 {process_id} 失败: {error}")) } } #[cfg(not(any(target_os = "windows", target_os = "macos")))] -fn stop_codex_desktop(_target: &CodexAppTarget) -> Result<(), String> { +fn stop_codex_desktop(_target: &DesktopAppTarget) -> Result<(), String> { Err("当前平台不支持重启 Codex App".to_string()) } @@ -1325,6 +1557,269 @@ mod tests { assert_eq!(normalize_agent_terminal(" auto "), "auto"); } + #[test] + fn desktop_process_matching_is_scoped_to_the_exact_bundle() { + let application = Path::new("/Applications/Claude Desktop.app/Contents/MacOS/Claude"); + let processes = "10 /Applications/Claude Desktop.app/Contents/MacOS/Claude\n11 /Applications/Claude Desktop.app/Contents/Frameworks/Helper.app/Contents/MacOS/Helper\n12 /Applications/Claude Desktop.app.old/Contents/MacOS/Claude\n13 /Users/test/Claude Desktop.app/Contents/MacOS/Claude\n14 /usr/local/bin/claude\n"; + assert_eq!( + desktop_process_ids_from_ps(processes, application), + vec![10, 11] + ); + assert!(desktop_process_ids_from_ps( + "bad input\n-1 /opt/desktop", + Path::new("/opt/desktop") + ) + .is_empty()); + } + + #[cfg(windows)] + #[test] + fn desktop_restart_script_handles_paths_and_store_packages_without_name_wide_kills() { + for path in [ + r"C:\Apps\Claude's Desktop\Claude.exe", + r"C:\Apps\ZCode\ZCode.exe", + ] { + let script = + windows_desktop_stop_script(&DesktopAppTarget::Application(PathBuf::from(path))); + assert!(script.contains(&windows_powershell_single_quoted_literal(path))); + assert!(script.contains("[string]::Equals($_.ExecutablePath, $targetExecutable")); + assert!(script.contains("$remaining.Count -eq 0")); + assert!(!script.contains("taskkill")); + assert!(!script.contains("Stop-Process -Name")); + } + let script = windows_desktop_stop_script(&DesktopAppTarget::WindowsAppId( + "Anthropic.Claude_family!App".to_string(), + )); + assert!(script.contains("$packageFamily = 'Anthropic.Claude_family'")); + assert!(script.contains("$_.PackageFamilyName -eq $packageFamily")); + assert!(script.contains("$targetRoot.TrimEnd('\\') + '\\'")); + } + + #[cfg(windows)] + #[test] + #[ignore = "helper launched only by the isolated desktop restart test"] + fn desktop_restart_test_process() { + if let Some(marker) = env::var_os("CPA_DESKTOP_RESTART_TEST_MARKER") { + fs::write(marker, "ready").unwrap(); + thread::sleep(Duration::from_secs(30)); + } + } + + #[cfg(windows)] + #[test] + fn desktop_stop_waits_for_only_the_selected_installation() { + struct Processes { + children: Vec, + directory: PathBuf, + } + impl Drop for Processes { + fn drop(&mut self) { + for child in &mut self.children { + let _ = child.kill(); + let _ = child.wait(); + } + let _ = fs::remove_dir_all(&self.directory); + } + } + let directory = env::temp_dir().join(format!( + "cpa-desktop-restart-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&directory).unwrap(); + let executable = env::current_exe().unwrap(); + let target = directory.join("DesktopTest.exe"); + fs::copy(&executable, &target).unwrap(); + let mut processes = Processes { + children: Vec::new(), + directory, + }; + for (index, application) in [&target, &executable].into_iter().enumerate() { + let marker = processes.directory.join(format!("ready-{index}")); + let mut command = Command::new(application); + command + .args([ + "--exact", + "agents::launch::tests::desktop_restart_test_process", + "--ignored", + ]) + .env("CPA_DESKTOP_RESTART_TEST_MARKER", &marker) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + processes.children.push(command.spawn().unwrap()); + for _ in 0..100 { + if marker.exists() { + break; + } + thread::sleep(Duration::from_millis(50)); + } + assert!(marker.exists(), "desktop helper did not start"); + } + let installation = DesktopAppTarget::Application(target); + stop_other_desktop(&installation, "Test Desktop").unwrap(); + assert!(processes.children[0].try_wait().unwrap().is_some()); + assert!( + processes.children[1].try_wait().unwrap().is_none(), + "unrelated installation must keep running" + ); + // Stopping an installation with no running processes succeeds as well. + stop_other_desktop(&installation, "Test Desktop").unwrap(); + } + + struct HarnessRestartFixture { + state: DeepSeekHarnessProcessState, + directory: PathBuf, + } + + impl Drop for HarnessRestartFixture { + fn drop(&mut self) { + let _ = stop_managed_deepseek_harness(&self.state); + let _ = fs::remove_dir_all(&self.directory); + } + } + + fn harness_restart_fixture() -> HarnessRestartFixture { + let directory = env::temp_dir().join(format!( + "cpa-harness-restart-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&directory).unwrap(); + #[cfg(windows)] + let (executable, arguments) = (windows_powershell_executable(), vec![ + "-NoLogo".to_string(), "-NoProfile".to_string(), "-NonInteractive".to_string(), "-Command".to_string(), + "[IO.File]::AppendAllText((Join-Path (Get-Location).Path 'launches.txt'), ((Get-Location).Path + [Environment]::NewLine)); Start-Sleep -Seconds 60".to_string(), + ]); + #[cfg(unix)] + let (executable, arguments) = ( + PathBuf::from("/bin/sh"), + vec![ + "-c".to_string(), + "pwd >> launches.txt; sleep 60".to_string(), + ], + ); + let mut options = deepseek_harness_options("web"); + options.web_port = Some(0); + options.trusted_hosts = vec!["localhost".to_string()]; + options.patches = vec!["preserve a value with spaces".to_string()]; + let fixture = HarnessRestartFixture { + state: DeepSeekHarnessProcessState::default(), + directory, + }; + launch_managed_deepseek_harness( + &fixture.state, + &executable, + &fixture.directory, + &arguments, + "web", + Some(&options), + ) + .unwrap(); + wait_for_harness_launches(&fixture, 1); + fixture + } + + fn wait_for_harness_launches(fixture: &HarnessRestartFixture, expected: usize) { + for _ in 0..100 { + if fs::read_to_string(fixture.directory.join("launches.txt")) + .unwrap_or_default() + .lines() + .count() + >= expected + { + return; + } + thread::sleep(Duration::from_millis(50)); + } + panic!("test process did not record launch {expected}"); + } + + #[test] + fn harness_web_restart_preserves_snapshot_and_serializes_stop_and_spawn() { + let fixture = harness_restart_fixture(); + let old_pid = fixture.state.status().unwrap().pid; + let snapshot = fixture + .state + .process + .lock() + .unwrap() + .as_ref() + .unwrap() + .launch + .clone(); + let status = restart_managed_deepseek_harness_with( + &fixture.state, + |child| { + assert!(fixture.state.process.try_lock().is_err()); + terminate_deepseek_harness_process_tree(child) + }, + |executable, directory, arguments| { + assert!(fixture.state.process.try_lock().is_err()); + assert_eq!(executable, snapshot.executable); + assert_eq!(directory, snapshot.working_directory); + assert_eq!(arguments, snapshot.arguments); + spawn_managed_deepseek_harness(executable, directory, arguments) + }, + ) + .unwrap(); + assert!(status.running); + assert_ne!(status.pid, old_pid); + wait_for_harness_launches(&fixture, 2); + let launches = fs::read_to_string(fixture.directory.join("launches.txt")).unwrap(); + let lines = launches.lines().collect::>(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], lines[1]); + let process = fixture.state.process.lock().unwrap(); + let options = process.as_ref().unwrap().launch.options.as_ref().unwrap(); + assert_eq!(options.web_port, Some(0)); + assert_eq!(options.trusted_hosts, vec!["localhost"]); + assert_eq!(options.patches, vec!["preserve a value with spaces"]); + } + + #[test] + fn harness_restart_stop_failure_retains_process_and_does_not_spawn() { + let fixture = harness_restart_fixture(); + let pid = fixture.state.status().unwrap().pid; + let result = restart_managed_deepseek_harness_with( + &fixture.state, + |_| Err("stop failed".to_string()), + |_, _, _| panic!("must not spawn after stop failure"), + ); + assert_eq!(result.unwrap_err(), "stop failed"); + assert_eq!(fixture.state.status().unwrap().pid, pid); + } + + #[test] + fn harness_restart_spawn_failure_reports_stopped_state() { + let fixture = harness_restart_fixture(); + let result = restart_managed_deepseek_harness_with( + &fixture.state, + terminate_deepseek_harness_process_tree, + |_, _, _| Err("spawn failed".to_string()), + ); + assert_eq!(result.unwrap_err(), "spawn failed"); + assert!(!fixture.state.status().unwrap().running); + } + + #[test] + fn harness_restart_rejects_missing_or_non_web_process_without_stopping_it() { + assert!(restart_managed_deepseek_harness(&DeepSeekHarnessProcessState::default()).is_err()); + let fixture = harness_restart_fixture(); + let pid = fixture.state.status().unwrap().pid; + fixture.state.process.lock().unwrap().as_mut().unwrap().mode = "headless".to_string(); + assert!(restart_managed_deepseek_harness(&fixture.state) + .unwrap_err() + .contains("Web")); + assert_eq!(fixture.state.status().unwrap().pid, pid); + } + #[test] fn relative_launch_directory_is_rejected() { let error = resolve_launch_directory(Some("relative/project"), Path::new("/fallback")) @@ -1458,6 +1953,12 @@ mod tests { *state.process.lock().unwrap() = Some(ManagedDeepSeekHarnessProcess { child, mode: "test".to_string(), + launch: DeepSeekHarnessLaunchSnapshot { + executable: PathBuf::new(), + working_directory: PathBuf::new(), + arguments: Vec::new(), + options: None, + }, }); let running = state.status().unwrap(); @@ -1569,13 +2070,13 @@ mod tests { #[cfg(target_os = "windows")] #[test] fn codex_restart_script_is_scoped_to_the_detected_installation() { - let executable = CodexAppTarget::Application(PathBuf::from(r"C:\Apps\Codex\Codex.exe")); + let executable = DesktopAppTarget::Application(PathBuf::from(r"C:\Apps\Codex\Codex.exe")); let executable_script = windows_codex_stop_script(&executable); assert!(executable_script.contains(r"C:\Apps\Codex\Codex.exe")); assert!(executable_script.contains("Get-CimInstance Win32_Process")); assert!(!executable_script.contains("taskkill")); - let store = CodexAppTarget::WindowsAppId("OpenAI.Codex_123!App".to_string()); + let store = DesktopAppTarget::WindowsAppId("OpenAI.Codex_123!App".to_string()); let store_script = windows_codex_stop_script(&store); assert!(store_script.contains("OpenAI.Codex_123")); assert!(store_script.contains("PackageFamilyName")); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e2185a0..5f856ab 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -327,6 +327,15 @@ struct DeepSeekHarnessProcessState { struct ManagedDeepSeekHarnessProcess { child: Child, mode: String, + launch: DeepSeekHarnessLaunchSnapshot, +} + +#[derive(Clone)] +struct DeepSeekHarnessLaunchSnapshot { + executable: PathBuf, + working_directory: PathBuf, + arguments: Vec, + options: Option, } #[derive(Clone)] @@ -1291,8 +1300,7 @@ enum AgentClient { } #[derive(Clone, Debug)] -#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] // The desktop-app variants are constructed only on macOS/Windows builds. -enum CodexAppTarget { +enum DesktopAppTarget { Application(PathBuf), #[cfg(target_os = "windows")] WindowsAppId(String), @@ -2532,6 +2540,8 @@ fn main() { launch_agent, get_deepseek_harness_process_status, stop_deepseek_harness_process, + restart_deepseek_harness_process, + restart_agent_app, restart_codex_app, restart_opencode_app, get_lan_ipv4, diff --git a/src-tauri/src/tests/platform.rs b/src-tauri/src/tests/platform.rs index 267305b..2e1e85e 100644 --- a/src-tauri/src/tests/platform.rs +++ b/src-tauri/src/tests/platform.rs @@ -66,10 +66,10 @@ fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() { let app = parse_windows_codex_app_discovery_output("APPID:OpenAI.Codex_2p2nqsd0c76g0!App\r\n") .unwrap(); match app { - CodexAppTarget::WindowsAppId(app_id) => { + DesktopAppTarget::WindowsAppId(app_id) => { assert_eq!(app_id, "OpenAI.Codex_2p2nqsd0c76g0!App"); } - CodexAppTarget::Application(_) => panic!("expected Store application ID"), + DesktopAppTarget::Application(_) => panic!("expected Store application ID"), } let executable = parse_windows_codex_app_discovery_output( @@ -77,13 +77,13 @@ fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() { ) .unwrap(); match executable { - CodexAppTarget::Application(path) => { + DesktopAppTarget::Application(path) => { assert_eq!( path, PathBuf::from(r"C:\Program Files\OpenAI\ChatGPT\ChatGPT.exe") ); } - CodexAppTarget::WindowsAppId(_) => panic!("expected desktop executable"), + DesktopAppTarget::WindowsAppId(_) => panic!("expected desktop executable"), } assert!(parse_windows_codex_app_discovery_output("MSEdgePWA:ChatGPT\r\n").is_none()); @@ -279,7 +279,7 @@ function Get-AppxPackageManifest($Package) {{ parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout)); if expected { assert!( - matches!(target, Some(CodexAppTarget::Application(path)) if path == executable) + matches!(target, Some(DesktopAppTarget::Application(path)) if path == executable) ); } else { assert!(target.is_none()); diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index ebb57dc..f9ef3ed 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -843,7 +843,23 @@ export const jaOverrides = { 'agents.embedded.subtitle': 'ない場合は先にインストールしてください', 'agents.redetect': '再検出', 'agents.tabs.label': 'エージェント設定ページ', - 'agents.tabs.core': 'コア設定', + "agents.tabs.management": "設定管理", + "agents.run.title": "起動操作", + "agents.management.backups": "バックアップと復元", + "agents.management.backupsDescription": "ディスク上の設定を保存するか、手動バックアップから復元します。未適用の変更はバックアップに含まれません。", + "agents.management.models": "モデル一覧", + "agents.management.modelsDescription": "Codex に表示するモデルとその属性を管理します。", + "agents.management.reset": "設定のリセット", + "agents.management.resetDescription": "テンプレートはカスタム設定を上書きし、クリア操作は設定ファイルを削除します。実行前に手動バックアップを作成できます。", + "agents.management.plugin": "プラグイン管理", + "agents.management.pluginVersion": "インストール済みのバージョン:{version}", + "agents.management.pluginInstalled": "Pi プラグインをインストールしました。", + "agents.management.pluginUpdated": "Pi プラグインを更新しました。", + "agents.management.pluginUninstalled": "Pi プラグインをアンインストールしました。", + "agents.management.pluginMissing": "Pi プラグインが未インストールです。基本設定でモデルを選択してインストールしてください。", + "agents.deepseekLaunch.restart": "Web を再起動", + "agents.deepseekLaunch.restarting": "Web を再起動中", + 'agents.tabs.core': "基本設定", 'agents.tabs.sessions': 'セッション管理', 'agents.sessions.title': 'Codex セッション管理', 'agents.sessions.description': 'ローカルの Codex セッションをスキャンし、過去のセッションの復元、バックアップ、削除を安全に行います。', @@ -937,7 +953,7 @@ export const jaOverrides = { "agents.modify.authApiKey": "API キー", "agents.modify.authOAuth": "OAuth 設定方式", "agents.modify.matches": "保存済み", - "agents.modify.pending": "未保存", + "agents.modify.pending": "適用待ち", "agents.modify.checking": "設定を読み込み中…", "agents.modify.invalidState": "現在の設定を解析できません", "agents.modify.more": "その他の操作", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7899a3f..6187448 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -843,7 +843,23 @@ export const en: Record = { 'agents.embedded.subtitle': 'Install it first if it is not available', 'agents.redetect': 'Detect Again', 'agents.tabs.label': 'Agent configuration sections', - 'agents.tabs.core': 'Core Configuration', + "agents.tabs.management": "Configuration management", + "agents.run.title": "Run controls", + "agents.management.backups": "Backup and restore", + "agents.management.backupsDescription": "Save the configuration on disk, or restore a manual backup. Pending edits are not included in backups.", + "agents.management.models": "Model list", + "agents.management.modelsDescription": "Manage the models shown in Codex and their properties.", + "agents.management.reset": "Reset configuration", + "agents.management.resetDescription": "Templates replace custom configuration; clearing deletes configuration files. You can create a manual backup first.", + "agents.management.plugin": "Plugin management", + "agents.management.pluginVersion": "Installed plugin version: {version}", + "agents.management.pluginInstalled": "The Pi plugin is installed.", + "agents.management.pluginUpdated": "The Pi plugin is updated.", + "agents.management.pluginUninstalled": "The Pi plugin is uninstalled.", + "agents.management.pluginMissing": "The Pi plugin is not installed. Select a model and install it in Basic configuration.", + "agents.deepseekLaunch.restart": "Restart Web", + "agents.deepseekLaunch.restarting": "Restarting Web", + 'agents.tabs.core': "Basic configuration", 'agents.tabs.sessions': 'Session Management', 'agents.sessions.title': 'Codex Session Management', 'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.', @@ -955,7 +971,7 @@ export const en: Record = { "agents.modify.authApiKey": "API key", "agents.modify.authOAuth": "OAuth configuration mode", "agents.modify.matches": "Saved", - "agents.modify.pending": "Unsaved changes", + "agents.modify.pending": "Changes to apply", "agents.modify.checking": "Reading configuration…", "agents.modify.invalidState": "Cannot parse current configuration", "agents.modify.more": "More actions", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 47f09f0..374d305 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -842,7 +842,23 @@ export const zhCN = { 'agents.embedded.subtitle': '如果没有客户端请自行安装', 'agents.redetect': '重新检测', 'agents.tabs.label': '智能体配置子页面', - 'agents.tabs.core': '核心配置', + "agents.tabs.management": "配置管理", + "agents.run.title": "运行控制", + "agents.management.backups": "备份与恢复", + "agents.management.backupsDescription": "保存当前磁盘配置,或从手动备份恢复。备份不包含待应用的修改。", + "agents.management.models": "模型列表", + "agents.management.modelsDescription": "管理 Codex 中显示的模型及其属性。", + "agents.management.reset": "重置配置", + "agents.management.resetDescription": "模板会覆盖自定义配置;清空操作会删除配置文件。执行前可先手动备份。", + "agents.management.plugin": "插件管理", + "agents.management.pluginVersion": "当前插件版本:{version}", + "agents.management.pluginInstalled": "Pi 插件已安装。", + "agents.management.pluginUpdated": "Pi 插件已更新。", + "agents.management.pluginUninstalled": "Pi 插件已卸载。", + "agents.management.pluginMissing": "尚未安装 Pi 插件,请在基础配置中选择模型并安装。", + "agents.deepseekLaunch.restart": "重启 Web", + "agents.deepseekLaunch.restarting": "正在重启 Web", + 'agents.tabs.core': "基础配置", 'agents.tabs.sessions': '会话管理', 'agents.sessions.title': 'Codex 会话管理', 'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。', @@ -954,7 +970,7 @@ export const zhCN = { "agents.modify.authApiKey": "API 密钥", "agents.modify.authOAuth": "OAuth配置方式", "agents.modify.matches": "已保存", - "agents.modify.pending": "未保存", + "agents.modify.pending": "待应用", "agents.modify.checking": "正在读取配置…", "agents.modify.invalidState": "当前配置无法解析", "agents.modify.more": "更多操作", diff --git a/src/pages/AgentControls.tsx b/src/pages/AgentControls.tsx new file mode 100644 index 0000000..91d0fb8 --- /dev/null +++ b/src/pages/AgentControls.tsx @@ -0,0 +1,124 @@ +import { AppWindow, LoaderCircle, Play, RefreshCw, Square, Terminal, Trash2 } from 'lucide-react'; +import { useI18n } from '../i18n'; + +type LaunchTarget = { id: 'app' | 'cli'; label: string; detail: string }; + +export function AgentRunControls({ + name, dualTargets, desktop, targets, enabled, busyAction, harness, onLaunch, onRestart, onStop, onRestartWeb, error, +}: { + name: string; + dualTargets: boolean; + desktop: boolean; + targets: LaunchTarget[]; + enabled: boolean; + busyAction: string | null; + harness: { running: boolean; pid: number | null; mode: string | null } | null; + onLaunch: (target: LaunchTarget | null) => void; + onRestart: () => void; + onStop: () => void; + onRestartWeb: () => void; + error: string; +}) { + const { t } = useI18n(); + const busy = busyAction !== null; + const cli = targets.find((target) => target.id === 'cli') ?? null; + const app = targets.find((target) => target.id === 'app') ?? null; + const launchButton = (target: LaunchTarget | null, kind?: 'cli' | 'app') => { + const action = dualTargets ? `launch-${kind}` : 'launch'; + const starting = busyAction === action; + return ; + }; + return
+
{t('agents.run.title')}
+
+ {dualTargets ? <>{launchButton(cli, 'cli')}{launchButton(app, 'app')} + : harness?.running ? : launchButton(targets[0] ?? null)} + {desktop ? : null} + {harness?.running && harness.mode === 'web' ? : null} +
+ {error ? {error} : null} +
; +} + +export function AgentConfigurationFeedback({ pending, notice, error, description }: { + pending: boolean; notice: string; error: string; description: string; +}) { + const { t } = useI18n(); + if (!pending && !notice && !error && !description) return null; + return
+ {pending ? {t('agents.modify.pending')} : null} + {error ? {error} : null} + {notice ? {notice} : null} + {description ? {description} : null} +
; +} + +export function AgentConfigManagementPanel({ + pi, codex, busyAction, canTemplate, canUpdatePi, canUninstallPi, pluginInstalled, pluginVersion, updateLabel, + onBackup, onRestore, onTemplate, onClear, onCatalog, onUpdatePi, onUninstallPi, +}: { + pi: boolean; codex: boolean; busyAction: string | null; canTemplate: boolean; + canUpdatePi: boolean; canUninstallPi: boolean; pluginInstalled: boolean; pluginVersion: string | null; updateLabel: string; + onBackup: () => void; onRestore: () => void; onTemplate: () => void; onClear: () => void; + onCatalog: () => void; onUpdatePi: () => void; onUninstallPi: () => void; +}) { + const { t } = useI18n(); + const busy = busyAction !== null; + return
+ {!pi ?
+

{t('agents.management.backups')}

{t('agents.management.backupsDescription')}

+
+ + +
+
: null} + {codex ?
+

{t('agents.management.models')}

{t('agents.management.modelsDescription')}

+
+
: null} + {!pi ?
+

{t('agents.management.reset')}

{t('agents.management.resetDescription')}

+
+ + {codex ? : null} +
+
: null} + {pi ?
+

{t('agents.management.plugin')}

{pluginInstalled + ? t('agents.management.pluginVersion', { version: pluginVersion ?? t('agents.notFetched') }) + : t('agents.management.pluginMissing')}

{updateLabel ? {updateLabel} : null}
+ {pluginInstalled ?
+ + +
: null} +
: null} +
; +} diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index b281a83..bb5afc4 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -13,20 +13,14 @@ import { listen } from '@tauri-apps/api/event'; import { open } from '@tauri-apps/plugin-dialog'; import { AlertTriangle, - AppWindow, BadgeCheck, Bot, Check, ChevronDown, LoaderCircle, - Play, RefreshCw, Search, - SlidersHorizontal, - Square, Trash2, - Terminal, - Wrench, X, } from 'lucide-react'; import claudeIcon from '../assets/icons/claude.svg'; @@ -69,6 +63,7 @@ import { getCurrentLocale, translate, useI18n } from '../i18n'; import { CodexSessionsPanel } from './CodexSessionsPanel'; import { CodexModelCatalogDialog } from './CodexModelCatalogDialog'; import { AgentConfigBackupDialog } from './AgentConfigBackupDialog'; +import { AgentConfigManagementPanel, AgentConfigurationFeedback, AgentRunControls } from './AgentControls'; type AgentClientId = | 'claude-code' @@ -154,6 +149,20 @@ type ClaudeModelMappings = { disableAutoCompact: boolean; }; +type AgentFormValues = { + model: string; + oauthConfiguration: boolean; + mappings: ClaudeModelMappings | null; +}; + +const sameAgentFormValues = (left: AgentFormValues, right: AgentFormValues) => ( + sameAgentModel(left.model, right.model) + && left.oauthConfiguration === right.oauthConfiguration + && (left.mappings && right.mappings + ? sameAgentModelMappings(left.mappings, right.mappings) + : left.mappings === right.mappings) +); + const CODEX_OAUTH_LOGIN_REQUIRED_ERROR = 'CODEX_OAUTH_LOGIN_REQUIRED'; const DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS = 200_000; const DEFAULT_CLAUDE_AUTO_COMPACT_PCT = 90; @@ -187,6 +196,7 @@ let claudeModelMappingsDraftCache = createClaudeModelMappingsByClient(); let claudeCustomMappingCache = createClaudeBooleanByClient(); const claudeModelMappingsDirtyCache = createClaudeBooleanByClient(); let codexOauthConfigurationDraftCache: boolean | null = null; +let agentFormEditBaselineCache: Partial> = {}; const claudeMappingRoles = [ { @@ -214,11 +224,11 @@ type AgentDefinition = { descriptionKey: 'agents.description.claudeCode' | 'agents.description.claudeDesktop' | 'agents.description.codex' | 'agents.description.opencode' | 'agents.description.openclaw' | 'agents.description.hermes' | 'agents.description.deepseekHarness' | 'agents.description.zcode' | 'agents.description.kimiCode' | 'agents.description.grokBuild' | 'agents.description.pi'; }; -type AgentSubpageId = 'core' | 'sessions'; +type AgentSubpageId = 'core' | 'management' | 'sessions'; type AgentSubpageDefinition = { id: AgentSubpageId; - labelKey: 'agents.tabs.core' | 'agents.tabs.sessions'; + labelKey: 'agents.tabs.core' | 'agents.tabs.management' | 'agents.tabs.sessions'; clients?: readonly AgentClientId[]; }; @@ -296,6 +306,10 @@ const agentSubpages: AgentSubpageDefinition[] = [ id: 'core', labelKey: 'agents.tabs.core', }, + { + id: 'management', + labelKey: 'agents.tabs.management', + }, { id: 'sessions', labelKey: 'agents.tabs.sessions', @@ -661,6 +675,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [modelByClient, setModelByClient] = useState>>( readAgentModelSelections, ); + const [formEditBaselineByClient, setFormEditBaselineByClient] = useState( + () => ({ ...agentFormEditBaselineCache }), + ); const [claudeModelMappingsDraftByClient, setClaudeModelMappingsDraftByClientState] = useState( () => ({ 'claude-code': { ...claudeModelMappingsDraftCache['claude-code'] }, @@ -673,7 +690,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const [loading, setLoading] = useState(true); const [modelLoading, setModelLoading] = useState(false); const [busyAction, setBusyAction] = useState< - 'backup' | 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | null + 'backup' | 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null >(null); const busy = busyAction !== null; const [detectionError, setDetectionError] = useState(''); @@ -979,14 +996,35 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP : connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update'); const configurationWriteBlocked = loading || !activeStatus || connectionState === 'invalid'; const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? ''; - const appliedMappings = selected === 'claude-code' - ? activeStatus?.claudeCodeModelMappings - : activeStatus?.claudeDesktopModelMappings; - const formMatchesConfiguration = Boolean(activeStatus?.configValid && activeStatus?.configured) - && (isClaudeModelMappingClient - ? Boolean(appliedMappings && sameAgentModelMappings(claudeModelMappingsDraft, appliedMappings)) - : sameAgentModel(selectedModel, appliedModel)) - && (selected !== 'codex' || oauthConfiguration === Boolean(activeStatus?.oauthConfiguration)); + const formValues: AgentFormValues = { + model: isClaudeModelMappingClient ? '' : selectedModel, + oauthConfiguration: selected === 'codex' && oauthConfiguration, + mappings: isClaudeModelMappingClient ? claudeModelMappingsDraft : null, + }; + const hasPendingChanges = Boolean(formEditBaselineByClient[selected]); + + // Only user edits establish a baseline. Detection and default selections are not edits. + const trackFormEdit = (nextValues: AgentFormValues) => { + setConfigurationNotice(''); + setClearNotice(''); + setFormEditBaselineByClient((current) => { + const baseline = current[selected] ?? formValues; + const next = { ...current }; + if (sameAgentFormValues(baseline, nextValues)) delete next[selected]; + else next[selected] = baseline; + agentFormEditBaselineCache = next; + return next; + }); + }; + + const clearPendingChanges = () => { + setFormEditBaselineByClient((current) => { + const next = { ...current }; + delete next[selected]; + agentFormEditBaselineCache = next; + return next; + }); + }; const claudeMappingsReady = !isClaudeModelMappingClient || claudeMappingRoles.every((role) => Boolean(findAgentModel(models, claudeModelMappingsDraft[role.key])), @@ -1006,14 +1044,10 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP : selectedModelOption), ); const activeLaunchTargets = activeStatus?.launchTargets ?? []; - const defaultLaunchTarget = activeLaunchTargets[0] ?? null; - const cliLaunchTarget = activeLaunchTargets.find((target) => target.id === 'cli') ?? null; - const appLaunchTarget = activeLaunchTargets.find((target) => target.id === 'app') ?? null; const launchEnabled = Boolean( activeStatus?.supportedPlatform && activeStatus.installed, ); - const canLaunchTarget = (target: AgentLaunchTarget | null) => launchEnabled && Boolean(target); const activeLaunchDirectoryHistory = launchDirectoryHistory[selected] ?? []; const deepSeekHarnessLaunchModeLabel = { web: t('agents.deepseekLaunch.mode.web'), @@ -1049,7 +1083,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP void loadModels(selected); }; - const runEmbeddedPrimaryAction = () => { + const applySelectedConfiguration = () => { if (isPiClient) { void (activeStatus?.pluginInstalled ? repairPiProvider() : installPiProvider()); return; @@ -1071,6 +1105,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setClearNotice(''); const model = findAgentModel(models, value); if (!model) return; + if (!isClaudeModelMappingClient) trackFormEdit({ ...formValues, model: model.name }); setModelSelectionError(''); setModelByClient((current) => { const next = { ...current, [selected]: model.name }; @@ -1079,20 +1114,25 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }); }; + const editClaudeModelMappings = (update: (current: ClaudeModelMappings) => ClaudeModelMappings) => { + if (!isClaudeModelMappingClient) return; + const next = update(claudeModelMappingsDraft); + trackFormEdit({ ...formValues, mappings: next }); + if (!sameAgentModelMappings(claudeModelMappingsDraft, next)) { + claudeModelMappingsDirtyRef.current[selected] = true; + } + setClaudeModelMappingsDraftByClient((current) => ({ ...current, [selected]: next })); + }; + const selectEmbeddedModel = (value: string) => { const model = findAgentModel(models, value); if (!model) return; if (isClaudeModelMappingClient) { - setConfigurationNotice(''); - claudeModelMappingsDirtyRef.current[selected] = true; - setClaudeModelMappingsDraftByClient((current) => ({ + editClaudeModelMappings((current) => ({ ...current, - [selected]: { - ...current[selected], - opus: model.name, - sonnet: model.name, - haiku: model.name, - }, + opus: model.name, + sonnet: model.name, + haiku: model.name, })); } selectModel(model.name); @@ -1104,13 +1144,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP ) => { const model = findAgentModel(models, value); if (!model || !isClaudeModelMappingClient) return; - setConfigurationNotice(''); - claudeModelMappingsDirtyRef.current[selected] = true; setModelSelectionError(''); - setClaudeModelMappingsDraftByClient((current) => ({ - ...current, - [selected]: { ...current[selected], [role]: model.name }, - })); + editClaudeModelMappings((current) => ({ ...current, [role]: model.name })); }; const changeClaude1mPreference = ( @@ -1118,17 +1153,15 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP enabled: boolean, ) => { if (!isClaudeModelMappingClient) return; - setConfigurationNotice(''); - claudeModelMappingsDirtyRef.current[selected] = true; - setClaudeModelMappingsDraftByClient((current) => { - const next = { ...current[selected], [preference]: enabled }; + editClaudeModelMappings((current) => { + const next = { ...current, [preference]: enabled }; if (selected === 'claude-code') { const any1mEnabled = next.opus1m || next.sonnet1m || next.haiku1m; next.maxContextTokens = any1mEnabled ? 1_000_000 : DEFAULT_CLAUDE_CODE_MAX_CONTEXT_TOKENS; } - return { ...current, [selected]: next }; + return next; }); }; @@ -1137,50 +1170,24 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP value: number, ) => { if (selected !== 'claude-code') return; - setConfigurationNotice(''); - claudeModelMappingsDirtyRef.current[selected] = true; - setClaudeModelMappingsDraftByClient((current) => ({ - ...current, - [selected]: { ...current[selected], [key]: value }, - })); + editClaudeModelMappings((current) => ({ ...current, [key]: value })); }; const changeClaudeCodeAutoCompactDisabled = (disabled: boolean) => { if (selected !== 'claude-code') return; - setConfigurationNotice(''); - claudeModelMappingsDirtyRef.current[selected] = true; - setClaudeModelMappingsDraftByClient((current) => ({ - ...current, - [selected]: { - ...current[selected], - disableAutoCompact: disabled, - }, - })); + editClaudeModelMappings((current) => ({ ...current, disableAutoCompact: disabled })); }; const changeClaudeCustomMapping = (enabled: boolean) => { if (!isClaudeModelMappingClient) return; setClaudeCustomMappingByClient((current) => ({ ...current, [selected]: enabled })); setModelSelectionError(''); - setClaudeModelMappingsDraftByClient((current) => { - const currentClientDraft = current[selected]; - const next: ClaudeModelMappings = { - opus: resolveAgentModelForAliasMode(models, currentClientDraft.opus, enabled), - sonnet: resolveAgentModelForAliasMode(models, currentClientDraft.sonnet, enabled), - haiku: resolveAgentModelForAliasMode(models, currentClientDraft.haiku, enabled), - opus1m: currentClientDraft.opus1m, - sonnet1m: currentClientDraft.sonnet1m, - haiku1m: currentClientDraft.haiku1m, - maxContextTokens: currentClientDraft.maxContextTokens, - autoCompactPct: currentClientDraft.autoCompactPct, - disableAutoCompact: currentClientDraft.disableAutoCompact, - }; - if (!sameAgentModelMappings(currentClientDraft, next)) { - setConfigurationNotice(''); - claudeModelMappingsDirtyRef.current[selected] = true; - } - return { ...current, [selected]: next }; - }); + editClaudeModelMappings((current) => ({ + ...current, + opus: resolveAgentModelForAliasMode(models, current.opus, enabled), + sonnet: resolveAgentModelForAliasMode(models, current.sonnet, enabled), + haiku: resolveAgentModelForAliasMode(models, current.haiku, enabled), + })); }; const requireSelectedModel = () => { @@ -1238,6 +1245,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setConfigurationNotice(''); setClearNotice(''); if (!enabled) { + trackFormEdit({ ...formValues, oauthConfiguration: false }); setOauthConfigurationDraft(false); return; } @@ -1245,6 +1253,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setBusyAction('oauth-check'); try { await invoke('check_codex_oauth_login'); + trackFormEdit({ ...formValues, oauthConfiguration: true }); setOauthConfigurationDraft(true); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'enable')) { @@ -1273,6 +1282,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, }); + clearPendingChanges(); if (isClaudeModelMappingClient) { claudeModelMappingsDirtyRef.current[selected] = false; } @@ -1294,9 +1304,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP if (!model) return; setBusyAction('install-pi'); setConfigurationError(''); + setConfigurationNotice(''); + setClearNotice(''); try { await invoke('install_pi_provider', { model }); + clearPendingChanges(); await reloadStatusesAfterAction(); + setConfigurationNotice(t('agents.management.pluginInstalled')); onConfigurationApplied?.(); } catch (requestError) { setConfigurationError(String(requestError)); @@ -1310,10 +1324,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP if (!model) return; setBusyAction('update-pi'); setConfigurationError(''); + setConfigurationNotice(''); + setClearNotice(''); setPiProviderUpdateStatus(null); try { await invoke('update_pi_provider', { model }); + clearPendingChanges(); await reloadStatusesAfterAction(); + setConfigurationNotice(t('agents.management.pluginUpdated')); await loadPiProviderUpdateStatus(); } catch (requestError) { setConfigurationError(String(requestError)); @@ -1329,6 +1347,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setConfigurationError(''); try { const result = await invoke('repair_pi_provider', { model }); + clearPendingChanges(); await reloadStatusesAfterAction(); setConfigurationNotice(t(result.outcome === 'unchanged' ? 'agents.backup.unchanged' : 'agents.backup.updated')); onConfigurationApplied?.(); @@ -1342,10 +1361,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP const uninstallPiProvider = async () => { setBusyAction('uninstall-pi'); setConfigurationError(''); + setConfigurationNotice(''); + setClearNotice(''); setPiProviderUpdateStatus(null); try { await invoke('uninstall_pi_provider'); + clearPendingChanges(); await reloadStatusesAfterAction(); + setConfigurationNotice(t('agents.management.pluginUninstalled')); } catch (requestError) { setConfigurationError(String(requestError)); } finally { @@ -1372,6 +1395,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP claudeCodeModelMappings: selected === 'claude-code' ? claudeModelMappings : null, claudeDesktopModelMappings: selected === 'claude-desktop' ? claudeModelMappings : null, }); + clearPendingChanges(); setDefaultConfirmOpen(false); if (isClaudeModelMappingClient) { claudeModelMappingsDirtyRef.current[selected] = false; @@ -1399,6 +1423,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP setClearNotice(''); try { await invoke('clear_codex_config'); + clearPendingChanges(); setClearConfirmOpen(false); setClearNotice(t('agents.clear.success')); await reloadStatusesAfterAction(); @@ -1507,11 +1532,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; const restartDesktopApp = async () => { - if (!hasIndependentCliAndApp) return; + if (!['codex', 'opencode', 'claude-desktop', 'zcode'].includes(selected)) return; setBusyAction('restart-app'); setLaunchError(''); try { - await invoke(selected === 'codex' ? 'restart_codex_app' : 'restart_opencode_app'); + await invoke('restart_agent_app', { client: selected }); } catch (requestError) { if (!handleOAuthLoginError(requestError, 'launch')) { setLaunchError(String(requestError)); @@ -1521,6 +1546,19 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP } }; + const restartDeepSeekHarness = async () => { + setBusyAction('restart-deepseek'); + setLaunchError(''); + try { + setDeepSeekHarnessProcessStatus(await invoke('restart_deepseek_harness_process')); + } catch (requestError) { + setLaunchError(String(requestError)); + await loadDeepSeekHarnessProcessStatus().catch(() => undefined); + } finally { + setBusyAction(null); + } + }; + const launchFromDirectoryDialog = async () => { if (!launchDirectoryTarget) return; const workingDirectory = launchDirectory.trim(); @@ -1589,7 +1627,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP }; const availableSubpages = agentSubpages.filter( - (subpage) => !subpage.clients || subpage.clients.includes(selected), + (subpage) => (!subpage.clients || subpage.clients.includes(selected)) && (!embedded || subpage.id !== 'sessions'), ); const oauthLoginRequiredDescription = oauthLoginRequiredAction === 'enable' ? ( <> @@ -1649,6 +1687,9 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP > {agent.name}{listStatusText(status)} + {status?.installed ? ( +