diff --git a/CHANGELOG.md b/CHANGELOG.md index b7dc3f66..286d7f3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ All notable changes to this project will be documented in this file. - VSCode: improved server management to ensure it initializes within the workspace directory with context-aware readiness checks. - VSCode: added responsive layout with sessions sidebar + chat side-by-side when wide, compact header, and streamlined settings. - Web: the server now automatically resolves and uses an available port if the default is occupied. +- Stability: fixed heartbeat race condition causing session stalls during long tasks (thanks to @tybradle). +- Desktop: fixed commands for worktree setup access to PATH. ## [1.4.9] - 2026-01-14 diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index 89a0204c..e210c8bb 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -2,9 +2,10 @@ use crate::path_utils::expand_tilde_path; use crate::{DesktopRuntime, SettingsStore}; use serde::{Deserialize, Serialize}; use std::{ - collections::{HashSet, VecDeque}, + collections::{HashMap, HashSet, VecDeque}, path::{Path, PathBuf}, process::Command, + sync::OnceLock, time::UNIX_EPOCH, }; use tokio::fs; @@ -848,6 +849,126 @@ pub async fn write_file( }) } +static CACHED_LOGIN_SHELL_PATH: OnceLock> = OnceLock::new(); + +#[cfg(target_os = "macos")] +fn get_user_shell() -> Option { + let username = + dirs::home_dir().and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string()))?; + + let output = Command::new("dscl") + .args([".", "-read", &format!("/Users/{}", username), "UserShell"]) + .output() + .ok()?; + + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.split(':').nth(1).map(|s| s.trim().to_string()) + } else { + None + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn get_user_shell() -> Option { + std::env::var("SHELL").ok() +} + +#[cfg(not(unix))] +fn get_user_shell() -> Option { + None +} + +fn build_shell_path_command(shell: &str) -> Vec { + let shell_name = std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("sh"); + + match shell_name { + "nu" | "nushell" => vec![ + "-l".to_string(), + "-i".to_string(), + "-c".to_string(), + "echo $\"__PATH__=($env.PATH | str join (char esep))\"".to_string(), + ], + "bash" => vec![ + "-lic".to_string(), + "source ~/.bashrc 2>/dev/null; echo \"__PATH__=$PATH\"".to_string(), + ], + "fish" => vec![ + "-lic".to_string(), + "echo \"__PATH__=$PATH\"".to_string(), + ], + _ => vec![ + "-lic".to_string(), + "echo \"__PATH__=$PATH\"".to_string(), + ], + } +} + +fn detect_login_shell_path() -> Option { + #[cfg(not(unix))] + { + None + } + #[cfg(unix)] + { + let shell = get_user_shell().unwrap_or_else(|| "/bin/zsh".into()); + let args = build_shell_path_command(&shell); + + let output = match Command::new(&shell).args(&args).output() { + Ok(o) => o, + Err(_) => return None, + }; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some(path) = line.strip_prefix("__PATH__=") { + if !path.is_empty() { + return Some(path.to_string()); + } + } + } + None + } +} + +fn get_cached_login_shell_path() -> Option<&'static String> { + CACHED_LOGIN_SHELL_PATH + .get_or_init(detect_login_shell_path) + .as_ref() +} + +fn merge_paths(login_path: &str, current: &str) -> String { + let mut segments = Vec::new(); + let mut seen = HashSet::new(); + + for part in login_path.split(':').chain(current.split(':')) { + if !part.is_empty() && !seen.contains(part) { + seen.insert(part.to_string()); + segments.push(part); + } + } + + segments.join(":") +} + +fn build_augmented_env() -> HashMap { + let mut env: HashMap = std::env::vars().collect(); + + if let Some(login_path) = get_cached_login_shell_path() { + let current = env.get("PATH").cloned().unwrap_or_default(); + env.insert("PATH".to_string(), merge_paths(login_path, ¤t)); + } + + env +} + #[tauri::command] pub async fn exec_commands( commands: Vec, @@ -886,6 +1007,8 @@ pub async fn exec_commands( let shell_flag = if cfg!(windows) { "/c" } else { "-c" }; + let augmented_env = build_augmented_env(); + let mut results = Vec::new(); for cmd in commands { @@ -905,6 +1028,7 @@ pub async fn exec_commands( let cwd_clone = resolved_cwd.clone(); let shell_clone = shell.clone(); let cmd_clone = cmd_trimmed.to_string(); + let env_clone = augmented_env.clone(); // Run command synchronously in blocking task let result = tokio::task::spawn_blocking(move || { @@ -912,6 +1036,7 @@ pub async fn exec_commands( .arg(shell_flag) .arg(&cmd_clone) .current_dir(&cwd_clone) + .envs(&env_clone) .output() { Ok(output) => CommandResult {