diff --git a/Cargo.lock b/Cargo.lock index b4736d7f0..7e4b7ff6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2232,6 +2232,7 @@ dependencies = [ "tokio-util", "unicode-segmentation", "unicode-width", + "windows-sys 0.61.2", "winreg 0.55.0", ] diff --git a/README.md b/README.md index 52e9fc3df..f286c7e05 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Parallel execution framework with specialized agents and real-time streaming: - **Parallel exploration**: Reviewer agent can spawn explore agents for large codebase analysis - **Real-time artifact streaming**: Task outputs stream as they're created, not just at completion - **Full output access**: Read complete subagent output via `agent://` resources when previews truncate -- **Isolation backends**: `isolated: true` runs tasks in git worktrees or fuse-overlay filesystems, with patch or branch merge strategies +- **Isolation backends**: `isolated: true` runs tasks in git worktrees, Unix fuse-overlay filesystems, or Windows ProjFS (`fuse-projfs`), with patch or branch merge strategies - **Async background jobs**: Background execution with configurable concurrency (up to 100 jobs) and `await` tool for blocking on results - **Agent Control Center**: `/agents` dashboard for managing and creating custom agents - **AI-powered agent creation**: Generate custom agent definitions with the architect model @@ -923,7 +923,7 @@ async: task: eager: false isolation: - mode: none # none | worktree | fuse-overlay + mode: none # none | worktree | fuse-overlay | fuse-projfs merge: patch # patch | branch ``` diff --git a/bun.lock b/bun.lock index deede252f..ccaab19bd 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ "pi-ai": "./src/cli.ts", }, "dependencies": { - "@anthropic-ai/sdk": "^0.78", + "@anthropic-ai/sdk": "^0.77.0", "@aws-sdk/client-bedrock-runtime": "^3.998", "@bufbuild/protobuf": "^2.11", "@google/genai": "^1.43", @@ -44,7 +44,7 @@ "ajv-formats": "^3.0", "openai": "^6.25", "partial-json": "^0.1", - "zod": "^4.3", + "zod": "4.3.5", }, "devDependencies": { "@types/bun": "^1.3", @@ -176,7 +176,7 @@ }, }, "packages": { - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.77.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A=="], "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], @@ -1002,7 +1002,7 @@ "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index db5d82e65..75eb3588b 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -67,5 +67,11 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] winreg = "0.55" +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Storage_ProjectedFileSystem", + "Win32_System_Com", + "Win32_System_LibraryLoader", +] } [build-dependencies] napi-build = "2" diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 97cd47a02..2af9ac9af 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -34,6 +34,7 @@ pub mod html; pub mod image; pub mod keys; pub mod prof; +pub mod projfs_overlay; pub mod ps; pub mod pty; pub mod shell; diff --git a/crates/pi-natives/src/projfs_overlay.rs b/crates/pi-natives/src/projfs_overlay.rs new file mode 100644 index 000000000..5dcb95673 --- /dev/null +++ b/crates/pi-natives/src/projfs_overlay.rs @@ -0,0 +1,853 @@ +//! Windows ProjFS-backed overlay lifecycle for task isolation. + +use napi::bindgen_prelude::*; +use napi_derive::napi; + +const PROJFS_UNAVAILABLE_PREFIX: &str = "PROJFS_UNAVAILABLE:"; + +#[napi(object)] +pub struct ProjfsOverlayProbeResult { + pub available: bool, + pub reason: Option, +} + +#[napi(js_name = "projfsOverlayProbe")] +pub fn projfs_overlay_probe() -> ProjfsOverlayProbeResult { + imp::probe() +} + +#[napi(js_name = "projfsOverlayStart")] +pub fn projfs_overlay_start(lower_root: String, projection_root: String) -> Result<()> { + imp::start(&lower_root, &projection_root) +} + +#[napi(js_name = "projfsOverlayStop")] +pub fn projfs_overlay_stop(projection_root: String) -> Result<()> { + imp::stop(&projection_root); + Ok(()) +} + +#[cfg(not(windows))] +mod imp { + use napi::bindgen_prelude::*; + + use super::{PROJFS_UNAVAILABLE_PREFIX, ProjfsOverlayProbeResult}; + + const UNSUPPORTED_REASON: &str = "Windows ProjFS is unavailable on this platform"; + + pub fn probe() -> ProjfsOverlayProbeResult { + ProjfsOverlayProbeResult { available: false, reason: Some(UNSUPPORTED_REASON.to_string()) } + } + + pub fn start(_lower_root: &str, _projection_root: &str) -> Result<()> { + Err(Error::from_reason(format!("{PROJFS_UNAVAILABLE_PREFIX} {UNSUPPORTED_REASON}"))) + } + + pub const fn stop(_projection_root: &str) {} +} + +#[cfg(windows)] +#[allow( + clippy::undocumented_unsafe_blocks, + reason = "Windows ProjFS bridge is FFI-heavy and safety is validated by pointer and handle \ + checks" +)] +mod imp { + use std::{ + collections::{BTreeMap, btree_map::Entry}, + ffi::{OsStr, OsString, c_void}, + fs, + io::{self, ErrorKind, Read, Seek, SeekFrom}, + mem, + os::windows::{ + ffi::{OsStrExt, OsStringExt}, + fs::MetadataExt, + }, + path::{Path, PathBuf}, + sync::{Arc, LazyLock}, + }; + + use napi::bindgen_prelude::*; + use parking_lot::Mutex; + use windows_sys::{ + Win32::{ + Foundation::{ + ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_FILE_SYSTEM_VIRTUALIZATION_BUSY, + ERROR_FILE_SYSTEM_VIRTUALIZATION_INVALID_OPERATION, + ERROR_FILE_SYSTEM_VIRTUALIZATION_METADATA_CORRUPT, + ERROR_FILE_SYSTEM_VIRTUALIZATION_PROVIDER_UNKNOWN, + ERROR_FILE_SYSTEM_VIRTUALIZATION_UNAVAILABLE, ERROR_HANDLE_EOF, + ERROR_INSUFFICIENT_BUFFER, ERROR_INVALID_FUNCTION, ERROR_INVALID_PARAMETER, + ERROR_MOD_NOT_FOUND, ERROR_NOT_SUPPORTED, ERROR_OLD_WIN_VERSION, ERROR_OUTOFMEMORY, + ERROR_PROC_NOT_FOUND, FreeLibrary, GetLastError, HMODULE, + }, + Storage::ProjectedFileSystem::{ + PRJ_CALLBACK_DATA, PRJ_CALLBACKS, PRJ_CB_DATA_FLAG_ENUM_RESTART_SCAN, + PRJ_CB_DATA_FLAG_ENUM_RETURN_SINGLE_ENTRY, PRJ_DIR_ENTRY_BUFFER_HANDLE, + PRJ_FILE_BASIC_INFO, PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, PRJ_PLACEHOLDER_INFO, + PRJ_STARTVIRTUALIZING_OPTIONS, + }, + System::{ + Com::CoCreateGuid, + LibraryLoader::{GetProcAddress, LoadLibraryW}, + }, + }, + core::{GUID, HRESULT, PCSTR, PCWSTR}, + }; + + use super::{PROJFS_UNAVAILABLE_PREFIX, ProjfsOverlayProbeResult}; + + const EMPTY_WIDE: [u16; 1] = [0]; + const MAX_READ_CHUNK: usize = 1024 * 1024; + const E_NOTIMPL: HRESULT = 0x8000_4001_u32 as i32; + const E_FAIL: HRESULT = 0x8000_4005_u32 as i32; + + type PrjAllocateAlignedBufferFn = + unsafe extern "system" fn(PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, usize) -> *mut c_void; + type PrjFreeAlignedBufferFn = unsafe extern "system" fn(*const c_void); + type PrjFileNameCompareFn = unsafe extern "system" fn(PCWSTR, PCWSTR) -> i32; + type PrjFileNameMatchFn = unsafe extern "system" fn(PCWSTR, PCWSTR) -> bool; + type PrjMarkDirectoryAsPlaceholderFn = + unsafe extern "system" fn(PCWSTR, PCWSTR, *const c_void, *const GUID) -> HRESULT; + type PrjStartVirtualizingFn = unsafe extern "system" fn( + PCWSTR, + *const PRJ_CALLBACKS, + *const c_void, + *const PRJ_STARTVIRTUALIZING_OPTIONS, + *mut PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, + ) -> HRESULT; + type PrjStopVirtualizingFn = unsafe extern "system" fn(PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT); + type PrjFillDirEntryBuffer2Fn = unsafe extern "system" fn( + PRJ_DIR_ENTRY_BUFFER_HANDLE, + PCWSTR, + *const PRJ_FILE_BASIC_INFO, + *const c_void, + ) -> HRESULT; + type PrjWriteFileDataFn = unsafe extern "system" fn( + PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, + *const GUID, + *const c_void, + u64, + u32, + ) -> HRESULT; + type PrjWritePlaceholderInfoFn = unsafe extern "system" fn( + PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, + PCWSTR, + *const PRJ_PLACEHOLDER_INFO, + u32, + ) -> HRESULT; + + struct ProjfsApi { + module: HMODULE, + prj_allocate_aligned_buffer: PrjAllocateAlignedBufferFn, + prj_free_aligned_buffer: PrjFreeAlignedBufferFn, + prj_file_name_compare: PrjFileNameCompareFn, + prj_file_name_match: PrjFileNameMatchFn, + prj_mark_directory_as_placeholder: PrjMarkDirectoryAsPlaceholderFn, + prj_start_virtualizing: PrjStartVirtualizingFn, + prj_stop_virtualizing: PrjStopVirtualizingFn, + prj_fill_dir_entry_buffer2: PrjFillDirEntryBuffer2Fn, + prj_write_file_data: PrjWriteFileDataFn, + prj_write_placeholder_info: PrjWritePlaceholderInfoFn, + } + + unsafe impl Send for ProjfsApi {} + unsafe impl Sync for ProjfsApi {} + + impl Drop for ProjfsApi { + fn drop(&mut self) { + if !self.module.is_null() { + unsafe { + FreeLibrary(self.module); + } + } + } + } + + impl ProjfsApi { + fn load() -> std::result::Result { + let library_name = to_wide(OsStr::new("ProjectedFSLib.dll")); + let module = unsafe { LoadLibraryW(library_name.as_ptr()) }; + if module.is_null() { + let win32 = unsafe { GetLastError() }; + return Err(format!("ProjectedFSLib.dll could not be loaded (win32={win32})")); + } + + macro_rules! load_symbol { + ($name:literal, $ty:ty) => {{ + let proc = unsafe { + GetProcAddress(module, concat!($name, "\0").as_ptr().cast::() as PCSTR) + }; + let Some(proc) = proc else { + unsafe { + FreeLibrary(module); + } + return Err(format!("ProjectedFSLib.dll missing symbol {}", $name)); + }; + unsafe { mem::transmute:: isize, $ty>(proc) } + }}; + } + + Ok(Self { + module, + prj_allocate_aligned_buffer: load_symbol!( + "PrjAllocateAlignedBuffer", + PrjAllocateAlignedBufferFn + ), + prj_free_aligned_buffer: load_symbol!("PrjFreeAlignedBuffer", PrjFreeAlignedBufferFn), + prj_file_name_compare: load_symbol!("PrjFileNameCompare", PrjFileNameCompareFn), + prj_file_name_match: load_symbol!("PrjFileNameMatch", PrjFileNameMatchFn), + prj_mark_directory_as_placeholder: load_symbol!( + "PrjMarkDirectoryAsPlaceholder", + PrjMarkDirectoryAsPlaceholderFn + ), + prj_start_virtualizing: load_symbol!("PrjStartVirtualizing", PrjStartVirtualizingFn), + prj_stop_virtualizing: load_symbol!("PrjStopVirtualizing", PrjStopVirtualizingFn), + prj_fill_dir_entry_buffer2: load_symbol!( + "PrjFillDirEntryBuffer2", + PrjFillDirEntryBuffer2Fn + ), + prj_write_file_data: load_symbol!("PrjWriteFileData", PrjWriteFileDataFn), + prj_write_placeholder_info: load_symbol!( + "PrjWritePlaceholderInfo", + PrjWritePlaceholderInfoFn + ), + }) + } + } + + struct DirectoryEntry { + name_wide: Vec, + basic_info: PRJ_FILE_BASIC_INFO, + } + + #[derive(Default)] + struct DirectoryEnumeration { + entries: Vec, + cursor: usize, + search_expression: Option>, + } + + struct ProviderContext { + lower_root: PathBuf, + api: Arc, + enumerations: Mutex>, + } + + struct ProjfsSession { + virtualization_context: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT, + provider_context: *mut ProviderContext, + callbacks: Box, + api_handle: Arc, + } + + // SAFETY: Session ownership is synchronized through `PROJFS_SESSIONS`; raw + // pointers are only created/freed on controlled start/stop paths and not + // concurrently aliased. + unsafe impl Send for ProjfsSession {} + + enum ProjfsSessionState { + Starting, + Active(ProjfsSession), + } + + static PROJFS_SESSIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(BTreeMap::new())); + + pub fn probe() -> ProjfsOverlayProbeResult { + match ProjfsApi::load() { + Ok(_) => ProjfsOverlayProbeResult { available: true, reason: None }, + Err(reason) => ProjfsOverlayProbeResult { available: false, reason: Some(reason) }, + } + } + + pub fn start(lower_root: &str, projection_root: &str) -> Result<()> { + let api = Arc::new(ProjfsApi::load().map_err(unavailable_error)?); + let lower_root_path = resolve_existing_dir(lower_root)?; + let projection_root_path = resolve_projection_root(projection_root)?; + let projection_key = normalize_session_key(&projection_root_path); + + { + let mut sessions = PROJFS_SESSIONS.lock(); + if sessions.contains_key(&projection_key) { + return Err(Error::from_reason(format!( + "ProjFS overlay is already active for {}", + projection_root_path.display() + ))); + } + sessions.insert(projection_key.clone(), ProjfsSessionState::Starting); + } + + let mut instance_id = GUID::default(); + let guid_hr = unsafe { CoCreateGuid(&raw mut instance_id) }; + if is_failed(guid_hr) { + PROJFS_SESSIONS.lock().remove(&projection_key); + return Err(Error::from_reason(format!( + "Unable to create ProjFS instance identifier ({})", + format_hresult(guid_hr) + ))); + } + + let root_wide = to_wide(projection_root_path.as_os_str()); + let mark_hr = unsafe { + (api.prj_mark_directory_as_placeholder)( + root_wide.as_ptr(), + std::ptr::null(), + std::ptr::null(), + &raw const instance_id, + ) + }; + if is_failed(mark_hr) { + PROJFS_SESSIONS.lock().remove(&projection_key); + return Err(classify_start_error("mark placeholder root", mark_hr)); + } + + let provider_context = Box::new(ProviderContext { + lower_root: lower_root_path, + api: api.clone(), + enumerations: Mutex::new(BTreeMap::new()), + }); + let provider_context_ptr = Box::into_raw(provider_context); + let callbacks = Box::new(PRJ_CALLBACKS { + StartDirectoryEnumerationCallback: Some(start_directory_enumeration_callback), + EndDirectoryEnumerationCallback: Some(end_directory_enumeration_callback), + GetDirectoryEnumerationCallback: Some(get_directory_enumeration_callback), + GetPlaceholderInfoCallback: Some(get_placeholder_info_callback), + GetFileDataCallback: Some(get_file_data_callback), + ..Default::default() + }); + + let mut virtualization_context: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT = std::ptr::null_mut(); + let start_hr = unsafe { + (api.prj_start_virtualizing)( + root_wide.as_ptr(), + callbacks.as_ref(), + provider_context_ptr.cast::(), + std::ptr::null(), + &raw mut virtualization_context, + ) + }; + if is_failed(start_hr) { + PROJFS_SESSIONS.lock().remove(&projection_key); + // SAFETY: `provider_context_ptr` comes from `Box::into_raw` above and start + // failed, so ProjFS never took ownership and this function remains the sole + // owner. + unsafe { + drop(Box::from_raw(provider_context_ptr)); + } + return Err(classify_start_error("start virtualization", start_hr)); + } + + let started_session = ProjfsSession { + virtualization_context, + provider_context: provider_context_ptr, + callbacks, + api_handle: api, + }; + + let mut sessions = PROJFS_SESSIONS.lock(); + match sessions.entry(projection_key) { + Entry::Occupied(mut entry) => { + if matches!(entry.get(), ProjfsSessionState::Starting) { + entry.insert(ProjfsSessionState::Active(started_session)); + Ok(()) + } else { + drop(entry); + drop(sessions); + stop_projfs_session(started_session); + Err(Error::from_reason(format!( + "ProjFS overlay is already active for {}", + projection_root_path.display() + ))) + } + }, + Entry::Vacant(_) => { + drop(sessions); + stop_projfs_session(started_session); + Err(Error::from_reason(format!( + "ProjFS overlay start was canceled for {}", + projection_root_path.display() + ))) + }, + } + } + + pub fn stop(projection_root: &str) { + let projection_root_path = resolve_absolute_path(Path::new(projection_root)); + let projection_root_path = + fs::canonicalize(&projection_root_path).unwrap_or(projection_root_path); + let key = normalize_session_key(&projection_root_path); + let session_state = PROJFS_SESSIONS.lock().remove(&key); + let Some(ProjfsSessionState::Active(session)) = session_state else { + return; + }; + + stop_projfs_session(session); + } + + fn stop_projfs_session(session: ProjfsSession) { + // SAFETY: The session holds the live ProjFS context and provider pointer + // created in `start`; this function consumes ownership and runs the + // corresponding one-time teardown. + unsafe { + (session.api_handle.prj_stop_virtualizing)(session.virtualization_context); + drop(Box::from_raw(session.provider_context)); + } + drop(session.callbacks); + } + + unsafe extern "system" fn start_directory_enumeration_callback( + callback_data: *const PRJ_CALLBACK_DATA, + enumeration_id: *const GUID, + ) -> HRESULT { + let Ok((callback_data, context)) = callback_context(callback_data) else { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + }; + if enumeration_id.is_null() { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + } + + let target_path = callback_relative_path(callback_data); + let entries = match list_directory_entries(context, &target_path) { + Ok(entries) => entries, + Err(err) => return io_error_to_hresult(&err), + }; + + let mut enumerations = context.enumerations.lock(); + enumerations.insert(guid_to_u128(unsafe { &*enumeration_id }), DirectoryEnumeration { + entries, + cursor: 0, + search_expression: None, + }); + 0 + } + + unsafe extern "system" fn end_directory_enumeration_callback( + callback_data: *const PRJ_CALLBACK_DATA, + enumeration_id: *const GUID, + ) -> HRESULT { + let Ok((_, context)) = callback_context(callback_data) else { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + }; + if enumeration_id.is_null() { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + } + + context + .enumerations + .lock() + .remove(&guid_to_u128(unsafe { &*enumeration_id })); + 0 + } + + unsafe extern "system" fn get_directory_enumeration_callback( + callback_data: *const PRJ_CALLBACK_DATA, + enumeration_id: *const GUID, + search_expression: PCWSTR, + dir_entry_buffer_handle: PRJ_DIR_ENTRY_BUFFER_HANDLE, + ) -> HRESULT { + let Ok((callback_data, context)) = callback_context(callback_data) else { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + }; + if enumeration_id.is_null() { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + } + + let enum_key = guid_to_u128(unsafe { &*enumeration_id }); + let mut enumerations = context.enumerations.lock(); + let Some(enumeration) = enumerations.get_mut(&enum_key) else { + return 0; + }; + + if callback_data.Flags & PRJ_CB_DATA_FLAG_ENUM_RESTART_SCAN != 0 { + enumeration.cursor = 0; + enumeration.search_expression = None; + } + if !search_expression.is_null() { + let expression = read_pcwstr(search_expression); + if !expression.is_empty() { + let mut with_nul = expression; + with_nul.push(0); + enumeration.search_expression = Some(with_nul); + } + } + + while enumeration.cursor < enumeration.entries.len() { + let entry = &enumeration.entries[enumeration.cursor]; + let matched = if let Some(expression) = &enumeration.search_expression { + unsafe { + (context.api.prj_file_name_match)(entry.name_wide.as_ptr(), expression.as_ptr()) + } + } else { + true + }; + + if matched { + let hr = unsafe { + (context.api.prj_fill_dir_entry_buffer2)( + dir_entry_buffer_handle, + entry.name_wide.as_ptr(), + &raw const entry.basic_info, + std::ptr::null(), + ) + }; + if is_failed(hr) { + if win32_from_hresult(hr) == Some(ERROR_INSUFFICIENT_BUFFER) { + break; + } + return hr; + } + } + + enumeration.cursor += 1; + if callback_data.Flags & PRJ_CB_DATA_FLAG_ENUM_RETURN_SINGLE_ENTRY != 0 { + break; + } + } + + 0 + } + + unsafe extern "system" fn get_placeholder_info_callback( + callback_data: *const PRJ_CALLBACK_DATA, + ) -> HRESULT { + let Ok((callback_data, context)) = callback_context(callback_data) else { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + }; + + let relative_path = callback_relative_path(callback_data); + let source_path = context.lower_root.join(relative_path); + let metadata = match fs::symlink_metadata(&source_path) { + Ok(metadata) => metadata, + Err(err) => return io_error_to_hresult(&err), + }; + + let placeholder_info = + PRJ_PLACEHOLDER_INFO { FileBasicInfo: to_basic_info(&metadata), ..Default::default() }; + + let destination = if callback_data.FilePathName.is_null() { + EMPTY_WIDE.as_ptr() + } else { + callback_data.FilePathName + }; + + unsafe { + (context.api.prj_write_placeholder_info)( + callback_data.NamespaceVirtualizationContext, + destination, + &raw const placeholder_info, + mem::size_of::() as u32, + ) + } + } + + unsafe extern "system" fn get_file_data_callback( + callback_data: *const PRJ_CALLBACK_DATA, + byte_offset: u64, + length: u32, + ) -> HRESULT { + let Ok((callback_data, context)) = callback_context(callback_data) else { + return hresult_from_win32(ERROR_INVALID_PARAMETER); + }; + if length == 0 { + return 0; + } + + let relative_path = callback_relative_path(callback_data); + let source_path = context.lower_root.join(relative_path); + let mut file = match fs::File::open(&source_path) { + Ok(file) => file, + Err(err) => return io_error_to_hresult(&err), + }; + if let Err(err) = file.seek(SeekFrom::Start(byte_offset)) { + return io_error_to_hresult(&err); + } + + let chunk_size = usize::min(length as usize, MAX_READ_CHUNK); + let aligned_ptr = unsafe { + (context.api.prj_allocate_aligned_buffer)( + callback_data.NamespaceVirtualizationContext, + chunk_size, + ) + }; + if aligned_ptr.is_null() { + return hresult_from_win32(ERROR_OUTOFMEMORY); + } + let mut aligned_buffer = AlignedBuffer::new(context.api.clone(), aligned_ptr, chunk_size); + + let mut written = 0usize; + while written < length as usize { + let to_read = usize::min(aligned_buffer.len(), length as usize - written); + if let Err(err) = file.read_exact(&mut aligned_buffer.as_mut_slice()[..to_read]) { + return io_error_to_hresult(&err); + } + + let hr = unsafe { + (context.api.prj_write_file_data)( + callback_data.NamespaceVirtualizationContext, + &raw const callback_data.DataStreamId, + aligned_buffer.as_mut_slice().as_ptr().cast::(), + byte_offset + written as u64, + to_read as u32, + ) + }; + if is_failed(hr) { + return hr; + } + + written += to_read; + } + + 0 + } + + struct AlignedBuffer { + api: Arc, + ptr: *mut c_void, + len: usize, + } + + impl AlignedBuffer { + const fn new(api: Arc, ptr: *mut c_void, len: usize) -> Self { + Self { api, ptr, len } + } + + const fn len(&self) -> usize { + self.len + } + + const fn as_mut_slice(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.ptr.cast::(), self.len) } + } + } + + impl Drop for AlignedBuffer { + fn drop(&mut self) { + unsafe { + (self.api.prj_free_aligned_buffer)(self.ptr); + } + } + } + + fn callback_context( + callback_data: *const PRJ_CALLBACK_DATA, + ) -> std::result::Result<(&'static PRJ_CALLBACK_DATA, &'static ProviderContext), HRESULT> { + if callback_data.is_null() { + return Err(hresult_from_win32(ERROR_INVALID_PARAMETER)); + } + let callback_data = unsafe { &*callback_data }; + if callback_data.InstanceContext.is_null() { + return Err(hresult_from_win32(ERROR_INVALID_PARAMETER)); + } + let context = unsafe { &*(callback_data.InstanceContext.cast::()) }; + Ok((callback_data, context)) + } + + fn list_directory_entries( + context: &ProviderContext, + relative_path: &Path, + ) -> io::Result> { + let source_dir = context.lower_root.join(relative_path); + let mut entries = Vec::new(); + for entry in fs::read_dir(&source_dir)? { + let entry = entry?; + let metadata = entry.metadata()?; + let name = entry.file_name(); + let mut name_wide = to_wide(name.as_os_str()); + if name_wide.is_empty() { + continue; + } + entries.push(DirectoryEntry { + name_wide: { + name_wide.shrink_to_fit(); + name_wide + }, + basic_info: to_basic_info(&metadata), + }); + } + + entries.sort_by(|left, right| { + let compare = unsafe { + (context.api.prj_file_name_compare)(left.name_wide.as_ptr(), right.name_wide.as_ptr()) + }; + compare.cmp(&0) + }); + Ok(entries) + } + + fn to_basic_info(metadata: &fs::Metadata) -> PRJ_FILE_BASIC_INFO { + PRJ_FILE_BASIC_INFO { + IsDirectory: metadata.is_dir(), + FileSize: metadata.file_size() as i64, + CreationTime: metadata.creation_time() as i64, + LastAccessTime: metadata.last_access_time() as i64, + LastWriteTime: metadata.last_write_time() as i64, + ChangeTime: metadata.last_write_time() as i64, + FileAttributes: metadata.file_attributes(), + } + } + + fn callback_relative_path(callback_data: &PRJ_CALLBACK_DATA) -> PathBuf { + if callback_data.FilePathName.is_null() { + return PathBuf::new(); + } + let raw = read_pcwstr(callback_data.FilePathName); + if raw.is_empty() { + return PathBuf::new(); + } + PathBuf::from(OsString::from_wide(&raw)) + } + + fn read_pcwstr(value: PCWSTR) -> Vec { + if value.is_null() { + return Vec::new(); + } + + let mut len = 0usize; + unsafe { + while *value.add(len) != 0 { + len += 1; + } + std::slice::from_raw_parts(value, len).to_vec() + } + } + + fn resolve_existing_dir(path: &str) -> Result { + let resolved = resolve_absolute_path(Path::new(path)); + let metadata = fs::metadata(&resolved).map_err(|err| { + Error::from_reason(format!("Invalid ProjFS lower root {}: {err}", resolved.display())) + })?; + if !metadata.is_dir() { + return Err(Error::from_reason(format!( + "Invalid ProjFS lower root {}: path is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn resolve_projection_root(path: &str) -> Result { + let resolved = resolve_absolute_path(Path::new(path)); + fs::create_dir_all(&resolved).map_err(|err| { + Error::from_reason(format!( + "Unable to create ProjFS projection root {}: {err}", + resolved.display() + )) + })?; + let metadata = fs::metadata(&resolved).map_err(|err| { + Error::from_reason(format!( + "Unable to access ProjFS projection root {}: {err}", + resolved.display() + )) + })?; + if !metadata.is_dir() { + return Err(Error::from_reason(format!( + "Invalid ProjFS projection root {}: path is not a directory", + resolved.display() + ))); + } + Ok(fs::canonicalize(&resolved).unwrap_or(resolved)) + } + + fn resolve_absolute_path(path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path)) + } + } + + fn normalize_session_key(path: &Path) -> String { + path.to_string_lossy().to_ascii_lowercase() + } + + fn to_wide(value: &OsStr) -> Vec { + let mut encoded: Vec = value.encode_wide().collect(); + encoded.push(0); + encoded + } + + fn unavailable_error(reason: String) -> Error { + Error::from_reason(format!("{PROJFS_UNAVAILABLE_PREFIX} {reason}")) + } + + fn classify_start_error(phase: &str, hr: HRESULT) -> Error { + let detail = format!("ProjFS {phase} failed ({})", format_hresult(hr)); + if is_unavailable_hresult(hr) { + return unavailable_error(detail); + } + Error::from_reason(detail) + } + + const fn is_unavailable_hresult(hr: HRESULT) -> bool { + if hr == E_NOTIMPL { + return true; + } + let Some(win32) = win32_from_hresult(hr) else { + return false; + }; + matches!( + win32, + ERROR_NOT_SUPPORTED + | ERROR_INVALID_FUNCTION + | ERROR_MOD_NOT_FOUND + | ERROR_PROC_NOT_FOUND + | ERROR_OLD_WIN_VERSION + | ERROR_FILE_SYSTEM_VIRTUALIZATION_UNAVAILABLE + | ERROR_FILE_SYSTEM_VIRTUALIZATION_PROVIDER_UNKNOWN + | ERROR_FILE_SYSTEM_VIRTUALIZATION_METADATA_CORRUPT + | ERROR_FILE_SYSTEM_VIRTUALIZATION_INVALID_OPERATION + | ERROR_FILE_SYSTEM_VIRTUALIZATION_BUSY + ) + } + + fn format_hresult(hr: HRESULT) -> String { + if let Some(win32) = win32_from_hresult(hr) { + format!("HRESULT=0x{:08X}, win32={win32}", hr as u32) + } else { + format!("HRESULT=0x{:08X}", hr as u32) + } + } + + const fn win32_from_hresult(hr: HRESULT) -> Option { + let raw = hr as u32; + if (raw & 0xffff_0000) == 0x8007_0000 { + Some(raw & 0xffff) + } else { + None + } + } + + const fn hresult_from_win32(code: u32) -> HRESULT { + if code == 0 { + 0 + } else { + ((code & 0x0000_ffff) | 0x8007_0000) as i32 + } + } + + fn io_error_to_hresult(err: &io::Error) -> HRESULT { + if let Some(code) = err.raw_os_error() + && code > 0 + { + return hresult_from_win32(code as u32); + } + + match err.kind() { + ErrorKind::NotFound => hresult_from_win32(ERROR_FILE_NOT_FOUND), + ErrorKind::PermissionDenied => hresult_from_win32(ERROR_ACCESS_DENIED), + ErrorKind::UnexpectedEof => hresult_from_win32(ERROR_HANDLE_EOF), + ErrorKind::OutOfMemory => hresult_from_win32(ERROR_OUTOFMEMORY), + _ => E_FAIL, + } + } + + fn guid_to_u128(guid: &GUID) -> u128 { + let bytes: [u8; 16] = unsafe { mem::transmute(*guid) }; + u128::from_le_bytes(bytes) + } + + const fn is_failed(hr: HRESULT) -> bool { + hr < 0 + } +} diff --git a/crates/pi-natives/src/ps.rs b/crates/pi-natives/src/ps.rs index 1bf8ff413..35cbe0fcc 100644 --- a/crates/pi-natives/src/ps.rs +++ b/crates/pi-natives/src/ps.rs @@ -131,7 +131,7 @@ mod platform { use smallvec::SmallVec; #[repr(C)] - #[allow(non_snake_case)] + #[allow(non_snake_case, reason = "Windows PROCESSENTRY32W field names must match Win32 ABI")] struct PROCESSENTRY32W { dwSize: u32, cntUsage: u32, @@ -145,25 +145,27 @@ mod platform { szExeFile: [u16; 260], } - type HANDLE = *mut std::ffi::c_void; - const INVALID_HANDLE_VALUE: HANDLE = -1isize as HANDLE; + type Handle = *mut std::ffi::c_void; + const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; const TH32CS_SNAPPROCESS: u32 = 0x00000002; const PROCESS_TERMINATE: u32 = 0x0001; #[link(name = "kernel32")] unsafe extern "system" { - fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> HANDLE; - fn Process32FirstW(hSnapshot: HANDLE, lppe: *mut PROCESSENTRY32W) -> i32; - fn Process32NextW(hSnapshot: HANDLE, lppe: *mut PROCESSENTRY32W) -> i32; - fn CloseHandle(hObject: HANDLE) -> i32; - fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> HANDLE; - fn TerminateProcess(hProcess: HANDLE, uExitCode: u32) -> i32; + fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> Handle; + fn Process32FirstW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn Process32NextW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; + fn CloseHandle(hObject: Handle) -> i32; + fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle; + fn TerminateProcess(hProcess: Handle, uExitCode: u32) -> i32; } - /// Build a map of parent_pid -> [child_pids] for all processes. + /// Build a map of `parent_pid` -> [`child_pids`] for all processes. fn build_process_tree() -> HashMap> { let mut tree: HashMap> = HashMap::new(); + // SAFETY: Toolhelp snapshot APIs are called with initialized structs and valid + // handles. unsafe { let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if snapshot == INVALID_HANDLE_VALUE { @@ -173,14 +175,14 @@ mod platform { let mut entry: PROCESSENTRY32W = mem::zeroed(); entry.dwSize = mem::size_of::() as u32; - if Process32FirstW(snapshot, &mut entry) != 0 { + if Process32FirstW(snapshot, &raw mut entry) != 0 { loop { tree .entry(entry.th32ParentProcessID) .or_default() .push(entry.th32ProcessID); - if Process32NextW(snapshot, &mut entry) == 0 { + if Process32NextW(snapshot, &raw mut entry) == 0 { break; } } @@ -215,6 +217,8 @@ mod platform { /// Terminate `pid` (Windows ignores `signal`). /// Returns true when the process is terminated. pub fn kill_pid(pid: i32, _signal: i32) -> bool { + // SAFETY: OpenProcess/TerminateProcess are called with kernel-provided process + // IDs and handles are always closed. unsafe { let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32); if handle.is_null() || handle == INVALID_HANDLE_VALUE { @@ -228,13 +232,13 @@ mod platform { /// Process groups are not exposed on Windows. /// Always returns `None`. - pub fn process_group_id(_pid: i32) -> Option { + pub const fn process_group_id(_pid: i32) -> Option { None } /// Process groups are not exposed on Windows. /// Always returns `false`. - pub fn kill_process_group(_pgid: i32, _signal: i32) -> bool { + pub const fn kill_process_group(_pgid: i32, _signal: i32) -> bool { false } } @@ -268,12 +272,14 @@ pub fn kill_tree(pid: i32, signal: i32) -> u32 { /// Get the process group id for `pid`. /// Returns `None` when the process is missing or unsupported on the platform. +#[allow(clippy::missing_const_for_fn, reason = "Dispatches to platform-specific implementation")] pub fn process_group_id(pid: i32) -> Option { platform::process_group_id(pid) } /// Send `signal` to the process group `pgid`. /// Returns false when process groups are unsupported on the platform. +#[allow(clippy::missing_const_for_fn, reason = "Dispatches to platform-specific implementation")] pub fn kill_process_group(pgid: i32, signal: i32) -> bool { platform::kill_process_group(pgid, signal) } diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index 3e77a2f33..789ee5554 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -347,7 +347,7 @@ const fn exit_code(result: &ExecutionResult) -> i32 { } #[cfg(windows)] -fn normalize_env_key(key: &str) -> &str { +const fn normalize_env_key(key: &str) -> &str { if key.eq_ignore_ascii_case("PATH") { "PATH" } else { @@ -368,8 +368,7 @@ fn merge_path_values(existing: &str, incoming: &str) -> String { push_unique_paths(&mut merged, &mut seen, incoming); std::env::join_paths(merged.iter()) - .map(|paths| paths.to_string_lossy().to_string()) - .unwrap_or_else(|_| merged.join(";")) + .map_or_else(|_| merged.join(";"), |paths| paths.to_string_lossy().to_string()) } #[cfg(windows)] @@ -452,10 +451,10 @@ async fn create_session(config: &ShellConfig) -> Result { } #[cfg(windows)] - if merged_path.is_none() { - if let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH")) { - merged_path = Some(value.to_string_lossy().to_string()); - } + if merged_path.is_none() + && let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH")) + { + merged_path = Some(value.to_string_lossy().to_string()); } if let Some(path_value) = merged_path { diff --git a/crates/pi-natives/src/shell/windows.rs b/crates/pi-natives/src/shell/windows.rs index 5f7871b08..ec9f23546 100644 --- a/crates/pi-natives/src/shell/windows.rs +++ b/crates/pi-natives/src/shell/windows.rs @@ -112,12 +112,11 @@ fn query_git_install_path_from_registry() -> Option { let key_paths = ["SOFTWARE\\GitForWindows", "SOFTWARE\\WOW6432Node\\GitForWindows"]; for key_path in key_paths { - if let Ok(key) = hklm.open_subkey(key_path) { - if let Ok(path) = key.get_value::("InstallPath") { - if !path.is_empty() { - return Some(path); - } - } + if let Ok(key) = hklm.open_subkey(key_path) + && let Ok(path) = key.get_value::("InstallPath") + && !path.is_empty() + { + return Some(path); } } diff --git a/packages/ai/package.json b/packages/ai/package.json index 080d7ff84..2aad9ee8c 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -37,7 +37,7 @@ "test": "bun test" }, "dependencies": { - "@anthropic-ai/sdk": "^0.78", + "@anthropic-ai/sdk": "^0.77.0", "@aws-sdk/client-bedrock-runtime": "^3.998", "@bufbuild/protobuf": "^2.11", "@google/genai": "^1.43", @@ -48,7 +48,7 @@ "ajv-formats": "^3.0", "openai": "^6.25", "partial-json": "^0.1", - "zod": "^4.3" + "zod": "4.3.5" }, "devDependencies": { "@types/bun": "^1.3" diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 096b5d9b7..bd5fff5d6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,18 +6,21 @@ - Added LM Studio integration to the model registry and discovery flow. - Added support for authenticating with LM Studio using the `/login lm-studio` command. +- Added `fuse-projfs` task isolation mode for Windows ProjFS-backed overlays. ### Changed - Updated Anthropic Foundry environment variable documentation and CLI help text to the canonical names: `CLAUDE_CODE_USE_FOUNDRY`, `CLAUDE_CODE_CLIENT_CERT`, and `CLAUDE_CODE_CLIENT_KEY` - Documented Foundry-specific Anthropic runtime configuration (`FOUNDRY_BASE_URL`, `ANTHROPIC_FOUNDRY_API_KEY`, `ANTHROPIC_CUSTOM_HEADERS`, `NODE_EXTRA_CA_CERTS`) in environment variable reference docs +- `fuse-overlay` task isolation now targets `fuse-overlayfs` on Unix hosts only; on Windows it falls back to `worktree` with a `` suggesting `fuse-projfs`. +- `fuse-projfs` now performs Windows ProjFS preflight checks and falls back to `worktree` when host or repository prerequisites are unavailable. +- Cross-repo patch capture now uses the platform null device (`NUL` on Windows, `/dev/null` elsewhere) for `git diff --no-index`. ### Fixed - Fixed MCP resource subscription handling to prevent unsubscribing when notifications are re-enabled after being disabled - Fixed LM Studio base URL validation to preserve invalid configured URLs instead of silently falling back to localhost - Fixed URI template matching to correctly handle expressions that expand to empty strings - ## [13.5.6] - 2026-03-01 ### Changed @@ -1067,7 +1070,6 @@ - Improved error reporting in fetch tool to include HTTP status codes when URL fetching fails - Fixed fetch tool to preserve actual response metadata (finalUrl, contentType) instead of defaults when requests fail -||||||| parent of a70a34c8b (fix(coding-agent/debug): Sanitized debug log rendering) ## [12.1.0] - 2026-02-13 diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 29fa22b5a..2b03be0b3 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -906,9 +906,10 @@ Despite the name `runSubprocess`, `packages/coding-agent/src/task/executor.ts` c What _is_ isolated is execution context and artifacts, not process memory: -- Optional filesystem isolation is controlled by the `task.isolation.mode` setting (`"none"`, `"worktree"`, or `"fuse-overlay"`). +- Optional filesystem isolation is controlled by the `task.isolation.mode` setting (`"none"`, `"worktree"`, `"fuse-overlay"`, or `"fuse-projfs"`). - **worktree**: `ensureWorktree(...)`, `applyBaseline(...)`, `captureDeltaPatch(...)`, `cleanupWorktree(...)`. Nested non-submodule git repos are discovered and handled independently. - - **fuse-overlay**: `ensureFuseOverlay(...)` (mounts a copy-on-write overlay via `fuse-overlayfs`), `captureDeltaPatch(...)`, `cleanupFuseOverlay(...)`. No baseline apply needed since the overlay reflects the full working tree. Fails outright if mount fails. + - **fuse-overlay**: `ensureFuseOverlay(...)`, `captureDeltaPatch(...)`, `cleanupFuseOverlay(...)` using `fuse-overlayfs` on Unix hosts. On Windows, this mode falls back to `worktree` with a system notification. + - **fuse-projfs**: `ensureProjfsOverlay(...)`, `captureDeltaPatch(...)`, `cleanupProjfsOverlay(...)` using ProjFS on Windows. Missing ProjFS prerequisites fall back to `worktree` with a system notification; non-prerequisite startup errors still fail the task. - The `task.isolation.merge` setting controls how isolated changes are integrated back: - **patch** (default): captures a diff via `captureDeltaPatch(...)`, combines patches, and applies with `git apply`. - **branch**: each task commits to a temp branch (`omp/task/`) via `commitToBranch(...)`, then `mergeTaskBranches(...)` cherry-picks them sequentially onto HEAD. If `git apply` fails inside `commitToBranch`, the error is non-fatal — the agent result is preserved with a `merge failed` status. diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 61494902f..d74bb6e1f 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -570,12 +570,13 @@ export const SETTINGS_SCHEMA = { // ───────────────────────────────────────────────────────────────────────── "task.isolation.mode": { type: "enum", - values: ["none", "worktree", "fuse-overlay"] as const, + values: ["none", "worktree", "fuse-overlay", "fuse-projfs"] as const, default: "none", ui: { tab: "tools", label: "Task isolation", - description: "Isolation mode for subagents (none, git worktree, or fuse-overlay)", + description: + "Isolation mode for subagents (none, git worktree, fuse-overlayfs on Unix, or ProjFS on Windows via fuse-projfs; unsupported modes fall back to worktree)", submenu: true, }, }, diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index 5813ca1bb..f8f870a35 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -97,7 +97,16 @@ const OPTION_PROVIDERS: Partial> = { "task.isolation.mode": [ { value: "none", label: "None", description: "No isolation" }, { value: "worktree", label: "Worktree", description: "Git worktree isolation" }, - { value: "fuse-overlay", label: "Fuse Overlay", description: "COW overlay via fuse-overlayfs" }, + { + value: "fuse-overlay", + label: "Fuse Overlay", + description: "COW overlay via fuse-overlayfs (Unix only)", + }, + { + value: "fuse-projfs", + label: "Fuse ProjFS", + description: "COW overlay via ProjFS (Windows only; falls back to worktree if unavailable)", + }, ], // Task isolation merge strategy "task.isolation.merge": [ diff --git a/packages/coding-agent/src/task/index.ts b/packages/coding-agent/src/task/index.ts index f3c93d577..d982caa1b 100644 --- a/packages/coding-agent/src/task/index.ts +++ b/packages/coding-agent/src/task/index.ts @@ -32,6 +32,7 @@ import "../tools/review"; import { generateCommitMessage } from "../utils/commit-message-generator"; import { discoverAgents, getAgent } from "./discovery"; import { runSubprocess } from "./executor"; +import { resolveIsolationBackendForTaskExecution } from "./isolation-backend"; import { AgentOutputManager } from "./output-manager"; import { mapWithConcurrencyLimit, Semaphore } from "./parallel"; import { renderCall, renderResult } from "./render"; @@ -52,10 +53,12 @@ import { captureBaseline, captureDeltaPatch, cleanupFuseOverlay, + cleanupProjfsOverlay, cleanupTaskBranches, cleanupWorktree, commitToBranch, ensureFuseOverlay, + ensureProjfsOverlay, ensureWorktree, getRepoRoot, mergeTaskBranches, @@ -442,7 +445,7 @@ export class TaskTool implements AgentTool { content: [ { type: "text", - text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree' or 'fuse-overlay'.", + text: "Task isolation is disabled. Remove the isolated argument or set task.isolation.mode to 'worktree', 'fuse-overlay', or 'fuse-projfs'.", }, ], details: { @@ -605,6 +608,29 @@ export class TaskTool implements AgentTool { } } + let effectiveIsolationMode = isolationMode; + let isolationBackendWarning = ""; + try { + const resolvedIsolation = await resolveIsolationBackendForTaskExecution(isolationMode, isIsolated, repoRoot); + effectiveIsolationMode = resolvedIsolation.effectiveIsolationMode; + isolationBackendWarning = resolvedIsolation.warning; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + content: [ + { + type: "text", + text: message, + }, + ], + details: { + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; + } + // Derive artifacts directory const sessionFile = this.session.getSessionFile(); const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null; @@ -761,8 +787,10 @@ export class TaskTool implements AgentTool { } const taskBaseline = structuredClone(baseline); - if (isolationMode === "fuse-overlay") { + if (effectiveIsolationMode === "fuse-overlay") { isolationDir = await ensureFuseOverlay(repoRoot, task.id); + } else if (effectiveIsolationMode === "fuse-projfs") { + isolationDir = await ensureProjfsOverlay(repoRoot, task.id); } else { isolationDir = await ensureWorktree(repoRoot, task.id); await applyBaseline(isolationDir, taskBaseline); @@ -871,8 +899,10 @@ export class TaskTool implements AgentTool { }; } finally { if (isolationDir) { - if (isolationMode === "fuse-overlay") { + if (effectiveIsolationMode === "fuse-overlay") { await cleanupFuseOverlay(isolationDir); + } else if (effectiveIsolationMode === "fuse-projfs") { + await cleanupProjfsOverlay(isolationDir); } else { await cleanupWorktree(isolationDir); } @@ -1108,6 +1138,7 @@ export class TaskTool implements AgentTool { }); const outputIds = results.filter(r => !r.aborted || r.output.trim()).map(r => `agent://${r.id}`); + const backendSummaryPrefix = isolationBackendWarning ? `\n\n${isolationBackendWarning}` : ""; const summary = renderPromptTemplate(taskSummaryTemplate, { successCount, totalCount: results.length, @@ -1117,7 +1148,7 @@ export class TaskTool implements AgentTool { summaries, outputIds, agentName, - mergeSummary, + mergeSummary: `${backendSummaryPrefix}${mergeSummary}`, }); // Cleanup temp directory if used diff --git a/packages/coding-agent/src/task/isolation-backend.ts b/packages/coding-agent/src/task/isolation-backend.ts new file mode 100644 index 000000000..3e0dbf959 --- /dev/null +++ b/packages/coding-agent/src/task/isolation-backend.ts @@ -0,0 +1,72 @@ +import { projfsOverlayProbe } from "@oh-my-pi/pi-natives"; +import { Snowflake } from "@oh-my-pi/pi-utils"; +import { cleanupProjfsOverlay, ensureProjfsOverlay, isProjfsUnavailableError } from "./worktree"; + +export type TaskIsolationMode = "none" | "worktree" | "fuse-overlay" | "fuse-projfs"; + +export interface IsolationBackendResolution { + effectiveIsolationMode: TaskIsolationMode; + warning: string; +} + +export async function resolveIsolationBackendForTaskExecution( + requestedMode: TaskIsolationMode, + isIsolated: boolean, + repoRoot: string | null, + platform: NodeJS.Platform = process.platform, +): Promise { + let effectiveIsolationMode = requestedMode; + let warning = ""; + if (!(isIsolated && repoRoot)) { + return { effectiveIsolationMode, warning }; + } + + if (requestedMode === "fuse-overlay" && platform === "win32") { + effectiveIsolationMode = "worktree"; + warning = + 'fuse-overlay isolation is unavailable on Windows. Use task.isolation.mode = "fuse-projfs" for ProjFS. Falling back to worktree isolation.'; + return { effectiveIsolationMode, warning }; + } + + if (requestedMode === "fuse-projfs" && platform !== "win32") { + effectiveIsolationMode = "worktree"; + warning = + "fuse-projfs isolation is only available on Windows. Falling back to worktree isolation."; + return { effectiveIsolationMode, warning }; + } + + if (!(requestedMode === "fuse-projfs" && platform === "win32")) { + return { effectiveIsolationMode, warning }; + } + + const probe = projfsOverlayProbe(); + if (!probe.available) { + effectiveIsolationMode = "worktree"; + const reason = probe.reason ? ` Reason: ${probe.reason}` : ""; + warning = `ProjFS is unavailable on this host. Falling back to worktree isolation.${reason}`; + return { effectiveIsolationMode, warning }; + } + + const probeIsolationId = `probe-${Snowflake.next()}`; + let probeIsolationDir: string | null = null; + try { + probeIsolationDir = await ensureProjfsOverlay(repoRoot, probeIsolationId); + } catch (err) { + if (isProjfsUnavailableError(err)) { + effectiveIsolationMode = "worktree"; + const raw = err instanceof Error ? err.message : String(err); + const reason = raw.replace(/^PROJFS_UNAVAILABLE:\s*/, ""); + const detail = reason ? ` Reason: ${reason}` : ""; + warning = `ProjFS prerequisites are unavailable for this repository. Falling back to worktree isolation.${detail}`; + } else { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`ProjFS isolation initialization failed. ${message}`); + } + } finally { + if (probeIsolationDir) { + await cleanupProjfsOverlay(probeIsolationDir); + } + } + + return { effectiveIsolationMode, warning }; +} diff --git a/packages/coding-agent/src/task/worktree.ts b/packages/coding-agent/src/task/worktree.ts index 05ea66402..2e09ffc67 100644 --- a/packages/coding-agent/src/task/worktree.ts +++ b/packages/coding-agent/src/task/worktree.ts @@ -2,6 +2,7 @@ import type { Dirent } from "node:fs"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import path from "node:path"; +import { projfsOverlayStart, projfsOverlayStop } from "@oh-my-pi/pi-natives"; import { getWorktreeDir, isEnoent, logger, Snowflake } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; @@ -37,6 +38,17 @@ export async function getRepoRoot(cwd: string): Promise { return repoRoot; } +const PROJFS_UNAVAILABLE_PREFIX = "PROJFS_UNAVAILABLE:"; +const GIT_NO_INDEX_NULL_PATH = process.platform === "win32" ? "NUL" : "/dev/null"; + +export function isProjfsUnavailableError(err: unknown): boolean { + return err instanceof Error && err.message.includes(PROJFS_UNAVAILABLE_PREFIX); +} + +export function getGitNoIndexNullPath(): string { + return GIT_NO_INDEX_NULL_PATH; +} + export async function ensureWorktree(baseCwd: string, id: string): Promise { const repoRoot = await getRepoRoot(baseCwd); const encodedProject = getEncodedProjectName(repoRoot); @@ -248,9 +260,10 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise const baselineUntracked = new Set(rb.untracked); const newUntracked = currentUntracked.filter(entry => !baselineUntracked.has(entry)); if (newUntracked.length > 0) { + const nullPath = getGitNoIndexNullPath(); const untrackedDiffs = await Promise.all( newUntracked.map(entry => - $`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(), + $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(), ), ); parts.push(...untrackedDiffs.filter(d => d.trim())); @@ -273,9 +286,10 @@ async function captureRepoDeltaPatch(repoDir: string, rb: RepoBaseline): Promise if (newUntracked.length === 0) return diff; + const nullPath = getGitNoIndexNullPath(); const untrackedDiffs = await Promise.all( newUntracked.map(entry => - $`git diff --binary --no-index /dev/null ${entry}`.cwd(repoDir).quiet().nothrow().text(), + $`git diff --binary --no-index ${nullPath} ${entry}`.cwd(repoDir).quiet().nothrow().text(), ), ); return `${diff}${diff && !diff.endsWith("\n") ? "\n" : ""}${untrackedDiffs.join("\n")}`; @@ -371,10 +385,14 @@ export async function cleanupWorktree(dir: string): Promise { } // ═══════════════════════════════════════════════════════════════════════════ -// Fuse-overlay isolation +// Fuse-overlay isolation (Unix) // ═══════════════════════════════════════════════════════════════════════════ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise { + if (process.platform === "win32") { + throw new Error('fuse-overlay isolation is unsupported on Windows. Use task.isolation.mode = "fuse-projfs".'); + } + const repoRoot = await getRepoRoot(baseCwd); const encodedProject = getEncodedProjectName(repoRoot); const baseDir = getWorktreeDir(encodedProject, id); @@ -382,13 +400,13 @@ export async function ensureFuseOverlay(baseCwd: string, id: string): Promise { } } +// ═══════════════════════════════════════════════════════════════════════════ +// ProjFS isolation (Windows) +// ═══════════════════════════════════════════════════════════════════════════ + +export async function ensureProjfsOverlay(baseCwd: string, id: string): Promise { + if (process.platform !== "win32") { + throw new Error("fuse-projfs isolation is only available on Windows."); + } + + const repoRoot = await getRepoRoot(baseCwd); + const encodedProject = getEncodedProjectName(repoRoot); + const baseDir = getWorktreeDir(encodedProject, id); + const mergedDir = path.join(baseDir, "merged"); + + await fs.rm(baseDir, { recursive: true, force: true }); + await fs.mkdir(mergedDir, { recursive: true }); + try { + projfsOverlayStart(repoRoot, mergedDir); + return mergedDir; + } catch (err) { + await fs.rm(baseDir, { recursive: true, force: true }); + throw err; + } +} + +export async function cleanupProjfsOverlay(mergedDir: string): Promise { + try { + if (process.platform === "win32") { + try { + projfsOverlayStop(mergedDir); + } catch (err) { + logger.warn("ProjFS overlay stop failed during cleanup", { + mergedDir, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } finally { + // baseDir is the parent of the merged directory + const baseDir = path.dirname(mergedDir); + await fs.rm(baseDir, { recursive: true, force: true }); + } +} + // ═══════════════════════════════════════════════════════════════════════════ // Branch-mode isolation // ═══════════════════════════════════════════════════════════════════════════ diff --git a/packages/coding-agent/test/task/isolation-mode.test.ts b/packages/coding-agent/test/task/isolation-mode.test.ts new file mode 100644 index 000000000..d572494e9 --- /dev/null +++ b/packages/coding-agent/test/task/isolation-mode.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; + +const projfsOverlayProbeMock = vi.fn(); +const ensureProjfsOverlayMock = vi.fn(); +const cleanupProjfsOverlayMock = vi.fn(); +const isProjfsUnavailableErrorMock = vi.fn( + (err: unknown) => err instanceof Error && err.message.includes("PROJFS_UNAVAILABLE:"), +); + +vi.mock("@oh-my-pi/pi-natives", () => ({ + projfsOverlayProbe: projfsOverlayProbeMock, +})); + +vi.mock("../../src/task/worktree", () => ({ + ensureProjfsOverlay: ensureProjfsOverlayMock, + cleanupProjfsOverlay: cleanupProjfsOverlayMock, + isProjfsUnavailableError: isProjfsUnavailableErrorMock, + getGitNoIndexNullPath: () => (process.platform === "win32" ? "NUL" : "/dev/null"), +})); + +async function loadIsolatedBackend() { + const { resolveIsolationBackendForTaskExecution } = await import("../../src/task/isolation-backend"); + return { + resolveIsolationBackendForTaskExecution, + projfsOverlayProbeMock, + ensureProjfsOverlayMock, + cleanupProjfsOverlayMock, + isProjfsUnavailableErrorMock, + }; +} + +describe("resolveIsolationBackendForTaskExecution", () => { + afterEach(() => { + vi.clearAllMocks(); + isProjfsUnavailableErrorMock.mockImplementation( + (err: unknown) => err instanceof Error && err.message.includes("PROJFS_UNAVAILABLE:"), + ); + }); + + it("falls back to worktree when fuse-overlay is requested on Windows", async () => { + const backend = await loadIsolatedBackend(); + + const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-overlay", true, "C:/repo", "win32"); + + expect(resolved.effectiveIsolationMode).toBe("worktree"); + expect(resolved.warning).toContain("fuse-projfs"); + expect(backend.projfsOverlayProbeMock).not.toHaveBeenCalled(); + }); + + it("falls back to worktree when fuse-projfs is requested on non-Windows", async () => { + const backend = await loadIsolatedBackend(); + + const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "/repo", "linux"); + + expect(resolved.effectiveIsolationMode).toBe("worktree"); + expect(resolved.warning).toContain("only available on Windows"); + expect(backend.projfsOverlayProbeMock).not.toHaveBeenCalled(); + }); + + it("falls back to worktree when ProjFS probe is unavailable on Windows", async () => { + const backend = await loadIsolatedBackend(); + backend.projfsOverlayProbeMock.mockReturnValue({ + available: false, + reason: "Client-ProjFS optional feature disabled", + }); + + const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32"); + + expect(resolved.effectiveIsolationMode).toBe("worktree"); + expect(resolved.warning).toContain("Falling back to worktree isolation"); + expect(backend.ensureProjfsOverlayMock).not.toHaveBeenCalled(); + }); + + it("falls back to worktree when ProjFS preflight returns prerequisite error", async () => { + const backend = await loadIsolatedBackend(); + backend.projfsOverlayProbeMock.mockReturnValue({ available: true }); + backend.ensureProjfsOverlayMock.mockRejectedValue( + new Error("PROJFS_UNAVAILABLE: filesystem does not support ProjFS"), + ); + backend.isProjfsUnavailableErrorMock.mockReturnValue(true); + + const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32"); + + expect(resolved.effectiveIsolationMode).toBe("worktree"); + expect(resolved.warning).toContain("filesystem does not support ProjFS"); + expect(backend.cleanupProjfsOverlayMock).not.toHaveBeenCalled(); + }); + + it("keeps fuse-projfs backend when ProjFS preflight succeeds", async () => { + const backend = await loadIsolatedBackend(); + backend.projfsOverlayProbeMock.mockReturnValue({ available: true }); + backend.ensureProjfsOverlayMock.mockResolvedValue("C:/repo/.tmp/merged"); + backend.isProjfsUnavailableErrorMock.mockReturnValue(false); + + const resolved = await backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32"); + + expect(resolved.effectiveIsolationMode).toBe("fuse-projfs"); + expect(resolved.warning).toBe(""); + expect(backend.cleanupProjfsOverlayMock).toHaveBeenCalledWith("C:/repo/.tmp/merged"); + }); + + it("throws when ProjFS preflight fails for non-prerequisite reasons", async () => { + const backend = await loadIsolatedBackend(); + backend.projfsOverlayProbeMock.mockReturnValue({ available: true }); + backend.ensureProjfsOverlayMock.mockRejectedValue(new Error("unexpected mount failure")); + backend.isProjfsUnavailableErrorMock.mockReturnValue(false); + + await expect( + backend.resolveIsolationBackendForTaskExecution("fuse-projfs", true, "C:/repo", "win32"), + ).rejects.toThrow("ProjFS isolation initialization failed. unexpected mount failure"); + }); +}); diff --git a/packages/coding-agent/test/task/worktree.test.ts b/packages/coding-agent/test/task/worktree.test.ts new file mode 100644 index 000000000..b419a0d4e --- /dev/null +++ b/packages/coding-agent/test/task/worktree.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "bun:test"; + +const projfsOverlayStartMock = vi.fn(); +const projfsOverlayStopMock = vi.fn(); + +vi.mock("@oh-my-pi/pi-natives", () => ({ + projfsOverlayStart: projfsOverlayStartMock, + projfsOverlayStop: projfsOverlayStopMock, +})); + +async function loadWorktreeHelpers() { + const { getGitNoIndexNullPath, isProjfsUnavailableError } = await import("../../src/task/worktree"); + return { getGitNoIndexNullPath, isProjfsUnavailableError }; +} + +describe("worktree isolation helpers", () => { + it("returns platform-specific null path for git --no-index diffs", async () => { + const { getGitNoIndexNullPath } = await loadWorktreeHelpers(); + const expected = process.platform === "win32" ? "NUL" : "/dev/null"; + expect(getGitNoIndexNullPath()).toBe(expected); + }); + + it("detects ProjFS prerequisite errors by prefix", async () => { + const { isProjfsUnavailableError } = await loadWorktreeHelpers(); + expect(isProjfsUnavailableError(new Error("PROJFS_UNAVAILABLE: missing feature"))).toBe(true); + expect(isProjfsUnavailableError(new Error("fuse-overlay mount failed"))).toBe(false); + expect(isProjfsUnavailableError("PROJFS_UNAVAILABLE: not-an-error-instance")).toBe(false); + }); +}); diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index c1d2f720e..e86cbf381 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -7,6 +7,7 @@ export * from "./highlight"; export * from "./html"; export * from "./image"; export * from "./keys"; +export * from "./projfs"; export * from "./ps"; export * from "./pty"; export * from "./shell"; diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index 0f3b7b961..5f488f1ba 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -21,6 +21,7 @@ import "./html/types"; import "./image/types"; import "./keys/types"; import "./ps/types"; +import "./projfs/types"; import "./pty/types"; import "./shell/types"; import "./text/types"; @@ -274,6 +275,9 @@ function validateNative(bindings: NativeBindings, source: string): void { checkFn("astEdit"); checkFn("detectMacOSAppearance"); checkFn("MacAppearanceObserver"); + checkFn("projfsOverlayProbe"); + checkFn("projfsOverlayStart"); + checkFn("projfsOverlayStop"); if (missing.length) { throw new Error( `Native addon missing exports (${source}). Missing: ${missing.join(", ")}. ` + diff --git a/packages/natives/src/projfs/index.ts b/packages/natives/src/projfs/index.ts new file mode 100644 index 000000000..e271a778f --- /dev/null +++ b/packages/natives/src/projfs/index.ts @@ -0,0 +1,8 @@ +/** + * Windows ProjFS-backed overlay lifecycle bindings. + */ + +import { native } from "../native"; + +export type { ProjfsOverlayProbeResult } from "./types"; +export const { projfsOverlayProbe, projfsOverlayStart, projfsOverlayStop } = native; diff --git a/packages/natives/src/projfs/types.ts b/packages/natives/src/projfs/types.ts new file mode 100644 index 000000000..8db17e1e9 --- /dev/null +++ b/packages/natives/src/projfs/types.ts @@ -0,0 +1,28 @@ +/** + * Types for Windows ProjFS-backed overlay lifecycle. + */ + +/** Result for probing whether ProjFS can be used on this machine. */ +export interface ProjfsOverlayProbeResult { + available: boolean; + reason?: string; +} + +declare module "../bindings" { + interface NativeBindings { + /** + * Probe whether ProjFS APIs are available and loadable on the current machine. + */ + projfsOverlayProbe(): ProjfsOverlayProbeResult; + + /** + * Start a ProjFS-backed projection at `projectionRoot` serving files from `lowerRoot`. + */ + projfsOverlayStart(lowerRoot: string, projectionRoot: string): void; + + /** + * Stop a ProjFS-backed projection previously started at `projectionRoot`. + */ + projfsOverlayStop(projectionRoot: string): void; + } +}