     1→"""
     2→Agent Process Manager
     3→=====================
     4→
     5→Manages the lifecycle of agent subprocesses per project.
     6→Provides start/stop/pause/resume functionality with cross-platform support.
     7→"""
     8→
     9→import asyncio
    10→import logging
    11→import re
    12→import subprocess
    13→import sys
    14→import threading
    15→from datetime import datetime
    16→from pathlib import Path
    17→from typing import Awaitable, Callable, Literal, Set
    18→
    19→import psutil
    20→
    21→logger = logging.getLogger(__name__)
    22→
    23→# Patterns for sensitive data that should be redacted from output
    24→SENSITIVE_PATTERNS = [
    25→    r'sk-[a-zA-Z0-9]{20,}',  # Anthropic API keys
    26→    r'ANTHROPIC_API_KEY=[^\s]+',
    27→    r'api[_-]?key[=:][^\s]+',
    28→    r'token[=:][^\s]+',
    29→    r'password[=:][^\s]+',
    30→    r'secret[=:][^\s]+',
    31→    r'ghp_[a-zA-Z0-9]{36,}',  # GitHub personal access tokens
    32→    r'gho_[a-zA-Z0-9]{36,}',  # GitHub OAuth tokens
    33→    r'ghs_[a-zA-Z0-9]{36,}',  # GitHub server tokens
    34→    r'ghr_[a-zA-Z0-9]{36,}',  # GitHub refresh tokens
    35→    r'aws[_-]?access[_-]?key[=:][^\s]+',  # AWS keys
    36→    r'aws[_-]?secret[=:][^\s]+',
    37→]
    38→
    39→
    40→def sanitize_output(line: str) -> str:
    41→    """Remove sensitive information from output lines."""
    42→    for pattern in SENSITIVE_PATTERNS:
    43→        line = re.sub(pattern, '[REDACTED]', line, flags=re.IGNORECASE)
    44→    return line
    45→
    46→
    47→class AgentProcessManager:
    48→    """
    49→    Manages agent subprocess lifecycle for a single project.
    50→
    51→    Provides start/stop/pause/resume with cross-platform support via psutil.
    52→    Supports multiple output callbacks for WebSocket clients.
    53→    """
    54→
    55→    def __init__(
    56→        self,
    57→        project_name: str,
    58→        project_dir: Path,
    59→        root_dir: Path,
    60→    ):
    61→        """
    62→        Initialize the process manager.
    63→
    64→        Args:
    65→            project_name: Name of the project
    66→            project_dir: Absolute path to the project directory
    67→            root_dir: Root directory of the autonomous-coding-ui project
    68→        """
    69→        self.project_name = project_name
    70→        self.project_dir = project_dir
    71→        self.root_dir = root_dir
    72→        self.process: subprocess.Popen | None = None
    73→        self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped"
    74→        self.started_at: datetime | None = None
    75→        self._output_task: asyncio.Task | None = None
    76→        self.yolo_mode: bool = False  # YOLO mode for rapid prototyping
    77→
    78→        # Support multiple callbacks (for multiple WebSocket clients)
    79→        self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
    80→        self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
    81→        self._callbacks_lock = threading.Lock()
    82→
    83→        # Lock file to prevent multiple instances (stored in project directory)
    84→        self.lock_file = self.project_dir / ".agent.lock"
    85→
    86→    @property
    87→    def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
    88→        return self._status
    89→
    90→    @status.setter
    91→    def status(self, value: Literal["stopped", "running", "paused", "crashed"]):
    92→        old_status = self._status
    93→        self._status = value
    94→        if old_status != value:
    95→            self._notify_status_change(value)
    96→
    97→    def _notify_status_change(self, status: str) -> None:
    98→        """Notify all registered callbacks of status change."""
    99→        with self._callbacks_lock:
   100→            callbacks = list(self._status_callbacks)
   101→
   102→        for callback in callbacks:
   103→            try:
   104→                # Schedule the callback in the event loop
   105→                loop = asyncio.get_running_loop()
   106→                loop.create_task(self._safe_callback(callback, status))
   107→            except RuntimeError:
   108→                # No running event loop
   109→                pass
   110→
   111→    async def _safe_callback(self, callback: Callable, *args) -> None:
   112→        """Safely execute a callback, catching and logging any errors."""
   113→        try:
   114→            await callback(*args)
   115→        except Exception as e:
   116→            logger.warning(f"Callback error: {e}")
   117→
   118→    def add_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
   119→        """Add a callback for output lines."""
   120→        with self._callbacks_lock:
   121→            self._output_callbacks.add(callback)
   122→
   123→    def remove_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
   124→        """Remove an output callback."""
   125→        with self._callbacks_lock:
   126→            self._output_callbacks.discard(callback)
   127→
   128→    def add_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
   129→        """Add a callback for status changes."""
   130→        with self._callbacks_lock:
   131→            self._status_callbacks.add(callback)
   132→
   133→    def remove_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
   134→        """Remove a status callback."""
   135→        with self._callbacks_lock:
   136→            self._status_callbacks.discard(callback)
   137→
   138→    @property
   139→    def pid(self) -> int | None:
   140→        return self.process.pid if self.process else None
   141→
   142→    def _check_lock(self) -> bool:
   143→        """Check if another agent is already running for this project."""
   144→        if not self.lock_file.exists():
   145→            return True
   146→
   147→        try:
   148→            pid = int(self.lock_file.read_text().strip())
   149→            if psutil.pid_exists(pid):
   150→                # Check if it's actually our agent process
   151→                try:
   152→                    proc = psutil.Process(pid)
   153→                    cmdline = " ".join(proc.cmdline())
   154→                    if "autonomous_agent_demo.py" in cmdline:
   155→                        return False  # Another agent is running
   156→                except (psutil.NoSuchProcess, psutil.AccessDenied):
   157→                    pass
   158→            # Stale lock file
   159→            self.lock_file.unlink(missing_ok=True)
   160→            return True
   161→        except (ValueError, OSError):
   162→            self.lock_file.unlink(missing_ok=True)
   163→            return True
   164→
   165→    def _create_lock(self) -> None:
   166→        """Create lock file with current process PID."""
   167→        self.lock_file.parent.mkdir(parents=True, exist_ok=True)
   168→        if self.process:
   169→            self.lock_file.write_text(str(self.process.pid))
   170→
   171→    def _remove_lock(self) -> None:
   172→        """Remove lock file."""
   173→        self.lock_file.unlink(missing_ok=True)
   174→
   175→    async def _broadcast_output(self, line: str) -> None:
   176→        """Broadcast output line to all registered callbacks."""
   177→        with self._callbacks_lock:
   178→            callbacks = list(self._output_callbacks)
   179→
   180→        for callback in callbacks:
   181→            await self._safe_callback(callback, line)
   182→
   183→    async def _stream_output(self) -> None:
   184→        """Stream process output to callbacks."""
   185→        if not self.process or not self.process.stdout:
   186→            return
   187→
   188→        try:
   189→            loop = asyncio.get_running_loop()
   190→            while True:
   191→                # Use run_in_executor for blocking readline
   192→                line = await loop.run_in_executor(
   193→                    None, self.process.stdout.readline
   194→                )
   195→                if not line:
   196→                    break
   197→
   198→                decoded = line.decode("utf-8", errors="replace").rstrip()
   199→                sanitized = sanitize_output(decoded)
   200→
   201→                await self._broadcast_output(sanitized)
   202→
   203→        except asyncio.CancelledError:
   204→            raise
   205→        except Exception as e:
   206→            logger.warning(f"Output streaming error: {e}")
   207→        finally:
   208→            # Check if process ended
   209→            if self.process and self.process.poll() is not None:
   210→                exit_code = self.process.returncode
   211→                if exit_code != 0 and self.status == "running":
   212→                    self.status = "crashed"
   213→                elif self.status == "running":
   214→                    self.status = "stopped"
   215→                self._remove_lock()
   216→
   217→    async def start(self, yolo_mode: bool = False) -> tuple[bool, str]:
   218→        """
   219→        Start the agent as a subprocess.
   220→
   221→        Args:
   222→            yolo_mode: If True, run in YOLO mode (no browser testing)
   223→
   224→        Returns:
   225→            Tuple of (success, message)
   226→        """
   227→        if self.status in ("running", "paused"):
   228→            return False, f"Agent is already {self.status}"
   229→
   230→        if not self._check_lock():
   231→            return False, "Another agent instance is already running for this project"
   232→
   233→        # Store YOLO mode for status queries
   234→        self.yolo_mode = yolo_mode
   235→
   236→        # Build command - pass absolute path to project directory
   237→        cmd = [
   238→            sys.executable,
   239→            str(self.root_dir / "autonomous_agent_demo.py"),
   240→            "--project-dir",
   241→            str(self.project_dir.resolve()),
   242→        ]
   243→
   244→        # Add --yolo flag if YOLO mode is enabled
   245→        if yolo_mode:
   246→            cmd.append("--yolo")
   247→
   248→        try:
   249→            # Start subprocess with piped stdout/stderr
   250→            # Use project_dir as cwd so Claude SDK sandbox allows access to project files
   251→            self.process = subprocess.Popen(
   252→                cmd,
   253→                stdout=subprocess.PIPE,
   254→                stderr=subprocess.STDOUT,
   255→                cwd=str(self.project_dir),
   256→            )
   257→
   258→            self._create_lock()
   259→            self.started_at = datetime.now()
   260→            self.status = "running"
   261→
   262→            # Start output streaming task
   263→            self._output_task = asyncio.create_task(self._stream_output())
   264→
   265→            return True, f"Agent started with PID {self.process.pid}"
   266→        except Exception as e:
   267→            logger.exception("Failed to start agent")
   268→            return False, f"Failed to start agent: {e}"
   269→
   270→    async def stop(self) -> tuple[bool, str]:
   271→        """
   272→        Stop the agent (SIGTERM then SIGKILL if needed).
   273→
   274→        Returns:
   275→            Tuple of (success, message)
   276→        """
   277→        if not self.process or self.status == "stopped":
   278→            return False, "Agent is not running"
   279→
   280→        try:
   281→            # Cancel output streaming
   282→            if self._output_task:
   283→                self._output_task.cancel()
   284→                try:
   285→                    await self._output_task
   286→                except asyncio.CancelledError:
   287→                    pass
   288→
   289→            # Terminate gracefully first
   290→            self.process.terminate()
   291→
   292→            # Wait up to 5 seconds for graceful shutdown
   293→            loop = asyncio.get_running_loop()
   294→            try:
   295→                await asyncio.wait_for(
   296→                    loop.run_in_executor(None, self.process.wait),
   297→                    timeout=5.0
   298→                )
   299→            except asyncio.TimeoutError:
   300→                # Force kill if still running
   301→                self.process.kill()
   302→                await loop.run_in_executor(None, self.process.wait)
   303→
   304→            self._remove_lock()
   305→            self.status = "stopped"
   306→            self.process = None
   307→            self.started_at = None
   308→            self.yolo_mode = False  # Reset YOLO mode
   309→
   310→            return True, "Agent stopped"
   311→        except Exception as e:
   312→            logger.exception("Failed to stop agent")
   313→            return False, f"Failed to stop agent: {e}"
   314→
   315→    async def pause(self) -> tuple[bool, str]:
   316→        """
   317→        Pause the agent using psutil for cross-platform support.
   318→
   319→        Returns:
   320→            Tuple of (success, message)
   321→        """
   322→        if not self.process or self.status != "running":
   323→            return False, "Agent is not running"
   324→
   325→        try:
   326→            proc = psutil.Process(self.process.pid)
   327→            proc.suspend()
   328→            self.status = "paused"
   329→            return True, "Agent paused"
   330→        except psutil.NoSuchProcess:
   331→            self.status = "crashed"
   332→            self._remove_lock()
   333→            return False, "Agent process no longer exists"
   334→        except Exception as e:
   335→            logger.exception("Failed to pause agent")
   336→            return False, f"Failed to pause agent: {e}"
   337→
   338→    async def resume(self) -> tuple[bool, str]:
   339→        """
   340→        Resume a paused agent.
   341→
   342→        Returns:
   343→            Tuple of (success, message)
   344→        """
   345→        if not self.process or self.status != "paused":
   346→            return False, "Agent is not paused"
   347→
   348→        try:
   349→            proc = psutil.Process(self.process.pid)
   350→            proc.resume()
   351→            self.status = "running"
   352→            return True, "Agent resumed"
   353→        except psutil.NoSuchProcess:
   354→            self.status = "crashed"
   355→            self._remove_lock()
   356→            return False, "Agent process no longer exists"
   357→        except Exception as e:
   358→            logger.exception("Failed to resume agent")
   359→            return False, f"Failed to resume agent: {e}"
   360→
   361→    async def healthcheck(self) -> bool:
   362→        """
   363→        Check if the agent process is still alive.
   364→
   365→        Updates status to 'crashed' if process has died unexpectedly.
   366→
   367→        Returns:
   368→            True if healthy, False otherwise
   369→        """
   370→        if not self.process:
   371→            return self.status == "stopped"
   372→
   373→        poll = self.process.poll()
   374→        if poll is not None:
   375→            # Process has terminated
   376→            if self.status in ("running", "paused"):
   377→                self.status = "crashed"
   378→                self._remove_lock()
   379→            return False
   380→
   381→        return True
   382→
   383→    def get_status_dict(self) -> dict:
   384→        """Get current status as a dictionary."""
   385→        return {
   386→            "status": self.status,
   387→            "pid": self.pid,
   388→            "started_at": self.started_at.isoformat() if self.started_at else None,
   389→            "yolo_mode": self.yolo_mode,
   390→        }
   391→
   392→
   393→# Global registry of process managers per project with thread safety
   394→_managers: dict[str, AgentProcessManager] = {}
   395→_managers_lock = threading.Lock()
   396→
   397→
   398→def get_manager(project_name: str, project_dir: Path, root_dir: Path) -> AgentProcessManager:
   399→    """Get or create a process manager for a project (thread-safe).
   400→
   401→    Args:
   402→        project_name: Name of the project
   403→        project_dir: Absolute path to the project directory
   404→        root_dir: Root directory of the autonomous-coding-ui project
   405→    """
   406→    with _managers_lock:
   407→        if project_name not in _managers:
   408→            _managers[project_name] = AgentProcessManager(project_name, project_dir, root_dir)
   409→        return _managers[project_name]
   410→
   411→
   412→async def cleanup_all_managers() -> None:
   413→    """Stop all running agents. Called on server shutdown."""
   414→    with _managers_lock:
   415→        managers = list(_managers.values())
   416→
   417→    for manager in managers:
   418→        try:
   419→            if manager.status != "stopped":
   420→                await manager.stop()
   421→        except Exception as e:
   422→            logger.warning(f"Error stopping manager for {manager.project_name}: {e}")
   423→
   424→    with _managers_lock:
   425→        _managers.clear()
   426→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Agent Process Manager
