diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 489ba22d28..ebae8f09b6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -568,6 +568,56 @@ jobs:
             -p git-credential-nostr \
             -p git-sign-nostr
 
+  windows-rust:
+    name: Windows Rust (x86_64-pc-windows-msvc)
+    runs-on: windows-latest
+    # Windows runners are slow and this compiles the workspace + Tauri crate
+    # cold across four steps; budget generously.
+    timeout-minutes: 45
+    needs: [changes]
+    if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' || needs.changes.outputs.desktop-rust == 'true'
+    permissions:
+      contents: read
+    env:
+      TARGET: x86_64-pc-windows-msvc
+    steps:
+      - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+      # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows
+      # runner — hermit, used by the Linux jobs, does not provide MSVC. The
+      # toolchain (1.95.0 + clippy via profile = default) comes from the
+      # repo-root rust-toolchain.toml, which the runner's preinstalled rustup
+      # honors on demand; the host triple already is x86_64-pc-windows-msvc.
+      - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
+        with:
+          workspaces: |
+            .
+            desktop/src-tauri
+          key: windows-msvc
+          save-if: ${{ github.event_name != 'pull_request' }}
+      # Tauri validates externalBin at compile time, so the Tauri-crate steps
+      # below fail without these stubs. Mirrors scripts/bundle-sidecars.sh's
+      # Windows naming (binaries/<bin>-<triple>.exe); empty files suffice for a
+      # type-check since nothing executes them.
+      - name: Create sidecar placeholders
+        shell: bash
+        run: |
+          mkdir -p desktop/src-tauri/binaries
+          for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do
+            touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe"
+          done
+      - name: Clippy (workspace)
+        run: cargo clippy --workspace --all-targets --target $env:TARGET -- -D warnings
+      - name: Check (workspace)
+        run: cargo check --workspace --all-targets --target $env:TARGET
+      - name: Check (Tauri crate)
+        run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
+        env:
+          CMAKE_POLICY_VERSION_MINIMUM: "3.5"
+      - name: Test (Tauri crate)
+        run: cargo test --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET
+        env:
+          CMAKE_POLICY_VERSION_MINIMUM: "3.5"
+
   desktop-build-macos:
     name: Desktop Build (macOS)
     runs-on: macos-latest
diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs
index 2c39de2703..6a7b61dcac 100644
--- a/crates/buzz-agent/src/mcp.rs
+++ b/crates/buzz-agent/src/mcp.rs
@@ -60,6 +60,13 @@ const PASSTHROUGH_ENV: &[&str] = &[
     "BUZZ_RELAY_URL",
 ];
 
+// Windows has no $TMPDIR/$HOME. TMP/TEMP/USERPROFILE are what
+// std::env::temp_dir() consults — without them it falls back to C:\Windows,
+// which child processes can't write to (PermissionDenied). USERPROFILE is the
+// always-set floor. LOCALAPPDATA/APPDATA carry child-tool config (git, etc.).
+#[cfg(windows)]
+const PASSTHROUGH_ENV_WINDOWS: &[&str] = &["TMP", "TEMP", "USERPROFILE", "LOCALAPPDATA", "APPDATA"];
+
 type Client = RunningService<RoleClient, ()>;
 
 #[derive(Clone)]
@@ -697,6 +704,12 @@ async fn spawn_one(
             cmd.env(k, v);
         }
     }
+    #[cfg(windows)]
+    for k in PASSTHROUGH_ENV_WINDOWS {
+        if let Ok(v) = std::env::var(k) {
+            cmd.env(k, v);
+        }
+    }
     for (k, v) in &spec.env {
         cmd.env(k, v);
     }
@@ -966,6 +979,19 @@ mod content_tests {
     use super::*;
     use rmcp::model::Content;
 
+    #[cfg(windows)]
+    #[test]
+    fn windows_passthrough_includes_temp_dir_vars() {
+        // std::env::temp_dir() consults these in order; without them it falls
+        // back to C:\Windows, which children can't write to.
+        for var in ["TMP", "TEMP", "USERPROFILE"] {
+            assert!(
+                PASSTHROUGH_ENV_WINDOWS.contains(&var),
+                "{var} must pass through or temp_dir() falls back to C:\\Windows"
+            );
+        }
+    }
+
     #[test]
     fn tool_result_content_preserves_images() {
         let blocks = vec![
diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs
index edc9b9fb7a..ab4f6ad4c9 100644
--- a/crates/buzz-dev-mcp/src/lib.rs
+++ b/crates/buzz-dev-mcp/src/lib.rs
@@ -137,7 +137,7 @@ impl ServerHandler for DevMcp {
 pub fn run() -> Result<(), Box<dyn std::error::Error>> {
     let argv0 = std::env::args().next().unwrap_or_default();
     let cmd = Path::new(&argv0)
-        .file_name()
+        .file_stem()
         .and_then(|n| n.to_str())
         .unwrap_or("")
         .to_ascii_lowercase();
diff --git a/crates/buzz-dev-mcp/src/shim.rs b/crates/buzz-dev-mcp/src/shim.rs
index 0ecef50616..36a07a18ca 100644
--- a/crates/buzz-dev-mcp/src/shim.rs
+++ b/crates/buzz-dev-mcp/src/shim.rs
@@ -40,12 +40,13 @@ impl Shim {
         }
 
         let original = std::env::var_os("PATH").unwrap_or_default();
-        let mut new_path = std::ffi::OsString::from(dir.path());
-        if !original.is_empty() {
-            new_path.push(":");
-            new_path.push(&original);
-        }
-        let path_env = new_path.to_string_lossy().into_owned();
+        let mut entries = vec![PathBuf::from(dir.path())];
+        entries.extend(std::env::split_paths(&original));
+        // join_paths uses the platform separator (':' on Unix, ';' on Windows).
+        let path_env = std::env::join_paths(entries)
+            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?
+            .to_string_lossy()
+            .into_owned();
 
         // Read and unconditionally remove NOSTR_PRIVATE_KEY from this process's
         // env. The key must never leak to child processes regardless of whether
@@ -234,6 +235,9 @@ fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
 
 #[cfg(not(unix))]
 fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
+    // No symlinks without elevation on Windows; copy instead. The target needs
+    // a .exe extension or PATH lookup (via PATHEXT) won't treat it as runnable.
+    let dst = dst.with_extension("exe");
     std::fs::copy(src, dst).map(|_| ())
 }
 
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 548094c942..0c8b22644a 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -33,7 +33,7 @@ ctrlc = { version = "3", features = ["termination"] }
 objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback"] }
 
 [target.'cfg(windows)'.dependencies]
-windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_Foundation"] }
 
 [dependencies]
 atomic-write-file = "0.3"
diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs
index 37bad60f8c..dc64dc8266 100644
--- a/desktop/src-tauri/src/commands/agents.rs
+++ b/desktop/src-tauri/src/commands/agents.rs
@@ -12,8 +12,7 @@ use crate::{
         sync_managed_agent_processes, try_regenerate_nest, validate_provider_config, BackendKind,
         BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse,
         ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, RelayMeshConfig,
-        DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
-        DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
+        DEFAULT_ACP_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS,
     },
     relay::{relay_ws_url_with_override, sync_managed_agent_profile},
     util::now_iso,
@@ -456,8 +455,8 @@ pub async fn create_managed_agent(
             .as_deref()
             .map(str::trim)
             .filter(|value| !value.is_empty())
-            .unwrap_or(DEFAULT_AGENT_COMMAND)
-            .to_string();
+            .map(str::to_string)
+            .unwrap_or_else(crate::managed_agents::default_agent_command);
         let agent_args = normalize_agent_args(
             &agent_command,
             input
diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs
index 88f5c2d6aa..21ca25d585 100644
--- a/desktop/src-tauri/src/managed_agents/discovery.rs
+++ b/desktop/src-tauri/src/managed_agents/discovery.rs
@@ -228,6 +228,21 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti
     KNOWN_ACP_RUNTIMES.iter().find(|p| p.id == id)
 }
 
+/// The agent command a freshly-created agent defaults to when the create
+/// request supplies none. Resolves the bundled `buzz-agent` from the catalog —
+/// the same shape `mesh_llm::preset` uses — so the default can't drift from the
+/// provider definition. Falls back to the id if the catalog entry is missing.
+///
+/// The previous default was the bare global `goose`, which is not on PATH on a
+/// stock Windows install: every worker failed with `program not found`. The
+/// bundled `buzz-agent` ships with the app and resolves on every platform.
+pub fn default_agent_command() -> String {
+    known_acp_runtime_exact("buzz-agent")
+        .and_then(|p| p.commands.first().copied())
+        .unwrap_or("buzz-agent")
+        .to_string()
+}
+
 fn default_agent_args(command: &str) -> Option<Vec<String>> {
     match normalize_command_identity(command).as_str() {
         "goose" => Some(vec!["acp".to_string()]),
@@ -562,8 +577,9 @@ mod tests {
     use std::path::PathBuf;
 
     use super::{
-        classify_runtime, find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
-        BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
+        classify_runtime, default_agent_command, find_via_login_shell, managed_agent_avatar_url,
+        normalize_agent_args, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL,
+        GOOSE_AVATAR_URL,
     };
     use crate::managed_agents::AcpAvailabilityStatus;
 
@@ -599,6 +615,18 @@ mod tests {
         assert!(managed_agent_avatar_url("custom-agent").is_none());
     }
 
+    #[test]
+    fn default_agent_command_resolves_bundled_buzz_agent() {
+        // The create-path default must be the bundled buzz-agent, never the
+        // bare `goose` that isn't on PATH on a stock Windows install.
+        assert_eq!(default_agent_command(), "buzz-agent");
+        // And buzz-agent takes no `acp` arg — confirm no arg leakage from the default.
+        assert_eq!(
+            normalize_agent_args(&default_agent_command(), vec!["acp".into()]),
+            Vec::<String>::new()
+        );
+    }
+
     #[test]
     fn normalizes_claude_and_codex_args_to_empty() {
         assert_eq!(
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index f10376a275..d22598ed8d 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -5,6 +5,8 @@ mod nest;
 mod persona_avatars;
 mod persona_card;
 mod personas;
+#[cfg(windows)]
+mod process_lifecycle;
 #[cfg(feature = "mesh-llm")]
 mod relay_mesh;
 mod restore;
@@ -20,6 +22,8 @@ pub use env_vars::*;
 pub use nest::*;
 pub use persona_card::*;
 pub use personas::*;
+#[cfg(windows)]
+pub use process_lifecycle::*;
 #[cfg(feature = "mesh-llm")]
 pub use relay_mesh::*;
 pub use restore::*;
diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs
new file mode 100644
index 0000000000..b3972d74e6
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs
@@ -0,0 +1,150 @@
+//! Windows process-tree lifecycle primitives for managed agents.
+//!
+//! The Unix teardown uses `process_group(0)` + group signals (in `runtime.rs`).
+//! Windows has no process groups, so the harness's 24 agent workers + MCP
+//! servers are reaped two ways here:
+//!   - [`JobHandle`] / [`create_job_for_child`] — the in-process stop path. A
+//!     Job Object owns the tree and `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` kills
+//!     it when the handle drops.
+//!   - [`taskkill_tree`] — the after-restart path, where only the PID survives
+//!     in the record and no job handle is available.
+//!
+//! This module is `#[cfg(windows)]`-only; nothing here compiles on other
+//! platforms.
+
+use windows_sys::Win32::Foundation::HANDLE;
+
+/// Win32 Job Object that owns the harness process and (via Windows' default
+/// child-inheritance) every process it spawns. Dropping the handle with
+/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set kills the whole tree — the Windows
+/// mirror of the Unix `process_group(0)` + group-signal teardown. This is what
+/// guarantees the 24 agent workers + MCP servers die when we stop or when the
+/// app exits, instead of being orphaned by a bare `Child::kill()`.
+pub struct JobHandle(HANDLE);
+
+// The handle is owned exclusively by this wrapper; moving it across threads is
+// sound (the spawn path in restore.rs runs in a thread scope).
+unsafe impl Send for JobHandle {}
+
+impl std::fmt::Debug for JobHandle {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("JobHandle(..)")
+    }
+}
+
+impl Drop for JobHandle {
+    fn drop(&mut self) {
+        // KILL_ON_JOB_CLOSE means the tree dies when the LAST handle closes.
+        // We hold the only handle (not inheritable), so this reaps the tree.
+        unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) };
+    }
+}
+
+/// Create a Job Object, assign `pid` to it, and configure it to kill the whole
+/// tree when the returned handle is dropped. Returns `None` on any failure so
+/// the caller can fall back to `Child::kill()` — a degraded teardown beats a
+/// failed spawn.
+///
+/// Assignment happens immediately after spawn, on the same parent thread. The
+/// child (buzz-acp) does spawn its 24 workers before it connects to the relay,
+/// so the window between our spawn and our assignment is NOT structurally empty.
+/// What closes it is assign-latency: `OpenProcess` + `AssignProcessToJobObject`
+/// are a few synchronous Win32 calls (microseconds), while buzz-acp must init
+/// tokio, parse its config, and spawn 24 children (tens-to-hundreds of ms), so
+/// the assign reliably wins before any worker exists. Once assigned, Windows
+/// places every subsequently-spawned descendant in the job automatically.
+///
+/// `CREATE_SUSPENDED` -> assign -> `ResumeThread` would make the window airtight
+/// regardless of child timing, but it requires raw `CreateProcessW`/`ResumeThread`
+/// (materially more unsafe Win32) to close a microsecond race, so it is
+/// deliberately not used here.
+fn create_job_for_child(pid: u32) -> Option<JobHandle> {
+    use std::ptr::null;
+    use windows_sys::Win32::Foundation::{CloseHandle, FALSE};
+    use windows_sys::Win32::System::JobObjects::{
+        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
+        SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
+        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
+    };
+    use windows_sys::Win32::System::Threading::{
+        OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
+    };
+
+    unsafe {
+        let job = CreateJobObjectW(null(), null());
+        if job.is_null() {
+            return None;
+        }
+
+        let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
+        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+        let ok = SetInformationJobObject(
+            job,
+            JobObjectExtendedLimitInformation,
+            &info as *const _ as *const _,
+            std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
+        );
+        if ok == FALSE {
+            CloseHandle(job);
+            return None;
+        }
+
+        let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, FALSE, pid);
+        if process.is_null() {
+            CloseHandle(job);
+            return None;
+        }
+        let assigned = AssignProcessToJobObject(job, process);
+        CloseHandle(process);
+        if assigned == FALSE {
+            CloseHandle(job);
+            return None;
+        }
+
+        Some(JobHandle(job))
+    }
+}
+
+/// Kill the entire process tree rooted at `pid` via `taskkill /T`, the closest
+/// equivalent to the Unix process-group kill. Used on the after-restart path
+/// where no job handle survived. `CREATE_NO_WINDOW` keeps taskkill's own
+/// console from flashing.
+pub fn taskkill_tree(pid: u32) -> Result<(), String> {
+    use std::os::windows::process::CommandExt;
+    const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+    let status = std::process::Command::new("taskkill")
+        .args(["/T", "/F", "/PID", &pid.to_string()])
+        .creation_flags(CREATE_NO_WINDOW)
+        .status()
+        .map_err(|error| format!("failed to run taskkill for pid {pid}: {error}"))?;
+    if status.success() {
+        Ok(())
+    } else {
+        Err(format!(
+            "taskkill exited with status {status} for pid {pid}"
+        ))
+    }
+}
+
+/// Assign a freshly-spawned harness `child` to a Job Object and package it into
+/// a [`ManagedAgentProcess`]. On job-assignment failure the process is still
+/// returned with `job: None` — teardown then falls back to `Child::kill()`,
+/// which kills only the harness (a degraded teardown beats a failed spawn).
+pub fn finish_spawn(
+    child: std::process::Child,
+    log_path: std::path::PathBuf,
+    agent_name: &str,
+) -> super::ManagedAgentProcess {
+    let job = create_job_for_child(child.id());
+    if job.is_none() {
+        eprintln!(
+            "buzz-desktop: failed to assign agent {agent_name} to a Job Object; \
+             teardown will fall back to killing only the harness process"
+        );
+    }
+    super::ManagedAgentProcess {
+        child,
+        log_path,
+        job,
+    }
+}
diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs
index 2bad0fd36e..3469dc80d7 100644
--- a/desktop/src-tauri/src/managed_agents/restore.rs
+++ b/desktop/src-tauri/src/managed_agents/restore.rs
@@ -9,7 +9,7 @@ use crate::util;
 use std::sync::atomic::{AtomicBool, Ordering};
 use tauri::Manager;
 
-type SpawnResult = Result<(std::process::Child, std::path::PathBuf), String>;
+type SpawnResult = Result<ManagedAgentProcess, String>;
 type AgentSpawnResult = (String, SpawnResult);
 
 /// Restore managed agents that were running before the app was closed.
@@ -176,15 +176,15 @@ pub async fn restore_managed_agents_on_launch(
             Err(_) => continue,
         };
         match result {
-            Ok((child, log_path)) => {
+            Ok(process) => {
                 let now = util::now_iso();
                 record.updated_at = now.clone();
-                record.runtime_pid = Some(child.id());
+                record.runtime_pid = Some(process.child.id());
                 record.last_started_at = Some(now);
                 record.last_stopped_at = None;
                 record.last_exit_code = None;
                 record.last_error = None;
-                runtimes.insert(pubkey.clone(), ManagedAgentProcess { child, log_path });
+                runtimes.insert(pubkey.clone(), process);
                 successfully_spawned.push(pubkey);
             }
             Err(error) => {
diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs
index d412e790e9..328b888fd5 100644
--- a/desktop/src-tauri/src/managed_agents/runtime.rs
+++ b/desktop/src-tauri/src/managed_agents/runtime.rs
@@ -284,9 +284,17 @@ pub(crate) fn terminate_process(pid: u32) -> Result<(), String> {
     Ok(())
 }
 
-#[cfg(not(unix))]
+#[cfg(windows)]
+pub(crate) fn terminate_process(pid: u32) -> Result<(), String> {
+    // No job handle is available on this path (e.g. after an app restart, when
+    // we only recovered the PID from the record), so fall back to taskkill on
+    // the whole tree.
+    super::process_lifecycle::taskkill_tree(pid)
+}
+
+#[cfg(not(any(unix, windows)))]
 pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> {
-    Err("managed agent shutdown after app restart is only supported on Unix".to_string())
+    Err("managed agent shutdown after app restart is not supported on this platform".to_string())
 }
 
 /// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL
@@ -1473,7 +1481,7 @@ pub fn spawn_agent_child(
     app: &AppHandle,
     record: &ManagedAgentRecord,
     owner_hex: Option<&str>,
-) -> Result<(std::process::Child, std::path::PathBuf), String> {
+) -> Result<crate::managed_agents::ManagedAgentProcess, String> {
     let log_path = managed_agent_log_path(app, &record.pubkey)?;
     append_log_marker(
         &log_path,
@@ -1516,22 +1524,25 @@ pub fn spawn_agent_child(
     //   - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/)
     //   - runtimes (node, python, etc.) via login shell PATH
     let augmented_path = {
-        let mut parts: Vec<String> = Vec::new();
+        let mut parts: Vec<std::path::PathBuf> = Vec::new();
         if let Some(home) = dirs::home_dir() {
-            parts.push(home.join(".local").join("bin").display().to_string());
+            parts.push(home.join(".local").join("bin"));
         }
         if let Ok(exe) = std::env::current_exe() {
             if let Some(parent) = exe.parent() {
-                parts.push(parent.display().to_string());
+                parts.push(parent.to_path_buf());
             }
         }
         if let Some(shell_path) = login_shell_path() {
-            parts.push(shell_path);
+            parts.push(std::path::PathBuf::from(shell_path));
         }
         if parts.is_empty() {
             None
         } else {
-            Some(parts.join(":"))
+            // join_paths uses the platform separator (':' on Unix, ';' on Windows).
+            std::env::join_paths(parts)
+                .ok()
+                .map(|s| s.to_string_lossy().into_owned())
         }
     };
 
@@ -1730,6 +1741,15 @@ pub fn spawn_agent_child(
         use std::os::unix::process::CommandExt;
         command.process_group(0);
     }
+    // Windows: suppress the harness console window. Without this a bare
+    // terminal pops for buzz-acp.exe and lingers (the app itself sets
+    // windows_subsystem="windows", but the spawned child does not inherit it).
+    #[cfg(windows)]
+    {
+        use std::os::windows::process::CommandExt;
+        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+        command.creation_flags(CREATE_NO_WINDOW);
+    }
 
     let child = command.spawn().map_err(|error| {
         format!(
@@ -1741,7 +1761,16 @@ pub fn spawn_agent_child(
 
     let _ = super::write_agent_pid_file(app, &record.pubkey, child.id());
 
-    Ok((child, log_path))
+    // Windows: assign the harness to a Job Object so its whole tree dies with
+    // the handle. The Unix process-group equivalent is set above.
+    #[cfg(windows)]
+    return Ok(super::process_lifecycle::finish_spawn(
+        child,
+        log_path,
+        &record.name,
+    ));
+    #[cfg(not(windows))]
+    Ok(crate::managed_agents::ManagedAgentProcess { child, log_path })
 }
 
 fn child_rust_log_filter() -> String {
@@ -1798,20 +1827,17 @@ pub fn start_managed_agent_process(
         record.runtime_pid = None;
     }
 
-    let (child, log_path) = spawn_agent_child(app, record, owner_hex)?;
+    let process = spawn_agent_child(app, record, owner_hex)?;
 
     let now = now_iso();
     record.updated_at = now.clone();
-    record.runtime_pid = Some(child.id());
+    record.runtime_pid = Some(process.child.id());
     record.last_started_at = Some(now);
     record.last_stopped_at = None;
     record.last_exit_code = None;
     record.last_error = None;
 
-    runtimes.insert(
-        record.pubkey.clone(),
-        ManagedAgentProcess { child, log_path },
-    );
+    runtimes.insert(record.pubkey.clone(), process);
     Ok(())
 }
 
@@ -1838,11 +1864,20 @@ pub fn stop_managed_agent_process(
     };
 
     // On Unix, kill the entire process group via terminate_process.
-    // On non-Unix, fall back to Child::kill() since terminate_process
-    // is not implemented there.
+    // On Windows, drop the Job Object handle (KILL_ON_JOB_CLOSE) so the whole
+    // harness tree dies — Child::kill() would orphan the agent workers + MCP
+    // servers. If job assignment failed at spawn, fall back to Child::kill().
     #[cfg(unix)]
     terminate_process(runtime.child.id())?;
-    #[cfg(not(unix))]
+    #[cfg(windows)]
+    match runtime.job.take() {
+        Some(job) => drop(job),
+        None => runtime
+            .child
+            .kill()
+            .map_err(|error| format!("failed to kill agent process: {error}"))?,
+    }
+    #[cfg(not(any(unix, windows)))]
     runtime
         .child
         .kill()
diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs
index e9d733e545..51761a0f92 100644
--- a/desktop/src-tauri/src/managed_agents/types.rs
+++ b/desktop/src-tauri/src/managed_agents/types.rs
@@ -207,6 +207,12 @@ pub struct RelayMeshConfig {
 pub struct ManagedAgentProcess {
     pub child: Child,
     pub log_path: PathBuf,
+    /// Win32 Job Object owning the harness + its entire process tree. Closing
+    /// the handle (via `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills the whole
+    /// tree — the Windows mirror of the Unix process-group teardown. `None`
+    /// if job creation/assignment failed (we fall back to `Child::kill()`).
+    #[cfg(windows)]
+    pub job: Option<crate::managed_agents::JobHandle>,
 }
 
 #[derive(Debug, Clone, Serialize)]
@@ -545,7 +551,6 @@ pub struct MigrationReport {
 }
 
 pub const DEFAULT_ACP_COMMAND: &str = "buzz-acp";
-pub const DEFAULT_AGENT_COMMAND: &str = "goose";
 /// ~5 min (320s) — matches the CLI harness default (BUZZ_ACP_IDLE_TIMEOUT).
 pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 320;
 /// 1 hour — absolute wall-clock safety cap per turn.
diff --git a/desktop/src-tauri/src/migration.rs b/desktop/src-tauri/src/migration.rs
index 48df3af84d..0387b31a41 100644
--- a/desktop/src-tauri/src/migration.rs
+++ b/desktop/src-tauri/src/migration.rs
@@ -361,7 +361,11 @@ pub fn sync_shared_agent_data(app: &tauri::AppHandle) {
 }
 
 fn reconcile_team_dirs_in_file(path: &Path, target_dir: &Path) {
-    let target_teams = target_dir.join("agents/teams");
+    // Build per-component so the persisted value uses native separators on
+    // every platform, matching fresh writes (agents.rs builds the same path as
+    // base.join("teams").join(id)). A single join("agents/teams") would embed a
+    // literal '/' on Windows, persisting a mixed-separator path into the store.
+    let target_teams = target_dir.join("agents").join("teams");
     patch_json_records(path, |obj| {
         // Handle both old field name and new field name
         let field_name = if obj.contains_key("persona_team_dir") {
diff --git a/desktop/src-tauri/src/migration_team_dir_tests.rs b/desktop/src-tauri/src/migration_team_dir_tests.rs
index afeca0b697..1bad6a9e32 100644
--- a/desktop/src-tauri/src/migration_team_dir_tests.rs
+++ b/desktop/src-tauri/src/migration_team_dir_tests.rs
@@ -11,17 +11,13 @@ fn team_dir_reconcile_rewrites_worktree_path() {
     // Team must exist on disk for the reconcile to proceed past the existence gate.
     std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap();
 
-    let worktree_pack_path = format!(
-        "{}/agents/packs/com.wpfleger.sietch-tabr",
-        parent
+    let worktree_pack_path = pack_dir(
+        &parent
             .path()
-            .join("xyz.block.buzz.app.dev.worktree-my-branch")
-            .display()
-    );
-    let expected_path = format!(
-        "{}/agents/teams/com.wpfleger.sietch-tabr",
-        canonical.display()
+            .join("xyz.block.buzz.app.dev.worktree-my-branch"),
+        "com.wpfleger.sietch-tabr",
     );
+    let expected_path = team_dir(&canonical, "com.wpfleger.sietch-tabr");
 
     write_agents_json(
         &canonical,
@@ -47,17 +43,13 @@ fn team_dir_reconcile_rewrites_new_field_name() {
     // Team must exist on disk for the reconcile to proceed past the existence gate.
     std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap();
 
-    let worktree_team_path = format!(
-        "{}/agents/teams/com.wpfleger.sietch-tabr",
-        parent
+    let worktree_team_path = team_dir(
+        &parent
             .path()
-            .join("xyz.block.buzz.app.dev.worktree-my-branch")
-            .display()
-    );
-    let expected_path = format!(
-        "{}/agents/teams/com.wpfleger.sietch-tabr",
-        canonical.display()
+            .join("xyz.block.buzz.app.dev.worktree-my-branch"),
+        "com.wpfleger.sietch-tabr",
     );
+    let expected_path = team_dir(&canonical, "com.wpfleger.sietch-tabr");
 
     write_agents_json(
         &canonical,
@@ -79,10 +71,7 @@ fn team_dir_reconcile_leaves_canonical_path_unchanged() {
     let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
     std::fs::create_dir_all(canonical.join("agents")).unwrap();
 
-    let canonical_path = format!(
-        "{}/agents/teams/com.wpfleger.sietch-tabr",
-        canonical.display()
-    );
+    let canonical_path = team_dir(&canonical, "com.wpfleger.sietch-tabr");
 
     write_agents_json(
         &canonical,
@@ -128,12 +117,11 @@ fn team_dir_reconcile_is_idempotent() {
     // Team must exist on disk for the reconcile to proceed past the existence gate.
     std::fs::create_dir_all(canonical.join("agents/teams/com.wpfleger.sietch-tabr")).unwrap();
 
-    let worktree_pack_path = format!(
-        "{}/agents/packs/com.wpfleger.sietch-tabr",
-        parent
+    let worktree_pack_path = pack_dir(
+        &parent
             .path()
-            .join("xyz.block.buzz.app.dev.worktree-my-branch")
-            .display()
+            .join("xyz.block.buzz.app.dev.worktree-my-branch"),
+        "com.wpfleger.sietch-tabr",
     );
 
     write_agents_json(
@@ -161,11 +149,11 @@ fn team_dir_reconcile_heals_legacy_release_path() {
     let release_dir = parent.path().join("xyz.block.buzz.app");
     std::fs::create_dir_all(release_dir.join("agents/teams/com.example.team")).unwrap();
 
-    let legacy_path = format!(
-        "{}/agents/packs/com.example.team",
-        parent.path().join("xyz.block.sprout.app").display()
+    let legacy_path = pack_dir(
+        &parent.path().join("xyz.block.sprout.app"),
+        "com.example.team",
     );
-    let expected_path = format!("{}/agents/teams/com.example.team", release_dir.display());
+    let expected_path = team_dir(&release_dir, "com.example.team");
 
     write_agents_json(
         &release_dir,
@@ -193,12 +181,9 @@ fn team_dir_reconcile_leaves_record_when_team_missing() {
     std::fs::create_dir_all(canonical.join("agents")).unwrap();
     // Intentionally do NOT create agents/teams/com.example.missing.
 
-    let stale_path = format!(
-        "{}/agents/packs/com.example.missing",
-        parent
-            .path()
-            .join("xyz.block.buzz.app.dev.worktree-old")
-            .display()
+    let stale_path = pack_dir(
+        &parent.path().join("xyz.block.buzz.app.dev.worktree-old"),
+        "com.example.missing",
     );
 
     write_agents_json(
@@ -222,6 +207,7 @@ fn team_dir_reconcile_leaves_record_when_team_missing() {
     assert!(records[0].get("persona_team_dir").is_none());
 }
 
+#[cfg(unix)]
 #[test]
 fn team_dir_reconcile_heals_to_symlinked_team_dir() {
     // When agents/teams/<id> is a symlink to a real directory elsewhere,
@@ -237,14 +223,11 @@ fn team_dir_reconcile_heals_to_symlinked_team_dir() {
     std::fs::create_dir_all(canonical.join("agents/teams")).unwrap();
     std::os::unix::fs::symlink(&real_team, &symlink_target).unwrap();
 
-    let stale_path = format!(
-        "{}/agents/packs/com.example.team",
-        parent
-            .path()
-            .join("xyz.block.buzz.app.dev.worktree-old")
-            .display()
+    let stale_path = pack_dir(
+        &parent.path().join("xyz.block.buzz.app.dev.worktree-old"),
+        "com.example.team",
     );
-    let expected_path = format!("{}/agents/teams/com.example.team", canonical.display());
+    let expected_path = team_dir(&canonical, "com.example.team");
 
     write_agents_json(
         &canonical,
@@ -260,6 +243,7 @@ fn team_dir_reconcile_heals_to_symlinked_team_dir() {
     assert_eq!(records[0]["persona_team_dir"], expected_path);
 }
 
+#[cfg(unix)]
 #[test]
 fn team_dir_reconcile_skips_dangling_candidate_symlink() {
     // When agents/teams/<id> is a symlink whose target does not exist,
@@ -273,12 +257,9 @@ fn team_dir_reconcile_skips_dangling_candidate_symlink() {
     let symlink_path = canonical.join("agents/teams/com.example.gone");
     std::os::unix::fs::symlink(&dangling_target, &symlink_path).unwrap();
 
-    let stale_path = format!(
-        "{}/agents/packs/com.example.gone",
-        parent
-            .path()
-            .join("xyz.block.buzz.app.dev.worktree-old")
-            .display()
+    let stale_path = pack_dir(
+        &parent.path().join("xyz.block.buzz.app.dev.worktree-old"),
+        "com.example.gone",
     );
 
     write_agents_json(
@@ -299,6 +280,7 @@ fn team_dir_reconcile_skips_dangling_candidate_symlink() {
     assert_eq!(records[0]["persona_pack_path"], stale_path);
 }
 
+#[cfg(unix)]
 #[test]
 fn team_dir_reconcile_through_symlink_preserves_symlink() {
     // Dev worktree instances reach the canonical store through a symlinked
@@ -312,8 +294,8 @@ fn team_dir_reconcile_through_symlink_preserves_symlink() {
     std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap();
     std::fs::create_dir_all(worktree.join("agents")).unwrap();
 
-    let stale_path = format!("{}/agents/packs/com.example.team", worktree.display());
-    let expected_path = format!("{}/agents/teams/com.example.team", canonical.display());
+    let stale_path = pack_dir(&worktree, "com.example.team");
+    let expected_path = team_dir(&canonical, "com.example.team");
 
     write_agents_json(
         &canonical,
@@ -435,7 +417,7 @@ fn team_dir_reconcile_renames_legacy_field_when_value_already_canonical() {
     let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
     std::fs::create_dir_all(canonical.join("agents/teams/com.example.team")).unwrap();
 
-    let correct_path = format!("{}/agents/teams/com.example.team", canonical.display());
+    let correct_path = team_dir(&canonical, "com.example.team");
 
     write_agents_json(
         &canonical,
diff --git a/desktop/src-tauri/src/migration_test_support.rs b/desktop/src-tauri/src/migration_test_support.rs
index 64a428949b..399cb9c8f7 100644
--- a/desktop/src-tauri/src/migration_test_support.rs
+++ b/desktop/src-tauri/src/migration_test_support.rs
@@ -2,6 +2,27 @@
 
 use std::path::Path;
 
+/// Build the native-separator `<base>/agents/teams/<id>` string the way
+/// production does (per-component `Path::join`), so test expectations match
+/// reconcile output on Windows as well as Unix.
+pub(crate) fn team_dir(base: &Path, id: &str) -> String {
+    base.join("agents")
+        .join("teams")
+        .join(id)
+        .display()
+        .to_string()
+}
+
+/// Native-separator `<base>/agents/packs/<id>` — the pre-migration layout used
+/// as reconcile input. See [`team_dir`] for why per-component join matters.
+pub(crate) fn pack_dir(base: &Path, id: &str) -> String {
+    base.join("agents")
+        .join("packs")
+        .join(id)
+        .display()
+        .to_string()
+}
+
 pub(crate) fn write_agents_json(dir: &Path, records: &serde_json::Value) {
     std::fs::create_dir_all(dir.join("agents")).unwrap();
     std::fs::write(
diff --git a/desktop/src-tauri/src/migration_tests.rs b/desktop/src-tauri/src/migration_tests.rs
index fefce641f7..b414383c17 100644
--- a/desktop/src-tauri/src/migration_tests.rs
+++ b/desktop/src-tauri/src/migration_tests.rs
@@ -65,6 +65,7 @@ fn copy_dir_all_preserves_nested_files_without_overwriting() {
 /// Helper: create a temp dir structure mimicking canonical + worktree layout.
 /// Packs live in a `.main` sibling (not canonical) to match real-world state.
 /// Returns `(parent_dir_handle, canonical_dir, worktree_dir)`.
+#[cfg(unix)]
 fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) {
     let parent = tempfile::tempdir().unwrap();
     let canonical = parent.path().join(CANONICAL_DEV_IDENTIFIER);
@@ -97,6 +98,7 @@ fn setup_sync_layout() -> (tempfile::TempDir, PathBuf, PathBuf) {
 /// Mirrors the symlink loop of `sync_shared_agent_data` but takes explicit
 /// paths. `sync_shared_agent_data` requires a live Tauri AppHandle and
 /// cannot be unit-tested directly.
+#[cfg(unix)]
 fn sync_files(canonical: &Path, worktree: &Path) -> u32 {
     let mut synced = 0u32;
     for rel in SHARED_AGENT_FILES {
@@ -180,6 +182,7 @@ fn sync_files(canonical: &Path, worktree: &Path) -> u32 {
     synced
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_creates_symlinks_to_fresh_worktree() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -201,6 +204,7 @@ fn sync_creates_symlinks_to_fresh_worktree() {
     );
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_replaces_existing_files_with_symlinks() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -226,6 +230,7 @@ fn sync_replaces_existing_files_with_symlinks() {
     );
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_preserves_correct_symlinks() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -238,6 +243,7 @@ fn sync_preserves_correct_symlinks() {
     }
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_replaces_wrong_symlinks() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -256,6 +262,7 @@ fn sync_replaces_wrong_symlinks() {
     }
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_handles_broken_symlinks() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -275,6 +282,7 @@ fn sync_handles_broken_symlinks() {
     }
 }
 
+#[cfg(unix)]
 #[test]
 fn writes_through_symlink_reach_canonical() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -321,6 +329,7 @@ fn canonical_dev_data_dir_returns_self_for_canonical_instance() {
     assert_eq!(canonical_dev_data_dir(&canonical).unwrap(), canonical);
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_creates_teams_directory_symlink() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -341,6 +350,7 @@ fn sync_creates_teams_directory_symlink() {
     );
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_migrates_teams_from_sibling_to_canonical() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -368,6 +378,7 @@ fn sync_migrates_teams_from_sibling_to_canonical() {
     );
 }
 
+#[cfg(unix)]
 #[test]
 fn sync_replaces_real_teams_dir_with_symlink() {
     let (_parent, canonical, worktree) = setup_sync_layout();
@@ -386,6 +397,7 @@ fn sync_replaces_real_teams_dir_with_symlink() {
 
 // ── Packs → Teams migration tests ───────────────────────────────────
 
+#[cfg(unix)]
 #[test]
 fn migrate_packs_merge_preserves_non_empty_dir() {
     // When packs/ contains symlinks that weren't moved (e.g., external tools
diff --git a/desktop/src/features/settings/UpdateChecker.tsx b/desktop/src/features/settings/UpdateChecker.tsx
index 4d524760f0..1c43c20977 100644
--- a/desktop/src/features/settings/UpdateChecker.tsx
+++ b/desktop/src/features/settings/UpdateChecker.tsx
@@ -59,6 +59,21 @@ export function UpdateChecker() {
           </SettingsOptionRow>
         )}
 
+        {status.state === "unavailable" && (
+          <SettingsOptionRow>
+            <div className="min-w-0">
+              <p className="text-sm font-medium">Update status</p>
+              <p className="text-sm font-normal text-muted-foreground">
+                Automatic updates aren't available on this build. Download the
+                latest release manually.
+              </p>
+            </div>
+            <Button variant="outline" size="sm" onClick={checkForUpdate}>
+              Check Again
+            </Button>
+          </SettingsOptionRow>
+        )}
+
         {status.state === "available" && (
           <SettingsOptionRow>
             <div className="min-w-0">
diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts
index 3b9c5e6322..45aff4262e 100644
--- a/desktop/src/features/settings/hooks/use-updater.ts
+++ b/desktop/src/features/settings/hooks/use-updater.ts
@@ -6,6 +6,7 @@ export type UpdateStatus =
   | { state: "idle" }
   | { state: "checking" }
   | { state: "up-to-date" }
+  | { state: "unavailable" }
   | { state: "available"; version: string }
   | { state: "downloading" }
   | { state: "installing" }
@@ -137,8 +138,12 @@ export function useUpdater() {
           !background || manualResultRequestedRef.current;
 
         if (isUpdaterUnavailable(message)) {
+          // Surface which branch fired on Windows builds where the updater
+          // plugin is missing — distinguishes plugin-unavailable from a genuine
+          // up-to-date result in Will's app log.
+          console.warn(`updater unavailable: ${message}`);
           if (shouldShowQuietResult) {
-            setStatus({ state: "idle" });
+            setStatus({ state: "unavailable" });
           }
           return;
         }
