diff --git a/apps/host-daemon/src/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts
index c308380a19..685057b9e1 100644
--- a/apps/host-daemon/src/runtime-manager.test.ts
+++ b/apps/host-daemon/src/runtime-manager.test.ts
@@ -1,4 +1,5 @@
-import { execFile } from "node:child_process";
+import { execFile, spawn } from "node:child_process";
+import { once } from "node:events";
 import fs from "node:fs/promises";
 import os from "node:os";
 import path from "node:path";
@@ -155,7 +156,11 @@ function getProvisionWorkspacePath(args: ProvisionWorkspaceArgs): string {
   }
 }
 
-function createFakeWorkspace(path: string, isGitRepo = true) {
+function createFakeWorkspace(
+  path: string,
+  isGitRepo = true,
+  options: { managed?: boolean } = {},
+) {
   const status: GetStatusResult = makeWorkspaceStatus({
     mergeBase: makeWorkspaceMergeBase(),
   });
@@ -172,7 +177,7 @@ function createFakeWorkspace(path: string, isGitRepo = true) {
   let sharedGitRefsFingerprintError: Error | null = null;
   const workspace = {
     path,
-    managed: false,
+    managed: options.managed ?? false,
     isGitRepo,
     isWorktree: false,
     getDefaultBranch: vi.fn(async () => "main"),
@@ -1720,6 +1725,73 @@ describe("RuntimeManager", () => {
     expect(workspace.destroy).toHaveBeenCalledTimes(1);
   });
 
+  it.skipIf(process.platform === "win32")(
+    "kills detached processes rooted in a managed workspace before destroying it",
+    async () => {
+      const workspacePath = await fs.realpath(
+        await fs.mkdtemp(path.join(os.tmpdir(), "bb-destroy-env-")),
+      );
+      const managedWorkspace = createFakeWorkspace(workspacePath, true, {
+        managed: true,
+      });
+      const runtime = createFakeRuntime();
+      const manager = new RuntimeManager({
+        provisionWorkspace:
+          createProvisionWorkspaceMock(workspacePath).mockResolvedValue(
+            managedWorkspace,
+          ),
+        createRuntime: vi.fn(() => runtime),
+      });
+      await manager.ensureEnvironment({
+        environmentId: "env-procs",
+        workspacePath,
+      });
+      // A new-session process is out of reach of any process-group kill.
+      const orphan = spawn("sh", ["-c", "sleep 300 & echo $!; wait"], {
+        cwd: workspacePath,
+        detached: true,
+        stdio: ["ignore", "pipe", "ignore"],
+      });
+      orphan.unref();
+      const grandchildPid = Number(
+        String((await once(orphan.stdout, "data"))[0]).trim(),
+      );
+      const isAlive = (pid: number) => {
+        try {
+          process.kill(pid, 0);
+          return true;
+        } catch {
+          return false;
+        }
+      };
+      try {
+        expect(isAlive(grandchildPid)).toBe(true);
+
+        await manager.destroyEnvironment("env-procs");
+
+        expect(managedWorkspace.destroy).toHaveBeenCalledTimes(1);
+        const deadline = Date.now() + 5000;
+        while (
+          (isAlive(grandchildPid) || isAlive(orphan.pid ?? 0)) &&
+          Date.now() < deadline
+        ) {
+          await new Promise((resolve) => setTimeout(resolve, 25));
+        }
+        expect(isAlive(grandchildPid)).toBe(false);
+        expect(isAlive(orphan.pid ?? 0)).toBe(false);
+      } finally {
+        for (const pid of [grandchildPid, orphan.pid ?? 0]) {
+          try {
+            process.kill(pid, "SIGKILL");
+          } catch {
+            // already gone
+          }
+        }
+        await fs.rm(workspacePath, { recursive: true, force: true });
+      }
+    },
+  );
+
   it("forgets a retired environment without destroying its workspace", async () => {
     const workspace = createFakeWorkspace("/tmp/env-retired");
     const runtime = createFakeRuntime();
diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts
index 2e35d96c3e..3442aa7ed6 100644
--- a/apps/host-daemon/src/runtime-manager.ts
+++ b/apps/host-daemon/src/runtime-manager.ts
@@ -9,6 +9,7 @@ import {
   type ReapedIdleProviderSession,
 } from "@bb/agent-runtime";
 import type { Logger } from "@bb/logger";
+import { killProcessesWithCwdUnder } from "@bb/process-utils";
 import type {
   PendingInteractionCreate,
   PendingInteractionResolution,
@@ -1122,10 +1123,49 @@ export class RuntimeManager {
     this.entries.delete(environmentId);
     await this.stopWatchingStatus(entry);
     await entry.runtime.shutdown();
+    await this.killManagedWorkspaceProcesses(entry);
     await entry.workspace.destroy();
     await this.cleanupUnusedInjectedSkillStagingDirs([]);
   }
 
+  /**
+   * Reaps every process still rooted in a managed workspace before its
+   * directory is removed. Runtime and terminal shutdown signal their own
+   * process groups, but processes that start a new session (`setsid`,
+   * `nohup`, detached dev servers) survive that and would otherwise keep
+   * running with a cwd in a deleted directory.
+   */
+  private async killManagedWorkspaceProcesses(
+    entry: RuntimeEntry,
+  ): Promise<void> {
+    if (!entry.workspace.managed) {
+      return;
+    }
+    try {
+      const killed = await killProcessesWithCwdUnder({
+        directory: entry.workspace.path,
+      });
+      if (killed.length > 0) {
+        this.options.logger?.warn(
+          {
+            environmentId: entry.environmentId,
+            workspacePath: entry.workspace.path,
+            pids: killed.map((process) => process.pid),
+          },
+          "Killed processes still running in a destroyed environment",
+        );
+      }
+    } catch (error) {
+      this.options.logger?.warn(
+        {
+          environmentId: entry.environmentId,
+          reason: error instanceof Error ? error.message : String(error),
+        },
+        "Failed to reap processes in a destroyed environment",
+      );
+    }
+  }
+
   async forgetEnvironment(environmentId: string): Promise<void> {
     const existing = this.entries.get(environmentId);
     const pending = this.pendingEntries.get(environmentId);
diff --git a/apps/host-daemon/src/terminals/terminal-manager.ts b/apps/host-daemon/src/terminals/terminal-manager.ts
index d5d88dd006..96a9ac12e5 100644
--- a/apps/host-daemon/src/terminals/terminal-manager.ts
+++ b/apps/host-daemon/src/terminals/terminal-manager.ts
@@ -6,7 +6,10 @@ import path from "node:path";
 import { spawn as spawnPty } from "node-pty";
 import type { TerminalSessionCloseReason } from "@bb/domain";
 import type { HostDaemonDaemonWsMessage } from "@bb/host-daemon-contract";
-import { sanitizeInheritedChildProcessEnv } from "@bb/process-utils";
+import {
+  killProcessGroup,
+  sanitizeInheritedChildProcessEnv,
+} from "@bb/process-utils";
 import type { HostDaemonServerTerminalMessage } from "../server-connection-support.js";
 import type { HostDaemonLogger } from "../logger.js";
 import { RuntimeManager } from "../runtime-manager.js";
@@ -54,7 +57,7 @@ export interface TerminalPtyExit {
 }
 
 export interface TerminalPtyProcess {
-  kill(signal?: string): void;
+  kill(signal?: NodeJS.Signals): void;
   onData(listener: (data: string) => void): TerminalPtyDisposable;
   onExit(listener: (event: TerminalPtyExit) => void): TerminalPtyDisposable;
   resize(cols: number, rows: number): void;
@@ -209,7 +212,14 @@ export const nodePtyAdapter: TerminalPtyAdapter = {
       rows: args.rows,
     });
     return {
-      kill: (signal) => pty.kill(signal),
+      // The pty child is a session leader, so its pid is also its process
+      // group id. Signal the whole group so background jobs die with the
+      // shell instead of surviving with a cwd in a removed workspace.
+      kill: (signal) =>
+        killProcessGroup({
+          child: { pid: pty.pid, kill: (groupSignal) => pty.kill(groupSignal) },
+          signal: signal ?? "SIGHUP",
+        }),
       onData: (listener) => pty.onData(listener),
       onExit: (listener) =>
         pty.onExit((event) =>
diff --git a/packages/agent-runtime/src/runtime-provider-process.ts b/packages/agent-runtime/src/runtime-provider-process.ts
index 9be419bc1b..ed7534ccca 100644
--- a/packages/agent-runtime/src/runtime-provider-process.ts
+++ b/packages/agent-runtime/src/runtime-provider-process.ts
@@ -3,8 +3,11 @@ import { randomUUID } from "node:crypto";
 import { createInterface } from "node:readline";
 import type { HostDaemonAcpLaunchSpec } from "@bb/host-daemon-contract";
 import {
+  isProcessGroupAlive,
+  killProcessGroup,
   sanitizeInheritedChildProcessEnv,
   spawnPortablePipedProcess,
+  supportsProcessGroups,
 } from "@bb/process-utils";
 import type {
   ProviderAdapter,
@@ -316,16 +319,26 @@ export class RuntimeProviderProcessManager {
         shutdownPromises.push(
           new Promise<void>((resolve) => {
             const timer = setTimeout(() => {
-              providerProcess.child.kill("SIGKILL");
+              killProcessGroup({
+                child: providerProcess.child,
+                signal: "SIGKILL",
+              });
               resolve();
             }, 5000);
 
             providerProcess.child.on("exit", () => {
+              // Keep the SIGKILL timer while group members outlive the leader.
+              if (isProcessGroupAlive(providerProcess.child)) {
+                return;
+              }
               clearTimeout(timer);
               resolve();
             });
 
-            providerProcess.child.kill("SIGTERM");
+            killProcessGroup({
+              child: providerProcess.child,
+              signal: "SIGTERM",
+            });
           }),
         );
       }
@@ -375,10 +388,13 @@ export class RuntimeProviderProcessManager {
       ...processConfig.env,
     };
 
+    // Lead a process group so shutdown can also reap grandchildren the
+    // provider CLI starts (background dev servers, MCP servers, ...).
     const child = spawnPortablePipedProcess({
       command: processConfig.command,
       args: processConfig.args,
       cwd: this.args.workspacePath,
+      detached: supportsProcessGroups(),
       env,
     });
     let finalizeExit: () => void = () => undefined;
@@ -531,19 +547,32 @@ export class RuntimeProviderProcessManager {
     await new Promise<void>((resolve) => {
       const timeoutMs = args.timeoutMs ?? 5000;
       const softTimer = setTimeout(() => {
-        if (!hasChildProcessExited(args.providerProcess.child)) {
-          args.providerProcess.child.kill("SIGKILL");
+        if (
+          !hasChildProcessExited(args.providerProcess.child) ||
+          isProcessGroupAlive(args.providerProcess.child)
+        ) {
+          killProcessGroup({
+            child: args.providerProcess.child,
+            signal: "SIGKILL",
+          });
         }
       }, timeoutMs);
       const hardTimer = setTimeout(resolve, timeoutMs + 1000);
 
       args.providerProcess.child.once("exit", () => {
+        // Keep the SIGKILL timer while group members outlive the leader.
+        if (isProcessGroupAlive(args.providerProcess.child)) {
+          return;
+        }
         clearTimeout(softTimer);
         clearTimeout(hardTimer);
         resolve();
       });
 
-      args.providerProcess.child.kill("SIGTERM");
+      killProcessGroup({
+        child: args.providerProcess.child,
+        signal: "SIGTERM",
+      });
     });
   }
 
diff --git a/packages/host-workspace/src/provisioning.ts b/packages/host-workspace/src/provisioning.ts
index 705b0d6157..d00e267424 100644
--- a/packages/host-workspace/src/provisioning.ts
+++ b/packages/host-workspace/src/provisioning.ts
@@ -8,9 +8,10 @@ import {
   type ProvisioningTranscriptEntry,
 } from "@bb/domain";
 import {
+  killProcessGroup,
   sanitizeInheritedChildProcessEnv,
   spawnPortableOutputProcess,
-  type PortableOutputChildProcess,
+  supportsProcessGroups,
 } from "@bb/process-utils";
 import { Workspace } from "./workspace.js";
 import { tryWithCheckoutMutationLock } from "./checkout-mutation-lock.js";
@@ -88,11 +89,6 @@ interface BuildSetupScriptCommandArgs {
   scriptPath: string;
 }
 
-interface KillSetupScriptProcessArgs {
-  child: PortableOutputChildProcess;
-  signal: NodeJS.Signals;
-}
-
 const SETUP_SCRIPT_ABORT_KILL_GRACE_MS = 2_000;
 
 function emitProgress(
@@ -211,23 +207,6 @@ export function buildSetupScriptCommand(
   };
 }
 
-function shouldRunSetupScriptInProcessGroup(): boolean {
-  return process.platform !== "win32";
-}
-
-function killSetupScriptProcess(args: KillSetupScriptProcessArgs): void {
-  if (shouldRunSetupScriptInProcessGroup() && args.child.pid !== undefined) {
-    try {
-      process.kill(-args.child.pid, args.signal);
-      return;
-    } catch {
-      // Fall back to killing the direct child if the process group is gone.
-    }
-  }
-
-  args.child.kill(args.signal);
-}
-
 function createProvisionCancelledError(cause?: unknown): WorkspaceError {
   return new WorkspaceError(
     "provision_cancelled",
@@ -568,7 +547,7 @@ export async function runSetupScript(
     command: command.command,
     args: command.args,
     cwd: args.workspacePath,
-    detached: shouldRunSetupScriptInProcessGroup(),
+    detached: supportsProcessGroups(),
     env,
   });
 
@@ -597,7 +576,7 @@ export async function runSetupScript(
 
   const timeout = setTimeout(() => {
     timedOut = true;
-    killSetupScriptProcess({
+    killProcessGroup({
       child,
       signal: "SIGKILL",
     });
@@ -607,12 +586,12 @@ export async function runSetupScript(
       return;
     }
     abortRequested = true;
-    killSetupScriptProcess({
+    killProcessGroup({
       child,
       signal: "SIGTERM",
     });
     abortKillTimeout = setTimeout(() => {
-      killSetupScriptProcess({
+      killProcessGroup({
         child,
         signal: "SIGKILL",
       });
diff --git a/packages/process-utils/src/index.ts b/packages/process-utils/src/index.ts
index bc7aa03be3..f5201dfab8 100644
--- a/packages/process-utils/src/index.ts
+++ b/packages/process-utils/src/index.ts
@@ -1,7 +1,16 @@
 import type { ChildProcess, StdioOptions } from "node:child_process";
 import { randomUUID } from "node:crypto";
 import { mkdirSync, writeFileSync } from "node:fs";
-import { isAbsolute, join, relative, resolve, sep } from "node:path";
+import { lstat, readdir, readlink, realpath } from "node:fs/promises";
+import {
+  basename,
+  dirname,
+  isAbsolute,
+  join,
+  relative,
+  resolve,
+  sep,
+} from "node:path";
 import type { Readable, Writable } from "node:stream";
 import crossSpawn from "cross-spawn";
 
@@ -36,6 +45,29 @@ export interface PortableOutputChildProcess extends PortableChildProcess {
   stderr: Readable;
 }
 
+export interface KillProcessGroupArgs {
+  child: {
+    pid?: number | undefined;
+    kill: (signal: NodeJS.Signals) => unknown;
+  };
+  signal: NodeJS.Signals;
+}
+
+export interface ProcessWithCwd {
+  pid: number;
+  cwd: string;
+}
+
+export interface ListProcessesWithCwdUnderArgs {
+  directory: string;
+}
+
+export interface KillProcessesWithCwdUnderArgs {
+  directory: string;
+  /** Time to wait after SIGTERM before SIGKILL. Defaults to 2000ms. */
+  graceMs?: number;
+}
+
 export interface ResolveContainedPathArgs {
   rootPath: string;
   candidatePath: string;
@@ -45,17 +77,14 @@ export interface SanitizeInheritedChildProcessEnvArgs {
   env: NodeJS.ProcessEnv;
 }
 
-export type SafeProcessDiagnosticKind =
-  | "startupFailure"
-  | "uncaughtException";
+export type SafeProcessDiagnosticKind = "startupFailure" | "uncaughtException";
 
 export interface SafeProcessDiagnosticsOptions {
   logsDir: string;
   processName: string;
 }
 
-export interface WriteSafeProcessDiagnosticReportArgs
-  extends SafeProcessDiagnosticsOptions {
+export interface WriteSafeProcessDiagnosticReportArgs extends SafeProcessDiagnosticsOptions {
   kind: SafeProcessDiagnosticKind;
   error: unknown;
   now?: () => Date;
@@ -145,6 +174,225 @@ export function spawnPortableOutputProcess(
   return child;
 }
 
+/**
+ * True when the platform supports POSIX process groups. Callers spawn with
+ * `detached: true` so the child leads its own group and this helper can signal
+ * the whole group, including grandchildren.
+ */
+export function supportsProcessGroups(): boolean {
+  return process.platform !== "win32";
+}
+
+/**
+ * Sends `signal` to the child's process group when possible, and falls back
+ * to the direct child when the group is gone or unsupported.
+ */
+export function killProcessGroup(args: KillProcessGroupArgs): void {
+  if (supportsProcessGroups() && args.child.pid !== undefined) {
+    try {
+      process.kill(-args.child.pid, args.signal);
+      return;
+    } catch {
+      // Fall back to killing the direct child if the process group is gone.
+    }
+  }
+  args.child.kill(args.signal);
+}
+
+/**
+ * True when at least one process still belongs to the group led by `child`.
+ * Returns false when the platform has no process groups or the child was not
+ * spawned as a group leader.
+ */
+export function isProcessGroupAlive(child: {
+  pid?: number | undefined;
+}): boolean {
+  if (!supportsProcessGroups() || child.pid === undefined) {
+    return false;
+  }
+  try {
+    process.kill(-child.pid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function isPathUnderDirectory(candidate: string, directory: string): boolean {
+  // Linux reports a removed cwd as "<path> (deleted)".
+  const normalized = candidate.endsWith(" (deleted)")
+    ? candidate.slice(0, -" (deleted)".length)
+    : candidate;
+  return (
+    normalized === directory || normalized.startsWith(`${directory}${sep}`)
+  );
+}
+
+async function listLinuxProcessCwds(): Promise<ProcessWithCwd[]> {
+  const entries = await readdir("/proc");
+  const results: ProcessWithCwd[] = [];
+  await Promise.all(
+    entries.map(async (entry) => {
+      if (!/^\d+$/.test(entry)) {
+        return;
+      }
+      try {
+        const cwd = await readlink(`/proc/${entry}/cwd`);
+        results.push({ pid: Number(entry), cwd });
+      } catch {
+        // Process exited or is not readable by this user.
+      }
+    }),
+  );
+  return results;
+}
+
+async function listLsofProcessCwds(): Promise<ProcessWithCwd[]> {
+  const child = spawnPortableOutputProcess({
+    command: "lsof",
+    args: ["-a", "-d", "cwd", "-F", "pn", "-w", "-n"],
+  });
+  const chunks: Buffer[] = [];
+  child.stdout.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
+  child.stderr.resume();
+  await new Promise<void>((resolveExit) => {
+    child.once("error", () => resolveExit());
+    child.once("exit", () => resolveExit());
+  });
+  const results: ProcessWithCwd[] = [];
+  let pid: number | null = null;
+  for (const line of Buffer.concat(chunks).toString("utf8").split("\n")) {
+    if (line.startsWith("p")) {
+      pid = Number(line.slice(1));
+    } else if (line.startsWith("n") && pid !== null) {
+      results.push({ pid, cwd: line.slice(1) });
+    }
+  }
+  return results;
+}
+
+/**
+ * Resolves the canonical path of `directory` for cwd matching. Returns null
+ * when the final path component is a symlink: a workspace root that was
+ * swapped for a link must not redirect the sweep to unrelated processes.
+ */
+async function resolveSweepDirectory(
+  directory: string,
+): Promise<string | null> {
+  const resolved = resolve(directory);
+  let parent = dirname(resolved);
+  try {
+    parent = await realpath(parent);
+  } catch {
+    // The parent may already be gone; match against the given path.
+  }
+  const canonical = join(parent, basename(resolved));
+  try {
+    if ((await lstat(canonical)).isSymbolicLink()) {
+      return null;
+    }
+  } catch {
+    // The directory itself may already be removed. Processes can still hold
+    // it as a "(deleted)" cwd, so keep matching on the path.
+  }
+  return canonical;
+}
+
+/**
+ * Lists processes whose current working directory is `directory` or a path
+ * inside it. Excludes the current process. Returns [] on unsupported
+ * platforms and when `directory` is a symlink.
+ */
+export async function listProcessesWithCwdUnder(
+  args: ListProcessesWithCwdUnderArgs,
+): Promise<ProcessWithCwd[]> {
+  if (process.platform === "win32") {
+    return [];
+  }
+  const directory = await resolveSweepDirectory(args.directory);
+  if (directory === null) {
+    return [];
+  }
+  const all =
+    process.platform === "linux"
+      ? await listLinuxProcessCwds()
+      : await listLsofProcessCwds();
+  return all.filter(
+    (entry) =>
+      entry.pid !== process.pid && isPathUnderDirectory(entry.cwd, directory),
+  );
+}
+
+function isProcessAlive(pid: number): boolean {
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function delay(ms: number): Promise<void> {
+  await new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
+}
+
+const MAX_CWD_SWEEP_ROUNDS = 5;
+
+function signalProcesses(
+  targets: ProcessWithCwd[],
+  signal: NodeJS.Signals,
+  signalled: Map<number, ProcessWithCwd>,
+): void {
+  for (const target of targets) {
+    try {
+      process.kill(target.pid, signal);
+      signalled.set(target.pid, target);
+    } catch {
+      // Already exited.
+    }
+  }
+}
+
+/**
+ * Sends SIGTERM to every process rooted in `directory`, waits up to
+ * `graceMs`, rescans, and SIGKILLs the processes that are still rooted there.
+ * Each signal follows a fresh scan, so a reused pid or a process that
+ * appeared during shutdown never receives a stale signal. Repeats until a
+ * scan finds nothing, bounded by a small round limit. Returns the processes
+ * that received a signal.
+ */
+export async function killProcessesWithCwdUnder(
+  args: KillProcessesWithCwdUnderArgs,
+): Promise<ProcessWithCwd[]> {
+  const graceMs = args.graceMs ?? 2000;
+  const signalled = new Map<number, ProcessWithCwd>();
+  for (let round = 0; round < MAX_CWD_SWEEP_ROUNDS; round += 1) {
+    const targets = await listProcessesWithCwdUnder({
+      directory: args.directory,
+    });
+    if (targets.length === 0) {
+      break;
+    }
+    signalProcesses(targets, "SIGTERM", signalled);
+    const deadline = Date.now() + graceMs;
+    while (
+      Date.now() < deadline &&
+      targets.some((target) => isProcessAlive(target.pid))
+    ) {
+      await delay(50);
+    }
+    const survivors = await listProcessesWithCwdUnder({
+      directory: args.directory,
+    });
+    if (survivors.length === 0) {
+      break;
+    }
+    signalProcesses(survivors, "SIGKILL", signalled);
+    await delay(50);
+  }
+  return Array.from(signalled.values());
+}
+
 export function resolveContainedPath(
   args: ResolveContainedPathArgs,
 ): string | null {
diff --git a/packages/process-utils/test/process-tree.test.ts b/packages/process-utils/test/process-tree.test.ts
new file mode 100644
index 0000000000..ccd0c0729f
--- /dev/null
+++ b/packages/process-utils/test/process-tree.test.ts
@@ -0,0 +1,176 @@
+import { spawn } from "node:child_process";
+import { once } from "node:events";
+import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+  isProcessGroupAlive,
+  killProcessGroup,
+  killProcessesWithCwdUnder,
+  listProcessesWithCwdUnder,
+  spawnPortablePipedProcess,
+} from "../src/index.js";
+
+const posixOnly = process.platform === "win32" ? describe.skip : describe;
+
+function isAlive(pid: number): boolean {
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function waitFor(check: () => boolean, timeoutMs = 5000): Promise<void> {
+  const deadline = Date.now() + timeoutMs;
+  while (!check()) {
+    if (Date.now() > deadline) {
+      throw new Error("Timed out waiting for condition");
+    }
+    await new Promise((resolve) => setTimeout(resolve, 25));
+  }
+}
+
+async function readFirstLine(stream: NodeJS.ReadableStream): Promise<string> {
+  const [chunk] = await once(stream, "data");
+  return String(chunk).trim();
+}
+
+posixOnly("process tree helpers", () => {
+  const cleanupPids: number[] = [];
+  const cleanupDirs: string[] = [];
+
+  afterEach(() => {
+    for (const pid of cleanupPids) {
+      try {
+        process.kill(pid, "SIGKILL");
+      } catch {
+        // already gone
+      }
+    }
+    cleanupPids.length = 0;
+    for (const dir of cleanupDirs) {
+      rmSync(dir, { recursive: true, force: true });
+    }
+    cleanupDirs.length = 0;
+  });
+
+  it("kills the grandchild when the leader is signalled by process group", async () => {
+    const child = spawnPortablePipedProcess({
+      command: "sh",
+      args: ["-c", "sleep 300 & echo $!; wait"],
+      detached: true,
+    });
+    const grandchildPid = Number(await readFirstLine(child.stdout));
+    cleanupPids.push(grandchildPid);
+    expect(isAlive(grandchildPid)).toBe(true);
+    const exited = once(child, "exit");
+
+    killProcessGroup({ child, signal: "SIGKILL" });
+
+    await waitFor(() => !isAlive(grandchildPid));
+    await exited;
+  });
+
+  it("finds and kills processes whose cwd is inside a directory", async () => {
+    const dir = realpathSync(mkdtempSync(join(tmpdir(), "bb-cwd-sweep-")));
+    cleanupDirs.push(dir);
+    // A detached session leader: not reachable by any process-group kill.
+    const child = spawn("sh", ["-c", "sleep 300 & echo $!; wait"], {
+      cwd: dir,
+      detached: true,
+      stdio: ["ignore", "pipe", "ignore"],
+    });
+    child.unref();
+    const grandchildPid = Number(await readFirstLine(child.stdout));
+    cleanupPids.push(child.pid ?? 0, grandchildPid);
+
+    const found = await listProcessesWithCwdUnder({ directory: dir });
+    expect(found.map((entry) => entry.pid)).toEqual(
+      expect.arrayContaining([child.pid, grandchildPid]),
+    );
+    expect(found.map((entry) => entry.pid)).not.toContain(process.pid);
+
+    const killed = await killProcessesWithCwdUnder({
+      directory: dir,
+      graceMs: 200,
+    });
+    expect(killed.map((entry) => entry.pid)).toEqual(
+      expect.arrayContaining([child.pid, grandchildPid]),
+    );
+    await waitFor(() => !isAlive(grandchildPid) && !isAlive(child.pid ?? 0));
+
+    // A sibling directory with a shared prefix must not match.
+    expect(
+      await listProcessesWithCwdUnder({ directory: `${dir}-other` }),
+    ).toEqual([]);
+  });
+
+  it("reports a live group after the leader exits and an empty one after the members die", async () => {
+    const child = spawnPortablePipedProcess({
+      command: "sh",
+      args: ["-c", "sleep 300 & echo $!"],
+      detached: true,
+    });
+    const grandchildPid = Number(await readFirstLine(child.stdout));
+    cleanupPids.push(grandchildPid);
+    await once(child, "exit");
+
+    expect(isProcessGroupAlive(child)).toBe(true);
+    process.kill(grandchildPid, "SIGKILL");
+    await waitFor(() => !isProcessGroupAlive(child));
+  });
+
+  it("rescans and kills processes that appear while the first targets shut down", async () => {
+    const dir = realpathSync(mkdtempSync(join(tmpdir(), "bb-cwd-respawn-")));
+    cleanupDirs.push(dir);
+    // On SIGTERM the target starts a new-session child and exits.
+    const child = spawn(
+      "sh",
+      [
+        "-c",
+        "trap 'setsid sleep 300 >/dev/null 2>&1 < /dev/null & exit 0' TERM; echo ready; while :; do sleep 0.05; done",
+      ],
+      { cwd: dir, detached: true, stdio: ["ignore", "pipe", "ignore"] },
+    );
+    child.unref();
+    await readFirstLine(child.stdout);
+    cleanupPids.push(child.pid ?? 0);
+
+    const killed = await killProcessesWithCwdUnder({
+      directory: dir,
+      graceMs: 500,
+    });
+
+    expect(killed.length).toBeGreaterThanOrEqual(2);
+    for (const target of killed) {
+      cleanupPids.push(target.pid);
+    }
+    await waitFor(() => killed.every((target) => !isAlive(target.pid)));
+    expect(await listProcessesWithCwdUnder({ directory: dir })).toEqual([]);
+  });
+
+  it("does not follow a symlinked workspace root", async () => {
+    const target = realpathSync(mkdtempSync(join(tmpdir(), "bb-cwd-target-")));
+    const linkParent = mkdtempSync(join(tmpdir(), "bb-cwd-link-"));
+    cleanupDirs.push(target, linkParent);
+    const link = join(linkParent, "workspace");
+    symlinkSync(target, link);
+    const child = spawn("sleep", ["300"], { cwd: target, stdio: "ignore" });
+    cleanupPids.push(child.pid ?? 0);
+    await waitFor(() => (child.pid ?? 0) > 0);
+
+    expect(await listProcessesWithCwdUnder({ directory: target })).toEqual([
+      { pid: child.pid, cwd: target },
+    ]);
+    expect(await listProcessesWithCwdUnder({ directory: link })).toEqual([]);
+  });
+
+  it("returns an empty list for a directory that no process uses", async () => {
+    const dir = mkdtempSync(join(tmpdir(), "bb-cwd-empty-"));
+    cleanupDirs.push(dir);
+    expect(await listProcessesWithCwdUnder({ directory: dir })).toEqual([]);
+  });
+});
