     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→    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.
   359→
   360→        Returns:
   361→            Tuple of (success, message)
   362→        """
   363→        if not self.process or self.status != "running":
   364→            return False, "Agent is not running"
   365→
   366→        try:
   367→            proc = psutil.Process(self.process.pid)
   368→            proc.suspend()
   369→            self.status = "paused"
   370→            return True, "Agent paused"
   371→        except psutil.NoSuchProcess:
   372→            self.status = "crashed"
   373→            self._remove_lock()
   374→            return False, "Agent process no longer exists"
   375→        except Exception as e:
   376→            logger.exception("Failed to pause agent")
   377→            return False, f"Failed to pause agent: {e}"
   378→
   379→    async def resume(self) -> tuple[bool, str]:
   380→        """
   381→        Resume a paused agent.
   382→
   383→        Returns:
   384→            Tuple of (success, message)
   385→        """
   386→        if not self.process or self.status != "paused":
   387→            return False, "Agent is not paused"
   388→
   389→        try:
   390→            proc = psutil.Process(self.process.pid)
   391→            proc.resume()
   392→            self.status = "running"
   393→            return True, "Agent resumed"
   394→        except psutil.NoSuchProcess:
   395→            self.status = "crashed"
   396→            self._remove_lock()
   397→            return False, "Agent process no longer exists"
   398→        except Exception as e:
   399→            logger.exception("Failed to resume agent")
   400→            return False, f"Failed to resume agent: {e}"
   401→
   402→    async def healthcheck(self) -> bool:
   403→        """
   404→        Check if the agent process is still alive.
   405→
   406→        Updates status to 'crashed' if process has died unexpectedly.
   407→
   408→        Returns:
   409→            True if healthy, False otherwise
   410→        """
   411→        if not self.process:
   412→            return self.status == "stopped"
   413→
   414→        poll = self.process.poll()
   415→        if poll is not None:
   416→            # Process has terminated
   417→            if self.status in ("running", "paused"):
   418→                self.status = "crashed"
   419→                self._remove_lock()
   420→            return False
   421→
   422→        return True
   423→
   424→    def get_status_dict(self) -> dict:
   425→        """Get current status as a dictionary."""
   426→        return {
   427→            "status": self.status,
   428→            "pid": self.pid,
   429→            "started_at": self.started_at.isoformat() if self.started_at else None,
   430→            "yolo_mode": self.yolo_mode,
   431→        }
   432→
   433→
   434→# Global registry of process managers per project with thread safety
   435→_managers: dict[str, AgentProcessManager] = {}
   436→_managers_lock = threading.Lock()
   437→
   438→
   439→def get_manager(project_name: str, project_dir: Path, root_dir: Path) -> AgentProcessManager:
   440→    """Get or create a process manager for a project (thread-safe).
   441→
   442→    Args:
   443→        project_name: Name of the project
   444→        project_dir: Absolute path to the project directory
   445→        root_dir: Root directory of the autonomous-coding-ui project
   446→    """
   447→    with _managers_lock:
   448→        if project_name not in _managers:
   449→            _managers[project_name] = AgentProcessManager(project_name, project_dir, root_dir)
   450→        return _managers[project_name]
   451→
   452→
   453→async def cleanup_all_managers() -> None:
   454→    """Stop all running agents. Called on server shutdown."""
   455→    with _managers_lock:
   456→        managers = list(_managers.values())
   457→
   458→    for manager in managers:
   459→        try:
   460→            if manager.status != "stopped":
   461→                await manager.stop()
   462→        except Exception as e:
   463→            logger.warning(f"Error stopping manager for {manager.project_name}: {e}")
   464→
   465→    with _managers_lock:
   466→        _managers.clear()
   467→
   468→
   469→def find_orphan_processes() -> list[dict]:
   470→    """
   471→    Find orphaned Autocoder-related processes.
   472→
   473→    Returns:
   474→        List of dicts with pid, name, cmdline, create_time
   475→    """
   476→    orphans = []
   477→    patterns = [
   478→        "autonomous_agent_demo.py",
   479→        "feature_mcp",
   480→        "@playwright/mcp",
   481→        "mcp_server",
   482→    ]
   483→
   484→    for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'create_time', 'ppid']):
   485→        try:
   486→            info = proc.info
   487→            cmdline = " ".join(info.get('cmdline') or [])
   488→
   489→            # Check if it matches our patterns
   490→            if any(p in cmdline for p in patterns):
   491→                # Check if parent is init/systemd (orphaned) or if parent doesn't exist
   492→                ppid = info.get('ppid', 0)
   493→                is_orphan = ppid in (0, 1) or not psutil.pid_exists(ppid)
   494→
   495→                # Also check if it's not tracked by any manager
   496→                with _managers_lock:
   497→                    tracked_pids = {m.pid for m in _managers.values() if m.pid}
   498→
   499→                if is_orphan or info['pid'] not in tracked_pids:
   500→                    orphans.append({
   501→                        'pid': info['pid'],
   502→                        'name': info['name'],
   503→                        'cmdline': cmdline[:200],  # Truncate for display
   504→                        'create_time': info['create_time'],
   505→                        'is_orphan': is_orphan,
   506→                    })
   507→        except (psutil.NoSuchProcess, psutil.AccessDenied):
   508→            continue
   509→
   510→    return orphans
   511→
   512→
   513→def kill_orphan_processes() -> dict:
   514→    """
   515→    Find and kill orphaned Autocoder-related processes.
   516→
   517→    Returns:
   518→        Dict with counts of found, killed, failed
   519→    """
   520→    orphans = find_orphan_processes()
   521→    results = {'found': len(orphans), 'killed': 0, 'failed': 0, 'details': []}
   522→
   523→    for orphan in orphans:
   524→        try:
   525→            proc = psutil.Process(orphan['pid'])
   526→            # Kill the process and its children
   527→            children = proc.children(recursive=True)
   528→            for child in children:
   529→                try:
   530→                    child.kill()
   531→                except psutil.NoSuchProcess:
   532→                    pass
   533→            proc.kill()
   534→            results['killed'] += 1
   535→            results['details'].append({
   536→                'pid': orphan['pid'],
   537→                'status': 'killed',
   538→                'cmdline': orphan['cmdline'][:100]
   539→            })
   540→            logger.info(f"Killed orphan process {orphan['pid']}: {orphan['cmdline'][:50]}")
   541→        except psutil.NoSuchProcess:
   542→            results['killed'] += 1  # Already dead, count as success
   543→        except Exception as e:
   544→            results['failed'] += 1
   545→            results['details'].append({
   546→                'pid': orphan['pid'],
   547→                'status': 'failed',
   548→                'error': str(e)
   549→            })
   550→            logger.warning(f"Failed to kill orphan {orphan['pid']}: {e}")
   551→
   552→    return results
   553→

<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}"

    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}"

    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 find_orphan_processes() -> list[dict]:
    """
    Find orphaned Autocoder-related processes.

    Returns:
        List of dicts with pid, name, cmdline, create_time
    """
    orphans = []
    patterns = [
        "autonomous_agent_demo.py",
        "feature_mcp",
        "@playwright/mcp",
        "mcp_server",
    ]

    for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'create_time', 'ppid']):
        try:
            info = proc.info
            cmdline = " ".join(info.get('cmdline') or [])

            # Check if it matches our patterns
            if any(p in cmdline for p in patterns):
                # Check if parent is init/systemd (orphaned) or if parent doesn't exist
                ppid = info.get('ppid', 0)
                is_orphan = ppid in (0, 1) or not psutil.pid_exists(ppid)

                # Also check if it's not tracked by any manager
                with _managers_lock:
                    tracked_pids = {m.pid for m in _managers.values() if m.pid}

                if is_orphan or info['pid'] not in tracked_pids:
                    orphans.append({
                        'pid': info['pid'],
                        'name': info['name'],
                        'cmdline': cmdline[:200],  # Truncate for display
                        'create_time': info['create_time'],
                        'is_orphan': is_orphan,
                    })
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            continue

    return orphans

def kill_orphan_processes() -> dict:
    """
    Find and kill orphaned Autocoder-related processes.

    Returns:
        Dict with counts of found, killed, failed
    """
    orphans = find_orphan_processes()
    results = {'found': len(orphans), 'killed': 0, 'failed': 0, 'details': []}

    for orphan in orphans:
        try:
            proc = psutil.Process(orphan['pid'])
            # Kill the process and its children
            children = proc.children(recursive=True)
            for child in children:
                try:
                    child.kill()
                except psutil.NoSuchProcess:
                    pass
            proc.kill()
            results['killed'] += 1
            results['details'].append({
                'pid': orphan['pid'],
                'status': 'killed',
                'cmdline': orphan['cmdline'][:100]
            })
            logger.info(f"Killed orphan process {orphan['pid']}: {orphan['cmdline'][:50]}")
        except psutil.NoSuchProcess:
            results['killed'] += 1  # Already dead, count as success
        except Exception as e:
            results['failed'] += 1
            results['details'].append({
                'pid': orphan['pid'],
                'status': 'failed',
                'error': str(e)
            })
            logger.warning(f"Failed to kill orphan {orphan['pid']}: {e}")

    return results


     1→"""
     2→Progress Tracking Utilities
     3→===========================
     4→
     5→Functions for tracking and displaying progress of the autonomous coding agent.
     6→Uses direct SQLite access for database queries.
     7→"""
     8→
     9→import json
    10→import os
    11→import sqlite3
    12→import urllib.request
    13→from datetime import datetime
    14→from pathlib import Path
    15→
    16→WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL")
    17→PROGRESS_CACHE_FILE = ".progress_cache"
    18→
    19→
    20→def has_features(project_dir: Path) -> bool:
    21→    """
    22→    Check if the project has features in the database.
    23→
    24→    This is used to determine if the initializer agent needs to run.
    25→    We check the database directly (not via API) since the API server
    26→    may not be running yet when this check is performed.
    27→
    28→    Returns True if:
    29→    - features.db exists AND has at least 1 feature, OR
    30→    - feature_list.json exists (legacy format)
    31→
    32→    Returns False if no features exist (initializer needs to run).
    33→    """
    34→    import sqlite3
    35→
    36→    # Check legacy JSON file first
    37→    json_file = project_dir / "feature_list.json"
    38→    if json_file.exists():
    39→        return True
    40→
    41→    # Check SQLite database
    42→    db_file = project_dir / "features.db"
    43→    if not db_file.exists():
    44→        return False
    45→
    46→    try:
    47→        conn = sqlite3.connect(db_file)
    48→        cursor = conn.cursor()
    49→        cursor.execute("SELECT COUNT(*) FROM features")
    50→        count = cursor.fetchone()[0]
    51→        conn.close()
    52→        return count > 0
    53→    except Exception:
    54→        # Database exists but can't be read or has no features table
    55→        return False
    56→
    57→
    58→def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
    59→    """
    60→    Count passing, in_progress, and total tests via direct database access.
    61→
    62→    Args:
    63→        project_dir: Directory containing the project
    64→
    65→    Returns:
    66→        (passing_count, in_progress_count, total_count)
    67→    """
    68→    db_file = project_dir / "features.db"
    69→    if not db_file.exists():
    70→        return 0, 0, 0
    71→
    72→    try:
    73→        conn = sqlite3.connect(db_file)
    74→        cursor = conn.cursor()
    75→        cursor.execute("SELECT COUNT(*) FROM features")
    76→        total = cursor.fetchone()[0]
    77→        cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1")
    78→        passing = cursor.fetchone()[0]
    79→        # Handle case where in_progress column doesn't exist yet
    80→        try:
    81→            cursor.execute("SELECT COUNT(*) FROM features WHERE in_progress = 1")
    82→            in_progress = cursor.fetchone()[0]
    83→        except sqlite3.OperationalError:
    84→            in_progress = 0
    85→        conn.close()
    86→        return passing, in_progress, total
    87→    except Exception as e:
    88→        print(f"[Database error in count_passing_tests: {e}]")
    89→        return 0, 0, 0
    90→
    91→
    92→def get_all_passing_features(project_dir: Path) -> list[dict]:
    93→    """
    94→    Get all passing features for webhook notifications.
    95→
    96→    Args:
    97→        project_dir: Directory containing the project
    98→
    99→    Returns:
   100→        List of dicts with id, category, name for each passing feature
   101→    """
   102→    db_file = project_dir / "features.db"
   103→    if not db_file.exists():
   104→        return []
   105→
   106→    try:
   107→        conn = sqlite3.connect(db_file)
   108→        cursor = conn.cursor()
   109→        cursor.execute(
   110→            "SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC"
   111→        )
   112→        features = [
   113→            {"id": row[0], "category": row[1], "name": row[2]}
   114→            for row in cursor.fetchall()
   115→        ]
   116→        conn.close()
   117→        return features
   118→    except Exception:
   119→        return []
   120→
   121→
   122→def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None:
   123→    """Send webhook notification when progress increases."""
   124→    if not WEBHOOK_URL:
   125→        return  # Webhook not configured
   126→
   127→    cache_file = project_dir / PROGRESS_CACHE_FILE
   128→    previous = 0
   129→    previous_passing_ids = set()
   130→
   131→    # Read previous progress and passing feature IDs
   132→    if cache_file.exists():
   133→        try:
   134→            cache_data = json.loads(cache_file.read_text())
   135→            previous = cache_data.get("count", 0)
   136→            previous_passing_ids = set(cache_data.get("passing_ids", []))
   137→        except Exception:
   138→            previous = 0
   139→
   140→    # Only notify if progress increased
   141→    if passing > previous:
   142→        # Find which features are now passing via API
   143→        completed_tests = []
   144→        current_passing_ids = []
   145→
   146→        # Detect transition from old cache format (had count but no passing_ids)
   147→        # In this case, we can't reliably identify which specific tests are new
   148→        is_old_cache_format = len(previous_passing_ids) == 0 and previous > 0
   149→
   150→        # Get all passing features via direct database access
   151→        all_passing = get_all_passing_features(project_dir)
   152→        for feature in all_passing:
   153→            feature_id = feature.get("id")
   154→            current_passing_ids.append(feature_id)
   155→            # Only identify individual new tests if we have previous IDs to compare
   156→            if not is_old_cache_format and feature_id not in previous_passing_ids:
   157→                # This feature is newly passing
   158→                name = feature.get("name", f"Feature #{feature_id}")
   159→                category = feature.get("category", "")
   160→                if category:
   161→                    completed_tests.append(f"{category} {name}")
   162→                else:
   163→                    completed_tests.append(name)
   164→
   165→        payload = {
   166→            "event": "test_progress",
   167→            "passing": passing,
   168→            "total": total,
   169→            "percentage": round((passing / total) * 100, 1) if total > 0 else 0,
   170→            "previous_passing": previous,
   171→            "tests_completed_this_session": passing - previous,
   172→            "completed_tests": completed_tests,
   173→            "project": project_dir.name,
   174→            "timestamp": datetime.utcnow().isoformat() + "Z",
   175→        }
   176→
   177→        try:
   178→            req = urllib.request.Request(
   179→                WEBHOOK_URL,
   180→                data=json.dumps([payload]).encode("utf-8"),  # n8n expects array
   181→                headers={"Content-Type": "application/json"},
   182→            )
   183→            urllib.request.urlopen(req, timeout=5)
   184→        except Exception as e:
   185→            print(f"[Webhook notification failed: {e}]")
   186→
   187→        # Update cache with count and passing IDs
   188→        cache_file.write_text(
   189→            json.dumps({"count": passing, "passing_ids": current_passing_ids})
   190→        )
   191→    else:
   192→        # Update cache even if no change (for initial state)
   193→        if not cache_file.exists():
   194→            all_passing = get_all_passing_features(project_dir)
   195→            current_passing_ids = [f.get("id") for f in all_passing]
   196→            cache_file.write_text(
   197→                json.dumps({"count": passing, "passing_ids": current_passing_ids})
   198→            )
   199→
   200→
   201→def print_session_header(session_num: int, is_initializer: bool) -> None:
   202→    """Print a formatted header for the session."""
   203→    session_type = "INITIALIZER" if is_initializer else "CODING AGENT"
   204→
   205→    print("\n" + "=" * 70)
   206→    print(f"  SESSION {session_num}: {session_type}")
   207→    print("=" * 70)
   208→    print()
   209→
   210→
   211→def print_progress_summary(project_dir: Path) -> None:
   212→    """Print a summary of current progress."""
   213→    passing, in_progress, total = count_passing_tests(project_dir)
   214→
   215→    if total > 0:
   216→        percentage = (passing / total) * 100
   217→        status_parts = [f"{passing}/{total} tests passing ({percentage:.1f}%)"]
   218→        if in_progress > 0:
   219→            status_parts.append(f"{in_progress} in progress")
   220→        print(f"\nProgress: {', '.join(status_parts)}")
   221→        send_progress_webhook(passing, total, project_dir)
   222→    else:
   223→        print("\nProgress: No features in database yet")
   224→
   225→
   226→def get_feature_stats(project_dir: Path) -> dict:
   227→    """
   228→    Get feature statistics from the database.
   229→
   230→    Args:
   231→        project_dir: Directory containing the project
   232→
   233→    Returns:
   234→        Dict with keys: pending, in_progress, completed, total
   235→    """
   236→    passing, in_progress, total = count_passing_tests(project_dir)
   237→    pending = max(0, total - passing - in_progress)
   238→
   239→    return {
   240→        "pending": pending,
   241→        "in_progress": in_progress,
   242→        "completed": passing,
   243→        "total": total,
   244→    }
   245→

<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>

"""
Progress Tracking Utilities
===========================

