Implement fuse-projfs as an alternative to fuse-overlay for Windows. (#244)

* Implement fuse-projfs to use ProjFS on WIndows.

* fix(pi-natives): prevent ProjFS session key mismatch and start race

---------

Co-authored-by: can1357 <me@can.ac>
This commit is contained in:
haram
2026-03-03 04:12:30 +01:00
committed by GitHub
parent 7ce8b6d5df
commit 801553b118
23 changed files with 1276 additions and 51 deletions
Generated
+1
View File
@@ -2232,6 +2232,7 @@ dependencies = [
"tokio-util",
"unicode-segmentation",
"unicode-width",
"windows-sys 0.61.2",
"winreg 0.55.0",
]
+2 -2
View File
@@ -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://<id>` 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
```
+4 -4
View File
@@ -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=="],
+6
View File
@@ -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"
+1
View File
@@ -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;
+853
View File
@@ -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<String>,
}
#[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<Self, String> {
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::<u8>() as PCSTR)
};
let Some(proc) = proc else {
unsafe {
FreeLibrary(module);
}
return Err(format!("ProjectedFSLib.dll missing symbol {}", $name));
};
unsafe { mem::transmute::<unsafe extern "system" fn() -> 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<u16>,
basic_info: PRJ_FILE_BASIC_INFO,
}
#[derive(Default)]
struct DirectoryEnumeration {
entries: Vec<DirectoryEntry>,
cursor: usize,
search_expression: Option<Vec<u16>>,
}
struct ProviderContext {
lower_root: PathBuf,
api: Arc<ProjfsApi>,
enumerations: Mutex<BTreeMap<u128, DirectoryEnumeration>>,
}
struct ProjfsSession {
virtualization_context: PRJ_NAMESPACE_VIRTUALIZATION_CONTEXT,
provider_context: *mut ProviderContext,
callbacks: Box<PRJ_CALLBACKS>,
api_handle: Arc<ProjfsApi>,
}
// 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<Mutex<BTreeMap<String, ProjfsSessionState>>> =
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::<c_void>(),
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::<PRJ_PLACEHOLDER_INFO>() 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::<c_void>(),
byte_offset + written as u64,
to_read as u32,
)
};
if is_failed(hr) {
return hr;
}
written += to_read;
}
0
}
struct AlignedBuffer {
api: Arc<ProjfsApi>,
ptr: *mut c_void,
len: usize,
}
impl AlignedBuffer {
const fn new(api: Arc<ProjfsApi>, 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::<u8>(), 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::<ProviderContext>()) };
Ok((callback_data, context))
}
fn list_directory_entries(
context: &ProviderContext,
relative_path: &Path,
) -> io::Result<Vec<DirectoryEntry>> {
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<u16> {
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<PathBuf> {
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<PathBuf> {
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<u16> {
let mut encoded: Vec<u16> = 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<u32> {
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
}
}
+20 -14
View File
@@ -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<u32, SmallVec<[u32; 4]>> {
let mut tree: HashMap<u32, SmallVec<[u32; 4]>> = 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::<PROCESSENTRY32W>() 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<i32> {
pub const fn process_group_id(_pid: i32) -> Option<i32> {
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<i32> {
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)
}
+5 -6
View File
@@ -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,11 +451,11 @@ async fn create_session(config: &ShellConfig) -> Result<ShellSessionCore> {
}
#[cfg(windows)]
if merged_path.is_none() {
if let Some(value) = std::env::var_os("Path").or_else(|| std::env::var_os("PATH")) {
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 {
let mut var = ShellVariable::new(ShellValue::String(path_value));
+4 -5
View File
@@ -112,14 +112,13 @@ fn query_git_install_path_from_registry() -> Option<String> {
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::<String, _>("InstallPath") {
if !path.is_empty() {
if let Ok(key) = hklm.open_subkey(key_path)
&& let Ok(path) = key.get_value::<String, _>("InstallPath")
&& !path.is_empty()
{
return Some(path);
}
}
}
}
None
}
+2 -2
View File
@@ -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"
+4 -2
View File
@@ -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 `<system-notification>` 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
+3 -2
View File
@@ -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/<id>`) 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.
@@ -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,
},
},
@@ -97,7 +97,16 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
"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": [
+35 -4
View File
@@ -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<TaskSchema, TaskToolDetails, Theme> {
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<TaskSchema, TaskToolDetails, Theme> {
}
}
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<TaskSchema, TaskToolDetails, Theme> {
}
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<TaskSchema, TaskToolDetails, Theme> {
};
} 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<TaskSchema, TaskToolDetails, Theme> {
});
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<TaskSchema, TaskToolDetails, Theme> {
summaries,
outputIds,
agentName,
mergeSummary,
mergeSummary: `${backendSummaryPrefix}${mergeSummary}`,
});
// Cleanup temp directory if used
@@ -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<IsolationBackendResolution> {
let effectiveIsolationMode = requestedMode;
let warning = "";
if (!(isIsolated && repoRoot)) {
return { effectiveIsolationMode, warning };
}
if (requestedMode === "fuse-overlay" && platform === "win32") {
effectiveIsolationMode = "worktree";
warning =
'<system-notification>fuse-overlay isolation is unavailable on Windows. Use task.isolation.mode = "fuse-projfs" for ProjFS. Falling back to worktree isolation.</system-notification>';
return { effectiveIsolationMode, warning };
}
if (requestedMode === "fuse-projfs" && platform !== "win32") {
effectiveIsolationMode = "worktree";
warning =
"<system-notification>fuse-projfs isolation is only available on Windows. Falling back to worktree isolation.</system-notification>";
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 = `<system-notification>ProjFS is unavailable on this host. Falling back to worktree isolation.${reason}</system-notification>`;
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 = `<system-notification>ProjFS prerequisites are unavailable for this repository. Falling back to worktree isolation.${detail}</system-notification>`;
} 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 };
}
+67 -5
View File
@@ -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<string> {
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<string> {
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<void> {
}
// ═══════════════════════════════════════════════════════════════════════════
// Fuse-overlay isolation
// Fuse-overlay isolation (Unix)
// ═══════════════════════════════════════════════════════════════════════════
export async function ensureFuseOverlay(baseCwd: string, id: string): Promise<string> {
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<st
const workDir = path.join(baseDir, "work");
const mergedDir = path.join(baseDir, "merged");
// Clean up any stale mount at this path
// Clean up any stale mount at this path (linux only)
const fusermount = Bun.which("fusermount3") ?? Bun.which("fusermount");
if (fusermount) {
await $`${fusermount} -u ${mergedDir}`.quiet().nothrow();
}
await fs.rm(baseDir, { recursive: true, force: true });
await fs.rm(baseDir, { recursive: true, force: true });
await fs.mkdir(upperDir, { recursive: true });
await fs.mkdir(workDir, { recursive: true });
await fs.mkdir(mergedDir, { recursive: true });
@@ -426,6 +444,50 @@ export async function cleanupFuseOverlay(mergedDir: string): Promise<void> {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// ProjFS isolation (Windows)
// ═══════════════════════════════════════════════════════════════════════════
export async function ensureProjfsOverlay(baseCwd: string, id: string): Promise<string> {
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<void> {
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
// ═══════════════════════════════════════════════════════════════════════════
@@ -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");
});
});
@@ -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);
});
});
+1
View File
@@ -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";
+4
View File
@@ -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(", ")}. ` +
+8
View File
@@ -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;
+28
View File
@@ -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;
}
}