=====================

Manages the lifecycle of agent subprocesses per project.
Provides start/stop/pause/resume functionality with cross-platform support.
"""

import asyncio
import logging
import re
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable, Literal, Set

import psutil

logger = logging.getLogger(__name__)

# Patterns for sensitive data that should be redacted from output
SENSITIVE_PATTERNS = [
    r'sk-[a-zA-Z0-9]{20,}',  # Anthropic API keys
    r'ANTHROPIC_API_KEY=[^\s]+',
    r'api[_-]?key[=:][^\s]+',
    r'token[=:][^\s]+',
    r'password[=:][^\s]+',
    r'secret[=:][^\s]+',
    r'ghp_[a-zA-Z0-9]{36,}',  # GitHub personal access tokens
    r'gho_[a-zA-Z0-9]{36,}',  # GitHub OAuth tokens
    r'ghs_[a-zA-Z0-9]{36,}',  # GitHub server tokens
    r'ghr_[a-zA-Z0-9]{36,}',  # GitHub refresh tokens
    r'aws[_-]?access[_-]?key[=:][^\s]+',  # AWS keys
    r'aws[_-]?secret[=:][^\s]+',
]

def sanitize_output(line: str) -> str:
    """Remove sensitive information from output lines."""
    for pattern in SENSITIVE_PATTERNS:
        line = re.sub(pattern, '[REDACTED]', line, flags=re.IGNORECASE)
    return line

class AgentProcessManager:
    """
    Manages agent subprocess lifecycle for a single project.

    Provides start/stop/pause/resume with cross-platform support via psutil.
    Supports multiple output callbacks for WebSocket clients.
    """

    def __init__(
        self,
        project_name: str,
        project_dir: Path,
        root_dir: Path,
    ):
        """
        Initialize the process manager.

        Args:
            project_name: Name of the project
            project_dir: Absolute path to the project directory
            root_dir: Root directory of the autonomous-coding-ui project
        """
        self.project_name = project_name
        self.project_dir = project_dir
        self.root_dir = root_dir
        self.process: subprocess.Popen | None = None
        self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped"
        self.started_at: datetime | None = None
        self._output_task: asyncio.Task | None = None
        self.yolo_mode: bool = False  # YOLO mode for rapid prototyping

        # Support multiple callbacks (for multiple WebSocket clients)
        self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
        self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
        self._callbacks_lock = threading.Lock()

        # Lock file to prevent multiple instances (stored in project directory)
        self.lock_file = self.project_dir / ".agent.lock"

    @property
    def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
        return self._status

    @status.setter
    def status(self, value: Literal["stopped", "running", "paused", "crashed"]):
        old_status = self._status
        self._status = value
        if old_status != value:
            self._notify_status_change(value)

    def _notify_status_change(self, status: str) -> None:
        """Notify all registered callbacks of status change."""
        with self._callbacks_lock:
            callbacks = list(self._status_callbacks)

        for callback in callbacks:
            try:
                # Schedule the callback in the event loop
                loop = asyncio.get_running_loop()
                loop.create_task(self._safe_callback(callback, status))
            except RuntimeError:
                # No running event loop
                pass

    async def _safe_callback(self, callback: Callable, *args) -> None:
        """Safely execute a callback, catching and logging any errors."""
        try:
            await callback(*args)
        except Exception as e:
            logger.warning(f"Callback error: {e}")

    def add_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Add a callback for output lines."""
        with self._callbacks_lock:
            self._output_callbacks.add(callback)

    def remove_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Remove an output callback."""
        with self._callbacks_lock:
            self._output_callbacks.discard(callback)

    def add_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Add a callback for status changes."""
        with self._callbacks_lock:
            self._status_callbacks.add(callback)

    def remove_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Remove a status callback."""
        with self._callbacks_lock:
            self._status_callbacks.discard(callback)

    @property
    def pid(self) -> int | None:
        return self.process.pid if self.process else None

    def _check_lock(self) -> bool:
        """Check if another agent is already running for this project."""
        if not self.lock_file.exists():
            return True

        try:
            pid = int(self.lock_file.read_text().strip())
            if psutil.pid_exists(pid):
                # Check if it's actually our agent process
                try:
                    proc = psutil.Process(pid)
                    cmdline = " ".join(proc.cmdline())
                    if "autonomous_agent_demo.py" in cmdline:
                        return False  # Another agent is running
                except (psutil.NoSuchProcess, psutil.AccessDenied):
                    pass
            # Stale lock file
            self.lock_file.unlink(missing_ok=True)
            return True
        except (ValueError, OSError):
            self.lock_file.unlink(missing_ok=True)
            return True

    def _create_lock(self) -> None:
        """Create lock file with current process PID."""
        self.lock_file.parent.mkdir(parents=True, exist_ok=True)
        if self.process:
            self.lock_file.write_text(str(self.process.pid))

    def _remove_lock(self) -> None:
        """Remove lock file."""
        self.lock_file.unlink(missing_ok=True)

    async def _broadcast_output(self, line: str) -> None:
        """Broadcast output line to all registered callbacks."""
        with self._callbacks_lock:
            callbacks = list(self._output_callbacks)

        for callback in callbacks:
            await self._safe_callback(callback, line)

    async def _stream_output(self) -> None:
        """Stream process output to callbacks."""
        if not self.process or not self.process.stdout:
            return

        try:
            loop = asyncio.get_running_loop()
            while True:
                # Use run_in_executor for blocking readline
                line = await loop.run_in_executor(
                    None, self.process.stdout.readline
                )
                if not line:
                    break

                decoded = line.decode("utf-8", errors="replace").rstrip()
                sanitized = sanitize_output(decoded)

                await self._broadcast_output(sanitized)

        except asyncio.CancelledError:
            raise
        except Exception as e:
            logger.warning(f"Output streaming error: {e}")
        finally:
            # Check if process ended
            if self.process and self.process.poll() is not None:
                exit_code = self.process.returncode
                if exit_code != 0 and self.status == "running":
                    self.status = "crashed"
                elif self.status == "running":
                    self.status = "stopped"
                self._remove_lock()

    async def start(self, yolo_mode: bool = False) -> tuple[bool, str]:
        """
        Start the agent as a subprocess.

        Args:
            yolo_mode: If True, run in YOLO mode (no browser testing)

        Returns:
            Tuple of (success, message)
        """
        if self.status in ("running", "paused"):
            return False, f"Agent is already {self.status}"

        if not self._check_lock():
            return False, "Another agent instance is already running for this project"

        # Store YOLO mode for status queries
        self.yolo_mode = yolo_mode

        # Build command - pass absolute path to project directory
        cmd = [
            sys.executable,
            str(self.root_dir / "autonomous_agent_demo.py"),
            "--project-dir",
            str(self.project_dir.resolve()),
        ]

        # Add --yolo flag if YOLO mode is enabled
        if yolo_mode:
            cmd.append("--yolo")

        try:
            # Start subprocess with piped stdout/stderr
            # Use project_dir as cwd so Claude SDK sandbox allows access to project files
            self.process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                cwd=str(self.project_dir),
            )

            self._create_lock()
            self.started_at = datetime.now()
            self.status = "running"

            # Start output streaming task
            self._output_task = asyncio.create_task(self._stream_output())

            return True, f"Agent started with PID {self.process.pid}"
        except Exception as e:
            logger.exception("Failed to start agent")
            return False, f"Failed to start agent: {e}"

    async def stop(self) -> tuple[bool, str]:
        """
        Stop the agent (SIGTERM then SIGKILL if needed).

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status == "stopped":
            return False, "Agent is not running"

        try:
            # Cancel output streaming
            if self._output_task:
                self._output_task.cancel()
                try:
                    await self._output_task
                except asyncio.CancelledError:
                    pass

            # Terminate gracefully first
            self.process.terminate()

            # Wait up to 5 seconds for graceful shutdown
            loop = asyncio.get_running_loop()
            try:
                await asyncio.wait_for(
                    loop.run_in_executor(None, self.process.wait),
                    timeout=5.0
                )
            except asyncio.TimeoutError:
                # Force kill if still running
                self.process.kill()
                await loop.run_in_executor(None, self.process.wait)

            self._remove_lock()
            self.status = "stopped"
            self.process = None
            self.started_at = None
            self.yolo_mode = False  # Reset YOLO mode

            return True, "Agent stopped"
        except Exception as e:
            logger.exception("Failed to stop agent")
            return False, f"Failed to stop agent: {e}"

    async def pause(self) -> tuple[bool, str]:
        """
        Pause the agent using psutil for cross-platform support.

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status != "running":
            return False, "Agent is not running"

        try:
            proc = psutil.Process(self.process.pid)
            proc.suspend()
            self.status = "paused"
            return True, "Agent paused"
        except psutil.NoSuchProcess:
            self.status = "crashed"
            self._remove_lock()
            return False, "Agent process no longer exists"
        except Exception as e:
            logger.exception("Failed to pause agent")
            return False, f"Failed to pause agent: {e}"

    async def resume(self) -> tuple[bool, str]:
        """
        Resume a paused agent.

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status != "paused":
            return False, "Agent is not paused"

        try:
            proc = psutil.Process(self.process.pid)
            proc.resume()
            self.status = "running"
            return True, "Agent resumed"
        except psutil.NoSuchProcess:
            self.status = "crashed"
            self._remove_lock()
            return False, "Agent process no longer exists"
        except Exception as e:
            logger.exception("Failed to resume agent")
            return False, f"Failed to resume agent: {e}"

    async def healthcheck(self) -> bool:
        """
        Check if the agent process is still alive.

        Updates status to 'crashed' if process has died unexpectedly.

        Returns:
            True if healthy, False otherwise
        """
        if not self.process:
            return self.status == "stopped"

        poll = self.process.poll()
        if poll is not None:
            # Process has terminated
            if self.status in ("running", "paused"):
                self.status = "crashed"
                self._remove_lock()
            return False

        return True

    def get_status_dict(self) -> dict:
        """Get current status as a dictionary."""
        return {
            "status": self.status,
            "pid": self.pid,
            "started_at": self.started_at.isoformat() if self.started_at else None,
            "yolo_mode": self.yolo_mode,
        }

# Global registry of process managers per project with thread safety
_managers: dict[str, AgentProcessManager] = {}
_managers_lock = threading.Lock()

def get_manager(project_name: str, project_dir: Path, root_dir: Path) -> AgentProcessManager:
    """Get or create a process manager for a project (thread-safe).

    Args:
        project_name: Name of the project
        project_dir: Absolute path to the project directory
        root_dir: Root directory of the autonomous-coding-ui project
    """
    with _managers_lock:
        if project_name not in _managers:
            _managers[project_name] = AgentProcessManager(project_name, project_dir, root_dir)
        return _managers[project_name]

async def cleanup_all_managers() -> None:
    """Stop all running agents. Called on server shutdown."""
    with _managers_lock:
        managers = list(_managers.values())

    for manager in managers:
        try:
            if manager.status != "stopped":
                await manager.stop()
        except Exception as e:
            logger.warning(f"Error stopping manager for {manager.project_name}: {e}")

    with _managers_lock:
        _managers.clear()


     1→"""
     2→Agent Router
     3→============
     4→
     5→API endpoints for agent control (start/stop/pause/resume).
     6→Uses project registry for path lookups.
     7→"""
     8→
     9→import re
    10→from pathlib import Path
    11→
    12→from fastapi import APIRouter, HTTPException
    13→
    14→from ..schemas import AgentActionResponse, AgentStartRequest, AgentStatus
    15→from ..services.process_manager import get_manager
    16→
    17→
    18→def _get_project_path(project_name: str) -> Path:
    19→    """Get project path from registry."""
    20→    import sys
    21→    root = Path(__file__).parent.parent.parent
    22→    if str(root) not in sys.path:
    23→        sys.path.insert(0, str(root))
    24→
    25→    from registry import get_project_path
    26→    return get_project_path(project_name)
    27→
    28→
    29→router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
    30→
    31→# Root directory for process manager
    32→ROOT_DIR = Path(__file__).parent.parent.parent
    33→
    34→
    35→def validate_project_name(name: str) -> str:
    36→    """Validate and sanitize project name to prevent path traversal."""
    37→    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
    38→        raise HTTPException(
    39→            status_code=400,
    40→            detail="Invalid project name"
    41→        )
    42→    return name
    43→
    44→
    45→def get_project_manager(project_name: str):
    46→    """Get the process manager for a project."""
    47→    project_name = validate_project_name(project_name)
    48→    project_dir = _get_project_path(project_name)
    49→
    50→    if not project_dir:
    51→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
    52→
    53→    if not project_dir.exists():
    54→        raise HTTPException(status_code=404, detail=f"Project directory not found: {project_dir}")
    55→
    56→    return get_manager(project_name, project_dir, ROOT_DIR)
    57→
    58→
    59→@router.get("/status", response_model=AgentStatus)
    60→async def get_agent_status(project_name: str):
    61→    """Get the current status of the agent for a project."""
    62→    manager = get_project_manager(project_name)
    63→
    64→    # Run healthcheck to detect crashed processes
    65→    await manager.healthcheck()
    66→
    67→    return AgentStatus(
    68→        status=manager.status,
    69→        pid=manager.pid,
    70→        started_at=manager.started_at,
    71→        yolo_mode=manager.yolo_mode,
    72→    )
    73→
    74→
    75→@router.post("/start", response_model=AgentActionResponse)
    76→async def start_agent(
    77→    project_name: str,
    78→    request: AgentStartRequest = AgentStartRequest(),
    79→):
    80→    """Start the agent for a project."""
    81→    manager = get_project_manager(project_name)
    82→
    83→    success, message = await manager.start(yolo_mode=request.yolo_mode)
    84→
    85→    return AgentActionResponse(
    86→        success=success,
    87→        status=manager.status,
    88→        message=message,
    89→    )
    90→
    91→
    92→@router.post("/stop", response_model=AgentActionResponse)
    93→async def stop_agent(project_name: str):
    94→    """Stop the agent for a project."""
    95→    manager = get_project_manager(project_name)
    96→
    97→    success, message = await manager.stop()
    98→
    99→    return AgentActionResponse(
   100→        success=success,
   101→        status=manager.status,
   102→        message=message,
   103→    )
   104→
   105→
   106→@router.post("/pause", response_model=AgentActionResponse)
   107→async def pause_agent(project_name: str):
   108→    """Pause the agent for a project."""
   109→    manager = get_project_manager(project_name)
   110→
   111→    success, message = await manager.pause()
   112→
   113→    return AgentActionResponse(
   114→        success=success,
   115→        status=manager.status,
   116→        message=message,
   117→    )
   118→
   119→
   120→@router.post("/resume", response_model=AgentActionResponse)
   121→async def resume_agent(project_name: str):
   122→    """Resume a paused agent."""
   123→    manager = get_project_manager(project_name)
   124→
   125→    success, message = await manager.resume()
   126→
   127→    return AgentActionResponse(
   128→        success=success,
   129→        status=manager.status,
   130→        message=message,
   131→    )
   132→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Agent Router
============

API endpoints for agent control (start/stop/pause/resume).
Uses project registry for path lookups.
"""

import re
from pathlib import Path

from fastapi import APIRouter, HTTPException

from ..schemas import AgentActionResponse, AgentStartRequest, AgentStatus
from ..services.process_manager import get_manager

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])

# Root directory for process manager
ROOT_DIR = Path(__file__).parent.parent.parent

def validate_project_name(name: str) -> str:
    """Validate and sanitize project name to prevent path traversal."""
    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
        raise HTTPException(
            status_code=400,
            detail="Invalid project name"
        )
    return name

def get_project_manager(project_name: str):
    """Get the process manager for a project."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail=f"Project directory not found: {project_dir}")

    return get_manager(project_name, project_dir, ROOT_DIR)

@router.get("/status", response_model=AgentStatus)
async def get_agent_status(project_name: str):
    """Get the current status of the agent for a project."""
    manager = get_project_manager(project_name)

    # Run healthcheck to detect crashed processes
    await manager.healthcheck()

    return AgentStatus(
        status=manager.status,
        pid=manager.pid,
        started_at=manager.started_at,
        yolo_mode=manager.yolo_mode,
    )

@router.post("/start", response_model=AgentActionResponse)
async def start_agent(
    project_name: str,
    request: AgentStartRequest = AgentStartRequest(),
):
    """Start the agent for a project."""
    manager = get_project_manager(project_name)

    success, message = await manager.start(yolo_mode=request.yolo_mode)

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )

@router.post("/stop", response_model=AgentActionResponse)
async def stop_agent(project_name: str):
    """Stop the agent for a project."""
    manager = get_project_manager(project_name)

    success, message = await manager.stop()

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )

@router.post("/pause", response_model=AgentActionResponse)
async def pause_agent(project_name: str):
    """Pause the agent for a project."""
    manager = get_project_manager(project_name)

    success, message = await manager.pause()

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )

@router.post("/resume", response_model=AgentActionResponse)
async def resume_agent(project_name: str):
    """Resume a paused agent."""
    manager = get_project_manager(project_name)

    success, message = await manager.resume()

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )


Now I can see the issue. The process manager has some good foundations but there are several gaps that could lead to zombie processes:

1. **No child process tree termination** - When you kill the main process, child processes (like MCP servers, the Claude SDK subprocess) may survive
2. **No server shutdown cleanup registration** - `cleanup_all_managers()` exists but needs to be registered with FastAPI's lifevent
3. **No periodic health checking** - Stale managers could hold references to dead processes
4. **MCP servers spawned separately** - The Claude Agent SDK spawns MCP servers that aren't tracked

Let me check if there's server shutdown handling and also look at how agents spawn their own processes.


     1→"""
     2→FastAPI Main Application
     3→========================
     4→
     5→Main entry point for the Autonomous Coding UI server.
     6→Provides REST API, WebSocket, and static file serving.
     7→"""
     8→
     9→import shutil
    10→from contextlib import asynccontextmanager
    11→from pathlib import Path
    12→
    13→from fastapi import FastAPI, HTTPException, Request, WebSocket
    14→from fastapi.middleware.cors import CORSMiddleware
    15→from fastapi.responses import FileResponse
    16→from fastapi.staticfiles import StaticFiles
    17→
    18→from .routers import (
    19→    agent_router,
    20→    assistant_chat_router,
    21→    features_router,
    22→    filesystem_router,
    23→    projects_router,
    24→    spec_creation_router,
    25→    terminal_router,
    26→    git_router,
    27→    scheduler_router,
    28→)
    29→from .schemas import SetupStatus
    30→from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
    31→from .services.process_manager import cleanup_all_managers
    32→from .websocket import project_websocket
    33→
    34→# Paths
    35→ROOT_DIR = Path(__file__).parent.parent
    36→UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
    37→
    38→
    39→@asynccontextmanager
    40→async def lifespan(app: FastAPI):
    41→    """Lifespan context manager for startup and shutdown."""
    42→    # Startup
    43→    yield
    44→    # Shutdown - cleanup all running agents and assistant sessions
    45→    await cleanup_all_managers()
    46→    await cleanup_assistant_sessions()
    47→
    48→
    49→# Create FastAPI app
    50→app = FastAPI(
    51→    title="Autonomous Coding UI",
    52→    description="Web UI for the Autonomous Coding Agent",
    53→    version="1.0.0",
    54→    lifespan=lifespan,
    55→)
    56→
    57→# CORS - allow only localhost origins for security
    58→app.add_middleware(
    59→    CORSMiddleware,
    60→    allow_origins=[
    61→        "http://localhost:5173",      # Vite dev server
    62→        "http://127.0.0.1:5173",
    63→        "http://localhost:8888",      # Production
    64→        "http://127.0.0.1:8888",
    65→    ],
    66→    allow_credentials=True,
    67→    allow_methods=["*"],
    68→    allow_headers=["*"],
    69→)
    70→
    71→
    72→# ============================================================================
    73→# Security Middleware
    74→# ============================================================================
    75→
    76→@app.middleware("http")
    77→async def require_localhost(request: Request, call_next):
    78→    """Only allow requests from localhost."""
    79→    client_host = request.client.host if request.client else None
    80→
    81→    # Allow localhost connections
    82→    if client_host not in ("127.0.0.1", "::1", "localhost", None):
    83→        raise HTTPException(status_code=403, detail="Localhost access only")
    84→
    85→    return await call_next(request)
    86→
    87→
    88→# ============================================================================
    89→# Include Routers
    90→# ============================================================================
    91→
    92→app.include_router(projects_router)
    93→app.include_router(features_router)
    94→app.include_router(agent_router)
    95→app.include_router(spec_creation_router)
    96→app.include_router(filesystem_router)
    97→app.include_router(assistant_chat_router)
    98→app.include_router(terminal_router)
    99→app.include_router(git_router)
   100→app.include_router(scheduler_router)
   101→
   102→
   103→# ============================================================================
   104→# WebSocket Endpoint
   105→# ============================================================================
   106→
   107→@app.websocket("/ws/projects/{project_name}")
   108→async def websocket_endpoint(websocket: WebSocket, project_name: str):
   109→    """WebSocket endpoint for real-time project updates."""
   110→    await project_websocket(websocket, project_name)
   111→
   112→
   113→# ============================================================================
   114→# Setup & Health Endpoints
   115→# ============================================================================
   116→
   117→@app.get("/api/health")
   118→async def health_check():
   119→    """Health check endpoint."""
   120→    return {"status": "healthy"}
   121→
   122→
   123→@app.get("/api/setup/status", response_model=SetupStatus)
   124→async def setup_status():
   125→    """Check system setup status."""
   126→    # Check for Claude CLI
   127→    claude_cli = shutil.which("claude") is not None
   128→
   129→    # Check for credentials file
   130→    credentials_path = Path.home() / ".claude" / ".credentials.json"
   131→    credentials = credentials_path.exists()
   132→
   133→    # Check for Node.js and npm
   134→    node = shutil.which("node") is not None
   135→    npm = shutil.which("npm") is not None
   136→
   137→    return SetupStatus(
   138→        claude_cli=claude_cli,
   139→        credentials=credentials,
   140→        node=node,
   141→        npm=npm,
   142→    )
   143→
   144→
   145→# ============================================================================
   146→# Static File Serving (Production)
   147→# ============================================================================
   148→
   149→# Serve React build files if they exist
   150→if UI_DIST_DIR.exists():
   151→    # Mount static assets
   152→    app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")
   153→
   154→    @app.get("/")
   155→    async def serve_index():
   156→        """Serve the React app index.html."""
   157→        return FileResponse(UI_DIST_DIR / "index.html")
   158→
   159→    @app.get("/{path:path}")
   160→    async def serve_spa(path: str):
   161→        """
   162→        Serve static files or fall back to index.html for SPA routing.
   163→        """
   164→        # Check if the path is an API route (shouldn't hit this due to router ordering)
   165→        if path.startswith("api/") or path.startswith("ws/"):
   166→            raise HTTPException(status_code=404)
   167→
   168→        # Try to serve the file directly
   169→        file_path = UI_DIST_DIR / path
   170→        if file_path.exists() and file_path.is_file():
   171→            return FileResponse(file_path)
   172→
   173→        # Fall back to index.html for SPA routing
   174→        return FileResponse(UI_DIST_DIR / "index.html")
   175→
   176→
   177→# ============================================================================
   178→# Main Entry Point
   179→# ============================================================================
   180→
   181→if __name__ == "__main__":
   182→    import uvicorn
   183→    uvicorn.run(
   184→        "server.main:app",
   185→        host="127.0.0.1",  # Localhost only for security
   186→        port=8888,
   187→        reload=True,
   188→    )
   189→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
FastAPI Main Application
========================

Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving.
"""

import shutil
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_router,
    spec_creation_router,
    terminal_router,
    git_router,
    scheduler_router,
)
from .schemas import SetupStatus
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
from .services.process_manager import cleanup_all_managers
from .websocket import project_websocket

# Paths
ROOT_DIR = Path(__file__).parent.parent
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager for startup and shutdown."""
    # Startup
    yield
    # Shutdown - cleanup all running agents and assistant sessions
    await cleanup_all_managers()
    await cleanup_assistant_sessions()

# Create FastAPI app
app = FastAPI(
    title="Autonomous Coding UI",
    description="Web UI for the Autonomous Coding Agent",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS - allow only localhost origins for security
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",      # Vite dev server
        "http://127.0.0.1:5173",
        "http://localhost:8888",      # Production
        "http://127.0.0.1:8888",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ============================================================================
# Security Middleware
# ============================================================================

@app.middleware("http")
async def require_localhost(request: Request, call_next):
    """Only allow requests from localhost."""
    client_host = request.client.host if request.client else None

    # Allow localhost connections
    if client_host not in ("127.0.0.1", "::1", "localhost", None):
        raise HTTPException(status_code=403, detail="Localhost access only")

    return await call_next(request)

# ============================================================================
# Include Routers
# ============================================================================

app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)
app.include_router(terminal_router)
app.include_router(git_router)
app.include_router(scheduler_router)

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@app.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
    """WebSocket endpoint for real-time project updates."""
    await project_websocket(websocket, project_name)

# ============================================================================
# Setup & Health Endpoints
# ============================================================================

@app.get("/api/health")
async def health_check():
    """Health check endpoint."""
    return {"status": "healthy"}

@app.get("/api/setup/status", response_model=SetupStatus)
async def setup_status():
    """Check system setup status."""
    # Check for Claude CLI
    claude_cli = shutil.which("claude") is not None

    # Check for credentials file
    credentials_path = Path.home() / ".claude" / ".credentials.json"
    credentials = credentials_path.exists()

    # Check for Node.js and npm
    node = shutil.which("node") is not None
    npm = shutil.which("npm") is not None

    return SetupStatus(
        claude_cli=claude_cli,
        credentials=credentials,
        node=node,
        npm=npm,
    )

# ============================================================================
# Static File Serving (Production)
# ============================================================================

# Serve React build files if they exist
if UI_DIST_DIR.exists():
    # Mount static assets
    app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")

    @app.get("/")
    async def serve_index():
        """Serve the React app index.html."""
        return FileResponse(UI_DIST_DIR / "index.html")

    @app.get("/{path:path}")
    async def serve_spa(path: str):
        """
        Serve static files or fall back to index.html for SPA routing.
        """
        # Check if the path is an API route (shouldn't hit this due to router ordering)
        if path.startswith("api/") or path.startswith("ws/"):
            raise HTTPException(status_code=404)

        # Try to serve the file directly
        file_path = UI_DIST_DIR / path
        if file_path.exists() and file_path.is_file():
            return FileResponse(file_path)

        # Fall back to index.html for SPA routing
        return FileResponse(UI_DIST_DIR / "index.html")

# ============================================================================
# Main Entry Point
# ============================================================================

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "server.main:app",
        host="127.0.0.1",  # Localhost only for security
        port=8888,
        reload=True,
    )


C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:21:    "mcp__features__feature_get_stats",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:22:    "mcp__features__feature_get_next",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:23:    "mcp__features__feature_get_for_regression",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:24:    "mcp__features__feature_mark_in_progress",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:25:    "mcp__features__feature_mark_passing",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:26:    "mcp__features__feature_skip",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:27:    "mcp__features__feature_create_bulk",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:33:    "mcp__playwright__browser_navigate",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:34:    "mcp__playwright__browser_navigate_back",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:35:    "mcp__playwright__browser_take_screenshot",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:36:    "mcp__playwright__browser_snapshot",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:39:    "mcp__playwright__browser_click",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:40:    "mcp__playwright__browser_type",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:41:    "mcp__playwright__browser_fill_form",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:42:    "mcp__playwright__browser_select_option",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:43:    "mcp__playwright__browser_hover",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:44:    "mcp__playwright__browser_drag",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:45:    "mcp__playwright__browser_press_key",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:48:    "mcp__playwright__browser_evaluate",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:49:    # "mcp__playwright__browser_run_code",  # REMOVED - causes Playwright MCP server crash
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:50:    "mcp__playwright__browser_console_messages",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:51:    "mcp__playwright__browser_network_requests",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:54:    "mcp__playwright__browser_close",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:55:    "mcp__playwright__browser_resize",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:56:    "mcp__playwright__browser_tabs",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:57:    "mcp__playwright__browser_wait_for",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:58:    "mcp__playwright__browser_handle_dialog",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:59:    "mcp__playwright__browser_file_upload",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:60:    "mcp__playwright__browser_install",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:163:    mcp_servers = {
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:166:            "args": ["-m", "mcp_server.feature_mcp"],
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:178:        mcp_servers["playwright"] = {
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:180:            "args": ["@playwright/mcp@latest", "--viewport-size", "1280x720"],
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:191:            mcp_servers=mcp_servers,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:27:from mcp.server.fastmcp import FastMCP
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:107:mcp = FastMCP("features", lifespan=server_lifespan)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:117:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:144:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:172:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:206:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:238:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:291:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:328:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:359:@mcp.tool()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:417:    mcp.run()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start.py:12:import subprocess
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start.py:220:        subprocess.run(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start.py:372:        subprocess.run(cmd, check=False)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start_ui.py:25:import subprocess
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start_ui.py:64:        subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=check)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start_ui.py:66:    except subprocess.CalledProcessError:
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start_ui.py:118:        result = subprocess.run(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start_ui.py:165:    backend = subprocess.Popen([
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start_ui.py:177:    frontend = subprocess.Popen([
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\start_ui.py:190:    return subprocess.Popen([
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\schemas.py:244:    has_children: bool = False  # True if directory has subdirectories
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\filesystem.py:257:                    has_children = False
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\filesystem.py:259:                        for child in item.iterdir():
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\filesystem.py:260:                            if child.is_dir() and not is_path_blocked(child):
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\filesystem.py:261:                                has_children = True
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\filesystem.py:264:                        pass  # Can't read = assume no children
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\filesystem.py:272:                        has_children=has_children,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:11:import subprocess
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:466:            process = await asyncio.create_subprocess_exec(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:468:                stdout=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:469:                stderr=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:548:                process = await asyncio.create_subprocess_exec(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:550:                    stdout=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:551:                    stderr=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:576:            process = await asyncio.create_subprocess_exec(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:579:                stdout=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\resource_manager.py:580:                stderr=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\git_operations.py:90:            process = await asyncio.create_subprocess_exec(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\git_operations.py:93:                stdout=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\git_operations.py:94:                stderr=asyncio.subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:34:    "mcp__features__feature_get_stats",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:35:    "mcp__features__feature_get_next",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:36:    "mcp__features__feature_get_for_regression",
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:170:        mcp_servers = {
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:173:                "args": ["-m", "mcp_server.feature_mcp"],
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:195:                    mcp_servers=mcp_servers,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:5:Manages the lifecycle of agent subprocesses per project.
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:12:import subprocess
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:49:    Manages agent subprocess lifecycle for a single project.
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:72:        self.process: subprocess.Popen | None = None
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:219:        Start the agent as a subprocess.
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:249:            # Start subprocess with piped stdout/stderr
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:251:            self.process = subprocess.Popen(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:253:                stdout=subprocess.PIPE,
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py:254:                stderr=subprocess.STDOUT,


Now I understand the zombie process problem. Here are the issues:

1. **Main Process Manager** (`process_manager.py`):
   - Uses `subprocess.Popen` but only terminates the main process
   - Doesn't kill child processes (MCP servers spawned by Claude SDK)
   - The `psutil` library is imported but `psutil.Process.children()` isn't used for recursive kill

2. **Claude SDK spawns MCP servers** (`client.py`):
   - The Feature MCP server and Playwright MCP are spawned as child processes
   - When the main agent process is terminated, these can become orphaned

3. **Server shutdown** (`main.py`):
   - `cleanup_all_managers()` IS registered in the lifespan - that's good
   - But it still doesn't recursively kill children

The fix needs to:
1. Use `psutil.Process(pid).children(recursive=True)` to find all descendants
2. Kill the entire process tree when stopping
3. Add periodic cleanup for orphaned processes
4. Track MCP server PIDs specifically

Let me add this as a critical item to address in the app spec conversation. I should also update the ARCHITECTURE.md to include this as a known issue.


Found the root cause. Here's the zombie process problem:

## Analysis

**Current state in `process_manager.py`:**
```python
# Line 289-301: Only kills the main process
self.process.terminate()
# ... waits ...
self.process.kill()
```

**What's missing:**
- No killing of child process tree (MCP servers, Playwright)
- Claude SDK spawns `feature_mcp.py` and `@playwright/mcp` as child processes
- When main agent dies, these children become orphans

**The fix needs:**
1. Use `psutil.Process(pid).children(recursive=True)` to find all descendants
2. Kill the entire process tree, not just the parent
3. Periodic orphan cleanup for edge cases

---

## Updated Conversational Spec

Add this as a **priority 0** item to your spec interview:

---

**Critical fix needed first:**

There's a zombie process bug. When the agent stops or crashes, its child processes (MCP servers) don't get cleaned up. They become orphans and pile up.

The fix: In `server/services/process_manager.py`, the `stop()` method needs to kill the entire process tree, not just the main process. Use `psutil.Process(pid).children(recursive=True)` to find all descendants and terminate them. Also add a periodic cleanup task that scans for orphaned autocoder-related processes.

Should also add a `/api/system/cleanup` endpoint and maybe a "Kill orphans" button in the UI for manual cleanup.

---

**Then the enhancements I mentioned before:**

1. Token and cost tracking
2. Session recovery
3. Git integration
4. Multi-model support
5. Learning from past work
6. Visual improvements

---

Want me to just fix the zombie process issue directly right now, or include it in the spec for Autocoder to fix itself?


both


The user wants me to:
1. Fix the zombie process issue directly right now
2. Also include it in the spec for Autocoder to work on enhancements

Let me start by fixing the zombie process issue in process_manager.py. I need to:

1. Modify the `stop()` method to kill the entire process tree
2. Add a method to find and kill orphaned processes
3. Maybe add a periodic cleanup task

Let me also create a comprehensive app spec that includes the zombie fix plus all the other enhancements.


TodoWrite

Fix zombie process bug in process_manager.py

Add orphan process cleanup utility

Add system cleanup API endpoint

Create conversational app spec for Autocoder


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable

Fix zombie process bug in process_manager.py

Add orphan process cleanup utility

Add system cleanup API endpoint

Create conversational app spec for Autocoder


Now let me fix the process_manager.py to properly kill child processes.


Edit

    async def stop(self) -> tuple[bool, str]:
        """
        Stop the agent (SIGTERM then SIGKILL if needed).

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status == "stopped":
            return False, "Agent is not running"

        try:
            # Cancel output streaming
            if self._output_task:
                self._output_task.cancel()
                try:
                    await self._output_task
                except asyncio.CancelledError:
                    pass

            # Terminate gracefully first
            self.process.terminate()

            # Wait up to 5 seconds for graceful shutdown
            loop = asyncio.get_running_loop()
            try:
                await asyncio.wait_for(
                    loop.run_in_executor(None, self.process.wait),
                    timeout=5.0
                )
            except asyncio.TimeoutError:
                # Force kill if still running
                self.process.kill()
                await loop.run_in_executor(None, self.process.wait)

            self._remove_lock()
            self.status = "stopped"
            self.process = None
            self.started_at = None
            self.yolo_mode = False  # Reset YOLO mode

            return True, "Agent stopped"
        except Exception as e:
            logger.exception("Failed to stop agent")
            return False, f"Failed to stop agent: {e}"

    def _kill_process_tree(self, pid: int, timeout: float = 5.0) -> int:
        """
        Kill a process and all its children recursively.

        Args:
            pid: Process ID to kill
            timeout: Seconds to wait for graceful termination

        Returns:
            Number of processes killed
        """
        killed_count = 0
        try:
            parent = psutil.Process(pid)
            # Get all children recursively BEFORE killing parent
            children = parent.children(recursive=True)

            # Terminate parent first
            parent.terminate()

            # Terminate all children
            for child in children:
                try:
                    child.terminate()
                except psutil.NoSuchProcess:
                    pass

            # Wait for graceful shutdown
            gone, alive = psutil.wait_procs([parent] + children, timeout=timeout)
            killed_count = len(gone)

            # Force kill any survivors
            for proc in alive:
                try:
                    logger.warning(f"Force killing process {proc.pid}")
                    proc.kill()
                    killed_count += 1
                except psutil.NoSuchProcess:
                    pass

        except psutil.NoSuchProcess:
            pass  # Already dead
        except Exception as e:
            logger.warning(f"Error killing process tree: {e}")

        return killed_count

    async def stop(self) -> tuple[bool, str]:
        """
        Stop the agent and all child processes (MCP servers, etc).

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status == "stopped":
            return False, "Agent is not running"

        try:
            # Cancel output streaming
            if self._output_task:
                self._output_task.cancel()
                try:
                    await self._output_task
                except asyncio.CancelledError:
                    pass

            # Kill entire process tree (agent + MCP servers + any children)
            pid = self.process.pid
            loop = asyncio.get_running_loop()
            killed = await loop.run_in_executor(
                None, self._kill_process_tree, pid, 5.0
            )

            logger.info(f"Killed {killed} processes in tree for {self.project_name}")

            self._remove_lock()
            self.status = "stopped"
            self.process = None
            self.started_at = None
            self.yolo_mode = False  # Reset YOLO mode

            return True, f"Agent stopped ({killed} processes terminated)"
        except Exception as e:
            logger.exception("Failed to stop agent")
            return False, f"Failed to stop agent: {e}"


The file C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py has been updated. Here's the result of running `cat -n` on a snippet of the edited file:
   266→        except Exception as e:
   267→            logger.exception("Failed to start agent")
   268→            return False, f"Failed to start agent: {e}"
   269→
   270→    def _kill_process_tree(self, pid: int, timeout: float = 5.0) -> int:
   271→        """
   272→        Kill a process and all its children recursively.
   273→
   274→        Args:
   275→            pid: Process ID to kill
   276→            timeout: Seconds to wait for graceful termination
   277→
   278→        Returns:
   279→            Number of processes killed
   280→        """
   281→        killed_count = 0
   282→        try:
   283→            parent = psutil.Process(pid)
   284→            # Get all children recursively BEFORE killing parent
   285→            children = parent.children(recursive=True)
   286→
   287→            # Terminate parent first
   288→            parent.terminate()
   289→
   290→            # Terminate all children
   291→            for child in children:
   292→                try:
   293→                    child.terminate()
   294→                except psutil.NoSuchProcess:
   295→                    pass
   296→
   297→            # Wait for graceful shutdown
   298→            gone, alive = psutil.wait_procs([parent] + children, timeout=timeout)
   299→            killed_count = len(gone)
   300→
   301→            # Force kill any survivors
   302→            for proc in alive:
   303→                try:
   304→                    logger.warning(f"Force killing process {proc.pid}")
   305→                    proc.kill()
   306→                    killed_count += 1
   307→                except psutil.NoSuchProcess:
   308→                    pass
   309→
   310→        except psutil.NoSuchProcess:
   311→            pass  # Already dead
   312→        except Exception as e:
   313→            logger.warning(f"Error killing process tree: {e}")
   314→
   315→        return killed_count
   316→
   317→    async def stop(self) -> tuple[bool, str]:
   318→        """
   319→        Stop the agent and all child processes (MCP servers, etc).
   320→
   321→        Returns:
   322→            Tuple of (success, message)
   323→        """
   324→        if not self.process or self.status == "stopped":
   325→            return False, "Agent is not running"
   326→
   327→        try:
   328→            # Cancel output streaming
   329→            if self._output_task:
   330→                self._output_task.cancel()
   331→                try:
   332→                    await self._output_task
   333→                except asyncio.CancelledError:
   334→                    pass
   335→
   336→            # Kill entire process tree (agent + MCP servers + any children)
   337→            pid = self.process.pid
   338→            loop = asyncio.get_running_loop()
   339→            killed = await loop.run_in_executor(
   340→                None, self._kill_process_tree, pid, 5.0
   341→            )
   342→
   343→            logger.info(f"Killed {killed} processes in tree for {self.project_name}")
   344→
   345→            self._remove_lock()
   346→            self.status = "stopped"
   347→            self.process = None
   348→            self.started_at = None
   349→            self.yolo_mode = False  # Reset YOLO mode
   350→
   351→            return True, f"Agent stopped ({killed} processes terminated)"
   352→        except Exception as e:
   353→            logger.exception("Failed to stop agent")
   354→            return False, f"Failed to stop agent: {e}"
   355→
   356→    async def pause(self) -> tuple[bool, str]:
   357→        """
   358→        Pause the agent using psutil for cross-platform support.

    def _kill_process_tree(self, pid: int, timeout: float = 5.0) -> int:
        """
        Kill a process and all its children recursively.

        Args:
            pid: Process ID to kill
            timeout: Seconds to wait for graceful termination

        Returns:
            Number of processes killed
        """
        killed_count = 0
        try:
            parent = psutil.Process(pid)
            # Get all children recursively BEFORE killing parent
            children = parent.children(recursive=True)

            # Terminate parent first
            parent.terminate()

            # Terminate all children
            for child in children:
                try:
                    child.terminate()
                except psutil.NoSuchProcess:
                    pass

            # Wait for graceful shutdown
            gone, alive = psutil.wait_procs([parent] + children, timeout=timeout)
            killed_count = len(gone)

            # Force kill any survivors
            for proc in alive:
                try:
                    logger.warning(f"Force killing process {proc.pid}")
                    proc.kill()
                    killed_count += 1
                except psutil.NoSuchProcess:
                    pass

        except psutil.NoSuchProcess:
            pass  # Already dead
        except Exception as e:
            logger.warning(f"Error killing process tree: {e}")

        return killed_count

    async def stop(self) -> tuple[bool, str]:
        """
        Stop the agent and all child processes (MCP servers, etc).

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status == "stopped":
            return False, "Agent is not running"

        try:
            # Cancel output streaming
            if self._output_task:
                self._output_task.cancel()
                try:
                    await self._output_task
                except asyncio.CancelledError:
                    pass

            # Kill entire process tree (agent + MCP servers + any children)
            pid = self.process.pid
            loop = asyncio.get_running_loop()
            killed = await loop.run_in_executor(
                None, self._kill_process_tree, pid, 5.0
            )

            logger.info(f"Killed {killed} processes in tree for {self.project_name}")

            self._remove_lock()
            self.status = "stopped"
            self.process = None
            self.started_at = None
            self.yolo_mode = False  # Reset YOLO mode

            return True, f"Agent stopped ({killed} processes terminated)"
        except Exception as e:
            logger.exception("Failed to stop agent")
            return False, f"Failed to stop agent: {e}"

"""
Agent Process Manager
=====================

Manages the lifecycle of agent subprocesses per project.
Provides start/stop/pause/resume functionality with cross-platform support.
"""

import asyncio
import logging
import re
import subprocess
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable, Literal, Set

import psutil

logger = logging.getLogger(__name__)

# Patterns for sensitive data that should be redacted from output
SENSITIVE_PATTERNS = [
    r'sk-[a-zA-Z0-9]{20,}',  # Anthropic API keys
    r'ANTHROPIC_API_KEY=[^\s]+',
    r'api[_-]?key[=:][^\s]+',
    r'token[=:][^\s]+',
    r'password[=:][^\s]+',
    r'secret[=:][^\s]+',
    r'ghp_[a-zA-Z0-9]{36,}',  # GitHub personal access tokens
    r'gho_[a-zA-Z0-9]{36,}',  # GitHub OAuth tokens
    r'ghs_[a-zA-Z0-9]{36,}',  # GitHub server tokens
    r'ghr_[a-zA-Z0-9]{36,}',  # GitHub refresh tokens
    r'aws[_-]?access[_-]?key[=:][^\s]+',  # AWS keys
    r'aws[_-]?secret[=:][^\s]+',
]

def sanitize_output(line: str) -> str:
    """Remove sensitive information from output lines."""
    for pattern in SENSITIVE_PATTERNS:
        line = re.sub(pattern, '[REDACTED]', line, flags=re.IGNORECASE)
    return line

class AgentProcessManager:
    """
    Manages agent subprocess lifecycle for a single project.

    Provides start/stop/pause/resume with cross-platform support via psutil.
    Supports multiple output callbacks for WebSocket clients.
    """

    def __init__(
        self,
        project_name: str,
        project_dir: Path,
        root_dir: Path,
    ):
        """
        Initialize the process manager.

        Args:
            project_name: Name of the project
            project_dir: Absolute path to the project directory
            root_dir: Root directory of the autonomous-coding-ui project
        """
        self.project_name = project_name
        self.project_dir = project_dir
        self.root_dir = root_dir
        self.process: subprocess.Popen | None = None
        self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped"
        self.started_at: datetime | None = None
        self._output_task: asyncio.Task | None = None
        self.yolo_mode: bool = False  # YOLO mode for rapid prototyping

        # Support multiple callbacks (for multiple WebSocket clients)
        self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
        self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
        self._callbacks_lock = threading.Lock()

        # Lock file to prevent multiple instances (stored in project directory)
        self.lock_file = self.project_dir / ".agent.lock"

    @property
    def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
        return self._status

    @status.setter
    def status(self, value: Literal["stopped", "running", "paused", "crashed"]):
        old_status = self._status
        self._status = value
        if old_status != value:
            self._notify_status_change(value)

    def _notify_status_change(self, status: str) -> None:
        """Notify all registered callbacks of status change."""
        with self._callbacks_lock:
            callbacks = list(self._status_callbacks)

        for callback in callbacks:
            try:
                # Schedule the callback in the event loop
                loop = asyncio.get_running_loop()
                loop.create_task(self._safe_callback(callback, status))
            except RuntimeError:
                # No running event loop
                pass

    async def _safe_callback(self, callback: Callable, *args) -> None:
        """Safely execute a callback, catching and logging any errors."""
        try:
            await callback(*args)
        except Exception as e:
            logger.warning(f"Callback error: {e}")

    def add_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Add a callback for output lines."""
        with self._callbacks_lock:
            self._output_callbacks.add(callback)

    def remove_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Remove an output callback."""
        with self._callbacks_lock:
            self._output_callbacks.discard(callback)

    def add_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Add a callback for status changes."""
        with self._callbacks_lock:
            self._status_callbacks.add(callback)

    def remove_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
        """Remove a status callback."""
        with self._callbacks_lock:
            self._status_callbacks.discard(callback)

    @property
    def pid(self) -> int | None:
        return self.process.pid if self.process else None

    def _check_lock(self) -> bool:
        """Check if another agent is already running for this project."""
        if not self.lock_file.exists():
            return True

        try:
            pid = int(self.lock_file.read_text().strip())
            if psutil.pid_exists(pid):
                # Check if it's actually our agent process
                try:
                    proc = psutil.Process(pid)
                    cmdline = " ".join(proc.cmdline())
                    if "autonomous_agent_demo.py" in cmdline:
                        return False  # Another agent is running
                except (psutil.NoSuchProcess, psutil.AccessDenied):
                    pass
            # Stale lock file
            self.lock_file.unlink(missing_ok=True)
            return True
        except (ValueError, OSError):
            self.lock_file.unlink(missing_ok=True)
            return True

    def _create_lock(self) -> None:
        """Create lock file with current process PID."""
        self.lock_file.parent.mkdir(parents=True, exist_ok=True)
        if self.process:
            self.lock_file.write_text(str(self.process.pid))

    def _remove_lock(self) -> None:
        """Remove lock file."""
        self.lock_file.unlink(missing_ok=True)

    async def _broadcast_output(self, line: str) -> None:
        """Broadcast output line to all registered callbacks."""
        with self._callbacks_lock:
            callbacks = list(self._output_callbacks)

        for callback in callbacks:
            await self._safe_callback(callback, line)

    async def _stream_output(self) -> None:
        """Stream process output to callbacks."""
        if not self.process or not self.process.stdout:
            return

        try:
            loop = asyncio.get_running_loop()
            while True:
                # Use run_in_executor for blocking readline
                line = await loop.run_in_executor(
                    None, self.process.stdout.readline
                )
                if not line:
                    break

                decoded = line.decode("utf-8", errors="replace").rstrip()
                sanitized = sanitize_output(decoded)

                await self._broadcast_output(sanitized)

        except asyncio.CancelledError:
            raise
        except Exception as e:
            logger.warning(f"Output streaming error: {e}")
        finally:
            # Check if process ended
            if self.process and self.process.poll() is not None:
                exit_code = self.process.returncode
                if exit_code != 0 and self.status == "running":
                    self.status = "crashed"
                elif self.status == "running":
                    self.status = "stopped"
                self._remove_lock()

    async def start(self, yolo_mode: bool = False) -> tuple[bool, str]:
        """
        Start the agent as a subprocess.

        Args:
            yolo_mode: If True, run in YOLO mode (no browser testing)

        Returns:
            Tuple of (success, message)
        """
        if self.status in ("running", "paused"):
            return False, f"Agent is already {self.status}"

        if not self._check_lock():
            return False, "Another agent instance is already running for this project"

        # Store YOLO mode for status queries
        self.yolo_mode = yolo_mode

        # Build command - pass absolute path to project directory
        cmd = [
            sys.executable,
            str(self.root_dir / "autonomous_agent_demo.py"),
            "--project-dir",
            str(self.project_dir.resolve()),
        ]

        # Add --yolo flag if YOLO mode is enabled
        if yolo_mode:
            cmd.append("--yolo")

        try:
            # Start subprocess with piped stdout/stderr
            # Use project_dir as cwd so Claude SDK sandbox allows access to project files
            self.process = subprocess.Popen(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                cwd=str(self.project_dir),
            )

            self._create_lock()
            self.started_at = datetime.now()
            self.status = "running"

            # Start output streaming task
            self._output_task = asyncio.create_task(self._stream_output())

            return True, f"Agent started with PID {self.process.pid}"
        except Exception as e:
            logger.exception("Failed to start agent")
            return False, f"Failed to start agent: {e}"

    async def stop(self) -> tuple[bool, str]:
        """
        Stop the agent (SIGTERM then SIGKILL if needed).

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status == "stopped":
            return False, "Agent is not running"

        try:
            # Cancel output streaming
            if self._output_task:
                self._output_task.cancel()
                try:
                    await self._output_task
                except asyncio.CancelledError:
                    pass

            # Terminate gracefully first
            self.process.terminate()

            # Wait up to 5 seconds for graceful shutdown
            loop = asyncio.get_running_loop()
            try:
                await asyncio.wait_for(
                    loop.run_in_executor(None, self.process.wait),
                    timeout=5.0
                )
            except asyncio.TimeoutError:
                # Force kill if still running
                self.process.kill()
                await loop.run_in_executor(None, self.process.wait)

            self._remove_lock()
            self.status = "stopped"
            self.process = None
            self.started_at = None
            self.yolo_mode = False  # Reset YOLO mode

            return True, "Agent stopped"
        except Exception as e:
            logger.exception("Failed to stop agent")
            return False, f"Failed to stop agent: {e}"

    async def pause(self) -> tuple[bool, str]:
        """
        Pause the agent using psutil for cross-platform support.

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status != "running":
            return False, "Agent is not running"

        try:
            proc = psutil.Process(self.process.pid)
            proc.suspend()
            self.status = "paused"
            return True, "Agent paused"
        except psutil.NoSuchProcess:
            self.status = "crashed"
            self._remove_lock()
            return False, "Agent process no longer exists"
        except Exception as e:
            logger.exception("Failed to pause agent")
            return False, f"Failed to pause agent: {e}"

    async def resume(self) -> tuple[bool, str]:
        """
        Resume a paused agent.

        Returns:
            Tuple of (success, message)
        """
        if not self.process or self.status != "paused":
            return False, "Agent is not paused"

        try:
            proc = psutil.Process(self.process.pid)
            proc.resume()
            self.status = "running"
            return True, "Agent resumed"
        except psutil.NoSuchProcess:
            self.status = "crashed"
            self._remove_lock()
            return False, "Agent process no longer exists"
        except Exception as e:
            logger.exception("Failed to resume agent")
            return False, f"Failed to resume agent: {e}"

    async def healthcheck(self) -> bool:
        """
        Check if the agent process is still alive.

        Updates status to 'crashed' if process has died unexpectedly.

        Returns:
            True if healthy, False otherwise
        """
        if not self.process:
            return self.status == "stopped"

        poll = self.process.poll()
        if poll is not None:
            # Process has terminated
            if self.status in ("running", "paused"):
                self.status = "crashed"
                self._remove_lock()
            return False

        return True

    def get_status_dict(self) -> dict:
        """Get current status as a dictionary."""
        return {
            "status": self.status,
            "pid": self.pid,
            "started_at": self.started_at.isoformat() if self.started_at else None,
            "yolo_mode": self.yolo_mode,
        }

# Global registry of process managers per project with thread safety
_managers: dict[str, AgentProcessManager] = {}
_managers_lock = threading.Lock()

def get_manager(project_name: str, project_dir: Path, root_dir: Path) -> AgentProcessManager:
    """Get or create a process manager for a project (thread-safe).

    Args:
        project_name: Name of the project
        project_dir: Absolute path to the project directory
        root_dir: Root directory of the autonomous-coding-ui project
    """
    with _managers_lock:
        if project_name not in _managers:
            _managers[project_name] = AgentProcessManager(project_name, project_dir, root_dir)
        return _managers[project_name]

async def cleanup_all_managers() -> None:
    """Stop all running agents. Called on server shutdown."""
    with _managers_lock:
        managers = list(_managers.values())

    for manager in managers:
        try:
            if manager.status != "stopped":
                await manager.stop()
        except Exception as e:
            logger.warning(f"Error stopping manager for {manager.project_name}: {e}")

    with _managers_lock:
        _managers.clear()

+    def _kill_process_tree(self, pid: int, timeout: float = 5.0) -> int:

+        Kill a process and all its children recursively.

+            timeout: Seconds to wait for graceful termination

+            parent = psutil.Process(pid)

+            # Get all children recursively BEFORE killing parent

+            children = parent.children(recursive=True)

+                except psutil.NoSuchProcess:

+            # Wait for graceful shutdown

+            gone, alive = psutil.wait_procs([parent] + children, timeout=timeout)

+                    logger.warning(f"Force killing process {proc.pid}")

+            logger.warning(f"Error killing process tree: {e}")

-        Stop the agent (SIGTERM then SIGKILL if needed).

+        Stop the agent and all child processes (MCP servers, etc).

-            # Terminate gracefully first

-            # Wait up to 5 seconds for graceful shutdown

+            # Kill entire process tree (agent + MCP servers + any children)

-                await asyncio.wait_for(

-                    loop.run_in_executor(None, self.process.wait),

-            except asyncio.TimeoutError:

-                # Force kill if still running

-                await loop.run_in_executor(None, self.process.wait)

+            killed = await loop.run_in_executor(

+                None, self._kill_process_tree, pid, 5.0

+            logger.info(f"Killed {killed} processes in tree for {self.project_name}")

-            return True, "Agent stopped"

+            return True, f"Agent stopped ({killed} processes terminated)"


Good, I've fixed the stop() method to kill the entire process tree. Now let me:
1. Add an orphan cleanup utility function
2. Add an API endpoint for system cleanup
3. Update the todo list


Now let me add the orphan cleanup utility at the end of the file.