Functions for tracking and displaying progress of the autonomous coding agent.
Uses direct SQLite access for database queries.
"""

import json
import os
import sqlite3
import urllib.request
from datetime import datetime
from pathlib import Path

WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL")
PROGRESS_CACHE_FILE = ".progress_cache"

def has_features(project_dir: Path) -> bool:
    """
    Check if the project has features in the database.

    This is used to determine if the initializer agent needs to run.
    We check the database directly (not via API) since the API server
    may not be running yet when this check is performed.

    Returns True if:
    - features.db exists AND has at least 1 feature, OR
    - feature_list.json exists (legacy format)

    Returns False if no features exist (initializer needs to run).
    """
    import sqlite3

    # Check legacy JSON file first
    json_file = project_dir / "feature_list.json"
    if json_file.exists():
        return True

    # Check SQLite database
    db_file = project_dir / "features.db"
    if not db_file.exists():
        return False

    try:
        conn = sqlite3.connect(db_file)
        cursor = conn.cursor()
        cursor.execute("SELECT COUNT(*) FROM features")
        count = cursor.fetchone()[0]
        conn.close()
        return count > 0
    except Exception:
        # Database exists but can't be read or has no features table
        return False

def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
    """
    Count passing, in_progress, and total tests via direct database access.

    Args:
        project_dir: Directory containing the project

    Returns:
        (passing_count, in_progress_count, total_count)
    """
    db_file = project_dir / "features.db"
    if not db_file.exists():
        return 0, 0, 0

    try:
        conn = sqlite3.connect(db_file)
        cursor = conn.cursor()
        cursor.execute("SELECT COUNT(*) FROM features")
        total = cursor.fetchone()[0]
        cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1")
        passing = cursor.fetchone()[0]
        # Handle case where in_progress column doesn't exist yet
        try:
            cursor.execute("SELECT COUNT(*) FROM features WHERE in_progress = 1")
            in_progress = cursor.fetchone()[0]
        except sqlite3.OperationalError:
            in_progress = 0
        conn.close()
        return passing, in_progress, total
    except Exception as e:
        print(f"[Database error in count_passing_tests: {e}]")
        return 0, 0, 0

def get_all_passing_features(project_dir: Path) -> list[dict]:
    """
    Get all passing features for webhook notifications.

    Args:
        project_dir: Directory containing the project

    Returns:
        List of dicts with id, category, name for each passing feature
    """
    db_file = project_dir / "features.db"
    if not db_file.exists():
        return []

    try:
        conn = sqlite3.connect(db_file)
        cursor = conn.cursor()
        cursor.execute(
            "SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC"
        )
        features = [
            {"id": row[0], "category": row[1], "name": row[2]}
            for row in cursor.fetchall()
        ]
        conn.close()
        return features
    except Exception:
        return []

def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None:
    """Send webhook notification when progress increases."""
    if not WEBHOOK_URL:
        return  # Webhook not configured

    cache_file = project_dir / PROGRESS_CACHE_FILE
    previous = 0
    previous_passing_ids = set()

    # Read previous progress and passing feature IDs
    if cache_file.exists():
        try:
            cache_data = json.loads(cache_file.read_text())
            previous = cache_data.get("count", 0)
            previous_passing_ids = set(cache_data.get("passing_ids", []))
        except Exception:
            previous = 0

    # Only notify if progress increased
    if passing > previous:
        # Find which features are now passing via API
        completed_tests = []
        current_passing_ids = []

        # Detect transition from old cache format (had count but no passing_ids)
        # In this case, we can't reliably identify which specific tests are new
        is_old_cache_format = len(previous_passing_ids) == 0 and previous > 0

        # Get all passing features via direct database access
        all_passing = get_all_passing_features(project_dir)
        for feature in all_passing:
            feature_id = feature.get("id")
            current_passing_ids.append(feature_id)
            # Only identify individual new tests if we have previous IDs to compare
            if not is_old_cache_format and feature_id not in previous_passing_ids:
                # This feature is newly passing
                name = feature.get("name", f"Feature #{feature_id}")
                category = feature.get("category", "")
                if category:
                    completed_tests.append(f"{category} {name}")
                else:
                    completed_tests.append(name)

        payload = {
            "event": "test_progress",
            "passing": passing,
            "total": total,
            "percentage": round((passing / total) * 100, 1) if total > 0 else 0,
            "previous_passing": previous,
            "tests_completed_this_session": passing - previous,
            "completed_tests": completed_tests,
            "project": project_dir.name,
            "timestamp": datetime.utcnow().isoformat() + "Z",
        }

        try:
            req = urllib.request.Request(
                WEBHOOK_URL,
                data=json.dumps([payload]).encode("utf-8"),  # n8n expects array
                headers={"Content-Type": "application/json"},
            )
            urllib.request.urlopen(req, timeout=5)
        except Exception as e:
            print(f"[Webhook notification failed: {e}]")

        # Update cache with count and passing IDs
        cache_file.write_text(
            json.dumps({"count": passing, "passing_ids": current_passing_ids})
        )
    else:
        # Update cache even if no change (for initial state)
        if not cache_file.exists():
            all_passing = get_all_passing_features(project_dir)
            current_passing_ids = [f.get("id") for f in all_passing]
            cache_file.write_text(
                json.dumps({"count": passing, "passing_ids": current_passing_ids})
            )

def print_session_header(session_num: int, is_initializer: bool) -> None:
    """Print a formatted header for the session."""
    session_type = "INITIALIZER" if is_initializer else "CODING AGENT"

    print("\n" + "=" * 70)
    print(f"  SESSION {session_num}: {session_type}")
    print("=" * 70)
    print()

def print_progress_summary(project_dir: Path) -> None:
    """Print a summary of current progress."""
    passing, in_progress, total = count_passing_tests(project_dir)

    if total > 0:
        percentage = (passing / total) * 100
        status_parts = [f"{passing}/{total} tests passing ({percentage:.1f}%)"]
        if in_progress > 0:
            status_parts.append(f"{in_progress} in progress")
        print(f"\nProgress: {', '.join(status_parts)}")
        send_progress_webhook(passing, total, project_dir)
    else:
        print("\nProgress: No features in database yet")

def get_feature_stats(project_dir: Path) -> dict:
    """
    Get feature statistics from the database.

    Args:
        project_dir: Directory containing the project

    Returns:
        Dict with keys: pending, in_progress, completed, total
    """
    passing, in_progress, total = count_passing_tests(project_dir)
    pending = max(0, total - passing - in_progress)

    return {
        "pending": pending,
        "in_progress": in_progress,
        "completed": passing,
        "total": total,
    }


     1→"""
     2→Feature Dependency Graph
     3→========================
     4→
     5→Track feature dependencies and determine which features can be
     6→executed in parallel without conflicts.
     7→"""
     8→
     9→import logging
    10→from dataclasses import dataclass, field
    11→from pathlib import Path
    12→from typing import Optional
    13→
    14→from sqlalchemy import Column, Integer, String, ForeignKey, Table, create_engine, text
    15→from sqlalchemy.orm import sessionmaker, relationship
    16→from sqlalchemy.ext.declarative import declarative_base
    17→
    18→logger = logging.getLogger(__name__)
    19→
    20→Base = declarative_base()
    21→
    22→
    23→# Association table for feature dependencies (many-to-many)
    24→feature_dependencies = Table(
    25→    'feature_dependencies',
    26→    Base.metadata,
    27→    Column('feature_id', Integer, ForeignKey('feature_graph.id'), primary_key=True),
    28→    Column('depends_on_id', Integer, primary_key=True),  # References features.id in main db
    29→)
    30→
    31→
    32→class FeatureGraphEntry(Base):
    33→    """Track metadata about features for parallel execution."""
    34→
    35→    __tablename__ = "feature_graph"
    36→
    37→    id = Column(Integer, primary_key=True)  # Same as feature.id
    38→    files_touched = Column(String(4000), default="")  # Comma-separated file paths
    39→    estimated_complexity = Column(Integer, default=1)  # 1-5 scale
    40→
    41→
    42→class FileLockEntry(Base):
    43→    """Track which files are currently locked by which feature."""
    44→
    45→    __tablename__ = "file_locks"
    46→
    47→    id = Column(Integer, primary_key=True, autoincrement=True)
    48→    file_path = Column(String(500), unique=True, nullable=False, index=True)
    49→    feature_id = Column(Integer, nullable=False)
    50→    locked_at = Column(String(50))  # ISO timestamp
    51→
    52→
    53→@dataclass
    54→class ParallelFeature:
    55→    """A feature that can be worked on in parallel."""
    56→    id: int
    57→    priority: int
    58→    category: str
    59→    name: str
    60→    description: str
    61→    steps: list[str]
    62→    estimated_complexity: int = 1
    63→
    64→
    65→@dataclass
    66→class FeatureConflict:
    67→    """Describes why two features cannot run in parallel."""
    68→    feature_id: int
    69→    conflicting_feature_id: int
    70→    reason: str
    71→    conflicting_files: list[str] = field(default_factory=list)
    72→
    73→
    74→class FeatureGraph:
    75→    """
    76→    Track feature dependencies and file conflicts for parallel execution.
    77→
    78→    This class maintains a separate SQLite database alongside the main
    79→    features.db to track:
    80→    - Which files each feature has touched/will touch
    81→    - Dependencies between features
    82→    - Current file locks
    83→
    84→    This enables the orchestrator to determine which features can be
    85→    safely worked on in parallel.
    86→    """
    87→
    88→    def __init__(self, project_dir: Path):
    89→        """
    90→        Initialize the feature graph.
    91→
    92→        Args:
    93→            project_dir: Path to the project directory
    94→        """
    95→        self.project_dir = project_dir
    96→        self.db_path = project_dir / "feature_graph.db"
    97→        self.db_url = f"sqlite:///{self.db_path.as_posix()}"
    98→
    99→        # Create database
   100→        self.engine = create_engine(
   101→            self.db_url,
   102→            connect_args={"check_same_thread": False}
   103→        )
   104→        Base.metadata.create_all(bind=self.engine)
   105→        self.SessionLocal = sessionmaker(
   106→            autocommit=False,
   107→            autoflush=False,
   108→            bind=self.engine
   109→        )
   110→
   111→        # Also connect to the main features database
   112→        self.features_db_path = project_dir / "features.db"
   113→        self.features_db_url = f"sqlite:///{self.features_db_path.as_posix()}"
   114→
   115→    def _get_session(self):
   116→        """Get a database session."""
   117→        return self.SessionLocal()
   118→
   119→    def _get_features_from_main_db(self) -> list[dict]:
   120→        """Get all pending features from the main features database."""
   121→        if not self.features_db_path.exists():
   122→            return []
   123→
   124→        features_engine = create_engine(
   125→            self.features_db_url,
   126→            connect_args={"check_same_thread": False}
   127→        )
   128→
   129→        with features_engine.connect() as conn:
   130→            result = conn.execute(text("""
   131→                SELECT id, priority, category, name, description, steps, in_progress
   132→                FROM features
   133→                WHERE passes = 0
   134→                ORDER BY priority ASC, id ASC
   135→            """))
   136→
   137→            features = []
   138→            for row in result.fetchall():
   139→                # Parse steps JSON
   140→                import json
   141→                steps = row[5]
   142→                if isinstance(steps, str):
   143→                    steps = json.loads(steps)
   144→
   145→                features.append({
   146→                    "id": row[0],
   147→                    "priority": row[1],
   148→                    "category": row[2],
   149→                    "name": row[3],
   150→                    "description": row[4],
   151→                    "steps": steps,
   152→                    "in_progress": row[6],
   153→                })
   154→
   155→            return features
   156→
   157→    def get_parallelizable_features(
   158→        self,
   159→        count: int = 3,
   160→        exclude_in_progress: bool = True,
   161→    ) -> list[ParallelFeature]:
   162→        """
   163→        Get features that can be worked on in parallel.
   164→
   165→        Features are parallelizable if:
   166→        1. They don't have unsatisfied dependencies
   167→        2. They don't touch the same files as currently in-progress features
   168→        3. They're not already in progress (if exclude_in_progress=True)
   169→
   170→        Args:
   171→            count: Maximum number of features to return
   172→            exclude_in_progress: Whether to exclude features already in progress
   173→
   174→        Returns:
   175→            List of features that can be worked on in parallel
   176→        """
   177→        features = self._get_features_from_main_db()
   178→
   179→        if not features:
   180→            return []
   181→
   182→        session = self._get_session()
   183→        try:
   184→            # Get currently locked files
   185→            locked_files = set()
   186→            locks = session.query(FileLockEntry).all()
   187→            for lock in locks:
   188→                locked_files.add(lock.file_path)
   189→
   190→            # Get in-progress feature IDs
   191→            in_progress_ids = set()
   192→            if exclude_in_progress:
   193→                for f in features:
   194→                    if f.get("in_progress"):
   195→                        in_progress_ids.add(f["id"])
   196→
   197→            # Filter and score features
   198→            parallelizable = []
   199→            selected_files = set()  # Files that will be touched by selected features
   200→
   201→            for feature in features:
   202→                # Skip if already in progress
   203→                if feature["id"] in in_progress_ids:
   204→                    continue
   205→
   206→                # Get files this feature might touch
   207→                graph_entry = session.query(FeatureGraphEntry).filter(
   208→                    FeatureGraphEntry.id == feature["id"]
   209→                ).first()
   210→
   211→                feature_files = set()
   212→                if graph_entry and graph_entry.files_touched:
   213→                    feature_files = set(graph_entry.files_touched.split(","))
   214→
   215→                # Check for conflicts with locked files
   216→                if feature_files & locked_files:
   217→                    logger.debug(
   218→                        f"Feature {feature['id']} conflicts with locked files: "
   219→                        f"{feature_files & locked_files}"
   220→                    )
   221→                    continue
   222→
   223→                # Check for conflicts with already selected features
   224→                if feature_files & selected_files:
   225→                    logger.debug(
   226→                        f"Feature {feature['id']} conflicts with selected features"
   227→                    )
   228→                    continue
   229→
   230→                # This feature is parallelizable
   231→                complexity = graph_entry.estimated_complexity if graph_entry else 1
   232→
   233→                parallelizable.append(ParallelFeature(
   234→                    id=feature["id"],
   235→                    priority=feature["priority"],
   236→                    category=feature["category"],
   237→                    name=feature["name"],
   238→                    description=feature["description"],
   239→                    steps=feature["steps"],
   240→                    estimated_complexity=complexity,
   241→                ))
   242→
   243→                # Track files this feature will touch
   244→                selected_files.update(feature_files)
   245→
   246→                if len(parallelizable) >= count:
   247→                    break
   248→
   249→            return parallelizable
   250→
   251→        finally:
   252→            session.close()
   253→
   254→    def record_files_touched(
   255→        self,
   256→        feature_id: int,
   257→        files: list[str],
   258→    ) -> None:
   259→        """
   260→        Record which files a feature has touched.
   261→
   262→        This information is used to prevent parallel features from
   263→        modifying the same files.
   264→
   265→        Args:
   266→            feature_id: ID of the feature
   267→            files: List of file paths (relative to project)
   268→        """
   269→        session = self._get_session()
   270→        try:
   271→            entry = session.query(FeatureGraphEntry).filter(
   272→                FeatureGraphEntry.id == feature_id
   273→            ).first()
   274→
   275→            if entry:
   276→                # Merge with existing files
   277→                existing = set(entry.files_touched.split(",")) if entry.files_touched else set()
   278→                existing.update(files)
   279→                entry.files_touched = ",".join(sorted(existing))
   280→            else:
   281→                entry = FeatureGraphEntry(
   282→                    id=feature_id,
   283→                    files_touched=",".join(sorted(files)),
   284→                )
   285→                session.add(entry)
   286→
   287→            session.commit()
   288→            logger.debug(f"Recorded files for feature {feature_id}: {files}")
   289→
   290→        finally:
   291→            session.close()
   292→
   293→    def predict_files_for_feature(
   294→        self,
   295→        feature_id: int,
   296→        category: str,
   297→        name: str,
   298→    ) -> list[str]:
   299→        """
   300→        Predict which files a feature might touch based on its category and name.
   301→
   302→        This is a heuristic to help with initial parallel scheduling before
   303→        the feature has actually been worked on.
   304→
   305→        Args:
   306→            feature_id: ID of the feature
   307→            category: Feature category
   308→            name: Feature name
   309→
   310→        Returns:
   311→            List of predicted file paths
   312→        """
   313→        predicted = []
   314→
   315→        # Simple heuristics based on common patterns
   316→        category_lower = category.lower()
   317→        name_lower = name.lower()
   318→
   319→        # UI/Frontend patterns
   320→        if any(k in category_lower for k in ["ui", "frontend", "component", "page", "view"]):
   321→            predicted.extend([
   322→                "src/components/",
   323→                "src/pages/",
   324→                "src/styles/",
   325→            ])
   326→
   327→        # API/Backend patterns
   328→        if any(k in category_lower for k in ["api", "backend", "server", "route", "endpoint"]):
   329→            predicted.extend([
   330→                "src/api/",
   331→                "src/routes/",
   332→                "src/controllers/",
   333→                "server/",
   334→            ])
   335→
   336→        # Database patterns
   337→        if any(k in category_lower for k in ["database", "model", "schema", "migration"]):
   338→            predicted.extend([
   339→                "src/models/",
   340→                "src/database/",
   341→                "migrations/",
   342→            ])
   343→
   344→        # Auth patterns
   345→        if any(k in name_lower for k in ["auth", "login", "signup", "password", "session"]):
   346→            predicted.extend([
   347→                "src/auth/",
   348→                "src/middleware/",
   349→            ])
   350→
   351→        # Test patterns
   352→        if any(k in category_lower for k in ["test", "spec"]):
   353→            predicted.extend([
   354→                "tests/",
   355→                "__tests__/",
   356→                "*.test.*",
   357→                "*.spec.*",
   358→            ])
   359→
   360→        # Config patterns
   361→        if any(k in category_lower for k in ["config", "setup", "init"]):
   362→            predicted.extend([
   363→                "config/",
   364→                "*.config.*",
   365→            ])
   366→
   367→        return predicted
   368→
   369→    def set_estimated_complexity(
   370→        self,
   371→        feature_id: int,
   372→        complexity: int,
   373→    ) -> None:
   374→        """
   375→        Set the estimated complexity for a feature.
   376→
   377→        Args:
   378→            feature_id: ID of the feature
   379→            complexity: Complexity score (1-5)
   380→        """
   381→        complexity = max(1, min(5, complexity))
   382→
   383→        session = self._get_session()
   384→        try:
   385→            entry = session.query(FeatureGraphEntry).filter(
   386→                FeatureGraphEntry.id == feature_id
   387→            ).first()
   388→
   389→            if entry:
   390→                entry.estimated_complexity = complexity
   391→            else:
   392→                entry = FeatureGraphEntry(
   393→                    id=feature_id,
   394→                    estimated_complexity=complexity,
   395→                )
   396→                session.add(entry)
   397→
   398→            session.commit()
   399→
   400→        finally:
   401→            session.close()
   402→
   403→    def get_feature_conflicts(
   404→        self,
   405→        feature_id: int,
   406→    ) -> list[FeatureConflict]:
   407→        """
   408→        Get all conflicts for a feature.
   409→
   410→        Args:
   411→            feature_id: ID of the feature to check
   412→
   413→        Returns:
   414→            List of conflicts with other features
   415→        """
   416→        session = self._get_session()
   417→        try:
   418→            # Get this feature's files
   419→            entry = session.query(FeatureGraphEntry).filter(
   420→                FeatureGraphEntry.id == feature_id
   421→            ).first()
   422→
   423→            if not entry or not entry.files_touched:
   424→                return []
   425→
   426→            feature_files = set(entry.files_touched.split(","))
   427→
   428→            # Check against all other features
   429→            conflicts = []
   430→            all_entries = session.query(FeatureGraphEntry).filter(
   431→                FeatureGraphEntry.id != feature_id
   432→            ).all()
   433→
   434→            for other in all_entries:
   435→                if not other.files_touched:
   436→                    continue
   437→
   438→                other_files = set(other.files_touched.split(","))
   439→                common_files = feature_files & other_files
   440→
   441→                if common_files:
   442→                    conflicts.append(FeatureConflict(
   443→                        feature_id=feature_id,
   444→                        conflicting_feature_id=other.id,
   445→                        reason="Shared files",
   446→                        conflicting_files=list(common_files),
   447→                    ))
   448→
   449→            return conflicts
   450→
   451→        finally:
   452→            session.close()
   453→
   454→    def clear_feature_data(self, feature_id: int) -> None:
   455→        """
   456→        Clear all graph data for a feature (when it's completed).
   457→
   458→        Args:
   459→            feature_id: ID of the feature
   460→        """
   461→        session = self._get_session()
   462→        try:
   463→            session.query(FeatureGraphEntry).filter(
   464→                FeatureGraphEntry.id == feature_id
   465→            ).delete()
   466→            session.commit()
   467→
   468→        finally:
   469→            session.close()
   470→
   471→    def get_stats(self) -> dict:
   472→        """Get statistics about the feature graph."""
   473→        session = self._get_session()
   474→        try:
   475→            total_entries = session.query(FeatureGraphEntry).count()
   476→            total_locks = session.query(FileLockEntry).count()
   477→
   478→            # Get unique files tracked
   479→            all_files = set()
   480→            entries = session.query(FeatureGraphEntry).all()
   481→            for entry in entries:
   482→                if entry.files_touched:
   483→                    all_files.update(entry.files_touched.split(","))
   484→
   485→            return {
   486→                "tracked_features": total_entries,
   487→                "active_locks": total_locks,
   488→                "unique_files_tracked": len(all_files),
   489→            }
   490→
   491→        finally:
   492→            session.close()
   493→

<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>

"""
Feature Dependency Graph
========================

Track feature dependencies and determine which features can be
executed in parallel without conflicts.
"""

import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

from sqlalchemy import Column, Integer, String, ForeignKey, Table, create_engine, text
from sqlalchemy.orm import sessionmaker, relationship
from sqlalchemy.ext.declarative import declarative_base

logger = logging.getLogger(__name__)

Base = declarative_base()

# Association table for feature dependencies (many-to-many)
feature_dependencies = Table(
    'feature_dependencies',
    Base.metadata,
    Column('feature_id', Integer, ForeignKey('feature_graph.id'), primary_key=True),
    Column('depends_on_id', Integer, primary_key=True),  # References features.id in main db
)

class FeatureGraphEntry(Base):
    """Track metadata about features for parallel execution."""

    __tablename__ = "feature_graph"

    id = Column(Integer, primary_key=True)  # Same as feature.id
    files_touched = Column(String(4000), default="")  # Comma-separated file paths
    estimated_complexity = Column(Integer, default=1)  # 1-5 scale

class FileLockEntry(Base):
    """Track which files are currently locked by which feature."""

    __tablename__ = "file_locks"

    id = Column(Integer, primary_key=True, autoincrement=True)
    file_path = Column(String(500), unique=True, nullable=False, index=True)
    feature_id = Column(Integer, nullable=False)
    locked_at = Column(String(50))  # ISO timestamp

@dataclass
class ParallelFeature:
    """A feature that can be worked on in parallel."""
    id: int
    priority: int
    category: str
    name: str
    description: str
    steps: list[str]
    estimated_complexity: int = 1

@dataclass
class FeatureConflict:
    """Describes why two features cannot run in parallel."""
    feature_id: int
    conflicting_feature_id: int
    reason: str
    conflicting_files: list[str] = field(default_factory=list)

class FeatureGraph:
    """
    Track feature dependencies and file conflicts for parallel execution.

    This class maintains a separate SQLite database alongside the main
    features.db to track:
    - Which files each feature has touched/will touch
    - Dependencies between features
    - Current file locks

    This enables the orchestrator to determine which features can be
    safely worked on in parallel.
    """

    def __init__(self, project_dir: Path):
        """
        Initialize the feature graph.

        Args:
            project_dir: Path to the project directory
        """
        self.project_dir = project_dir
        self.db_path = project_dir / "feature_graph.db"
        self.db_url = f"sqlite:///{self.db_path.as_posix()}"

        # Create database
        self.engine = create_engine(
            self.db_url,
            connect_args={"check_same_thread": False}
        )
        Base.metadata.create_all(bind=self.engine)
        self.SessionLocal = sessionmaker(
            autocommit=False,
            autoflush=False,
            bind=self.engine
        )

        # Also connect to the main features database
        self.features_db_path = project_dir / "features.db"
        self.features_db_url = f"sqlite:///{self.features_db_path.as_posix()}"

    def _get_session(self):
        """Get a database session."""
        return self.SessionLocal()

    def _get_features_from_main_db(self) -> list[dict]:
        """Get all pending features from the main features database."""
        if not self.features_db_path.exists():
            return []

        features_engine = create_engine(
            self.features_db_url,
            connect_args={"check_same_thread": False}
        )

        with features_engine.connect() as conn:
            result = conn.execute(text("""
                SELECT id, priority, category, name, description, steps, in_progress
                FROM features
                WHERE passes = 0
                ORDER BY priority ASC, id ASC
            """))

            features = []
            for row in result.fetchall():
                # Parse steps JSON
                import json
                steps = row[5]
                if isinstance(steps, str):
                    steps = json.loads(steps)

                features.append({
                    "id": row[0],
                    "priority": row[1],
                    "category": row[2],
                    "name": row[3],
                    "description": row[4],
                    "steps": steps,
                    "in_progress": row[6],
                })

            return features

    def get_parallelizable_features(
        self,
        count: int = 3,
        exclude_in_progress: bool = True,
    ) -> list[ParallelFeature]:
        """
        Get features that can be worked on in parallel.

        Features are parallelizable if:
        1. They don't have unsatisfied dependencies
        2. They don't touch the same files as currently in-progress features
        3. They're not already in progress (if exclude_in_progress=True)

        Args:
            count: Maximum number of features to return
            exclude_in_progress: Whether to exclude features already in progress

        Returns:
            List of features that can be worked on in parallel
        """
        features = self._get_features_from_main_db()

        if not features:
            return []

        session = self._get_session()
        try:
            # Get currently locked files
            locked_files = set()
            locks = session.query(FileLockEntry).all()
            for lock in locks:
                locked_files.add(lock.file_path)

            # Get in-progress feature IDs
            in_progress_ids = set()
            if exclude_in_progress:
                for f in features:
                    if f.get("in_progress"):
                        in_progress_ids.add(f["id"])

            # Filter and score features
            parallelizable = []
            selected_files = set()  # Files that will be touched by selected features

            for feature in features:
                # Skip if already in progress
                if feature["id"] in in_progress_ids:
                    continue

                # Get files this feature might touch
                graph_entry = session.query(FeatureGraphEntry).filter(
                    FeatureGraphEntry.id == feature["id"]
                ).first()

                feature_files = set()
                if graph_entry and graph_entry.files_touched:
                    feature_files = set(graph_entry.files_touched.split(","))

                # Check for conflicts with locked files
                if feature_files & locked_files:
                    logger.debug(
                        f"Feature {feature['id']} conflicts with locked files: "
                        f"{feature_files & locked_files}"
                    )
                    continue

                # Check for conflicts with already selected features
                if feature_files & selected_files:
                    logger.debug(
                        f"Feature {feature['id']} conflicts with selected features"
                    )
                    continue

                # This feature is parallelizable
                complexity = graph_entry.estimated_complexity if graph_entry else 1

                parallelizable.append(ParallelFeature(
                    id=feature["id"],
                    priority=feature["priority"],
                    category=feature["category"],
                    name=feature["name"],
                    description=feature["description"],
                    steps=feature["steps"],
                    estimated_complexity=complexity,
                ))

                # Track files this feature will touch
                selected_files.update(feature_files)

                if len(parallelizable) >= count:
                    break

            return parallelizable

        finally:
            session.close()

    def record_files_touched(
        self,
        feature_id: int,
        files: list[str],
    ) -> None:
        """
        Record which files a feature has touched.

        This information is used to prevent parallel features from
        modifying the same files.

        Args:
            feature_id: ID of the feature
            files: List of file paths (relative to project)
        """
        session = self._get_session()
        try:
            entry = session.query(FeatureGraphEntry).filter(
                FeatureGraphEntry.id == feature_id
            ).first()

            if entry:
                # Merge with existing files
                existing = set(entry.files_touched.split(",")) if entry.files_touched else set()
                existing.update(files)
                entry.files_touched = ",".join(sorted(existing))
            else:
                entry = FeatureGraphEntry(
                    id=feature_id,
                    files_touched=",".join(sorted(files)),
                )
                session.add(entry)

            session.commit()
            logger.debug(f"Recorded files for feature {feature_id}: {files}")

        finally:
            session.close()

    def predict_files_for_feature(
        self,
        feature_id: int,
        category: str,
        name: str,
    ) -> list[str]:
        """
        Predict which files a feature might touch based on its category and name.

        This is a heuristic to help with initial parallel scheduling before
        the feature has actually been worked on.

        Args:
            feature_id: ID of the feature
            category: Feature category
            name: Feature name

        Returns:
            List of predicted file paths
        """
        predicted = []

        # Simple heuristics based on common patterns
        category_lower = category.lower()
        name_lower = name.lower()

        # UI/Frontend patterns
        if any(k in category_lower for k in ["ui", "frontend", "component", "page", "view"]):
            predicted.extend([
                "src/components/",
                "src/pages/",
                "src/styles/",
            ])

        # API/Backend patterns
        if any(k in category_lower for k in ["api", "backend", "server", "route", "endpoint"]):
            predicted.extend([
                "src/api/",
                "src/routes/",
                "src/controllers/",
                "server/",
            ])

        # Database patterns
        if any(k in category_lower for k in ["database", "model", "schema", "migration"]):
            predicted.extend([
                "src/models/",
                "src/database/",
                "migrations/",
            ])

        # Auth patterns
        if any(k in name_lower for k in ["auth", "login", "signup", "password", "session"]):
            predicted.extend([
                "src/auth/",
                "src/middleware/",
            ])

        # Test patterns
        if any(k in category_lower for k in ["test", "spec"]):
            predicted.extend([
                "tests/",
                "__tests__/",
                "*.test.*",
                "*.spec.*",
            ])

        # Config patterns
        if any(k in category_lower for k in ["config", "setup", "init"]):
            predicted.extend([
                "config/",
                "*.config.*",
            ])

        return predicted

    def set_estimated_complexity(
        self,
        feature_id: int,
        complexity: int,
    ) -> None:
        """
        Set the estimated complexity for a feature.

        Args:
            feature_id: ID of the feature
            complexity: Complexity score (1-5)
        """
        complexity = max(1, min(5, complexity))

        session = self._get_session()
        try:
            entry = session.query(FeatureGraphEntry).filter(
                FeatureGraphEntry.id == feature_id
            ).first()

            if entry:
                entry.estimated_complexity = complexity
            else:
                entry = FeatureGraphEntry(
                    id=feature_id,
                    estimated_complexity=complexity,
                )
                session.add(entry)

            session.commit()

        finally:
            session.close()

    def get_feature_conflicts(
        self,
        feature_id: int,
    ) -> list[FeatureConflict]:
        """
        Get all conflicts for a feature.

        Args:
            feature_id: ID of the feature to check

        Returns:
            List of conflicts with other features
        """
        session = self._get_session()
        try:
            # Get this feature's files
            entry = session.query(FeatureGraphEntry).filter(
                FeatureGraphEntry.id == feature_id
            ).first()

            if not entry or not entry.files_touched:
                return []

            feature_files = set(entry.files_touched.split(","))

            # Check against all other features
            conflicts = []
            all_entries = session.query(FeatureGraphEntry).filter(
                FeatureGraphEntry.id != feature_id
            ).all()

            for other in all_entries:
                if not other.files_touched:
                    continue

                other_files = set(other.files_touched.split(","))
                common_files = feature_files & other_files

                if common_files:
                    conflicts.append(FeatureConflict(
                        feature_id=feature_id,
                        conflicting_feature_id=other.id,
                        reason="Shared files",
                        conflicting_files=list(common_files),
                    ))

            return conflicts

        finally:
            session.close()

    def clear_feature_data(self, feature_id: int) -> None:
        """
        Clear all graph data for a feature (when it's completed).

        Args:
            feature_id: ID of the feature
        """
        session = self._get_session()
        try:
            session.query(FeatureGraphEntry).filter(
                FeatureGraphEntry.id == feature_id
            ).delete()
            session.commit()

        finally:
            session.close()

    def get_stats(self) -> dict:
        """Get statistics about the feature graph."""
        session = self._get_session()
        try:
            total_entries = session.query(FeatureGraphEntry).count()
            total_locks = session.query(FileLockEntry).count()

            # Get unique files tracked
            all_files = set()
            entries = session.query(FeatureGraphEntry).all()
            for entry in entries:
                if entry.files_touched:
                    all_files.update(entry.files_touched.split(","))

            return {
                "tracked_features": total_entries,
                "active_locks": total_locks,
                "unique_files_tracked": len(all_files),
            }

        finally:
            session.close()


Now let me check some more files to understand feature tracking and session boundaries:


Bash

grep -r "feature_mark_passing\|on_feature_complete\|session.*end\|session.*start" "/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0" --include="*.py" | head -30


/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/api/database.py:# Global session maker - will be set when server starts
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/client.py:    "mcp__features__feature_mark_passing",
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/config.py:    push_on: list[str] = field(default_factory=lambda: ["feature_complete", "session_end"])
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/mcp_server/feature_mcp.py:- feature_mark_passing: Mark a feature as passing
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/mcp_server/feature_mcp.py:# Global database session maker (initialized on startup)
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/mcp_server/feature_mcp.py:def feature_mark_passing(
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py:        on_feature_complete: Optional[Callable[[AgentResult], Any]] = None,
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py:            on_feature_complete: Callback when a feature completes
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py:        self._on_feature_complete = on_feature_complete
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py:            await self.git_agent.on_feature_complete(feature.id, feature.name)
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py:                        if self._on_feature_complete:
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py:                            self._on_feature_complete(result)
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/parallel_agent.py:            await self.git_agent.on_session_end()
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/prompts.py:        "_Resources available to this project (verified at session start)_",
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:    push_on: list[str] = field(default_factory=lambda: ["feature_complete", "session_end"])
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:    async def on_feature_complete(
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:    async def on_session_end(self) -> Optional[GitResult]:
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:        Handle session end - commit any remaining changes.
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:        Called when the autocoder session ends to ensure no work is lost.
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:                return await self.on_feature_complete(feature_id)
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:                message = self._generate_session_end_message(self.change_log)
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:                    if self.config.auto_push and "session_end" in self.config.push_on:
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:    def _generate_session_end_message(self, changes: list[ChangeEntry]) -> str:
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/agents/git_agent.py:        """Generate a commit message for session end."""
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/assistant_chat.py:                        async for chunk in session.start():
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/assistant_chat.py:                                "content": "No active session. Send 'start' first."
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/assistant_chat.py:                    async for chunk in session.send_message(user_content):
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/spec_creation.py:                    async for chunk in session.start():
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/spec_creation.py:                                "content": "No active session. Send 'start' first."
/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/spec_creation.py:                    async for chunk in session.send_message(user_content, attachments if attachments else None):


Let me read the feature_mark_passing implementation and the parallel agent: