     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


Now let me check the assistant database for session tracking patterns and look at hooks used in the Claude SDK:


     1→"""
     2→Assistant Database
     3→==================
     4→
     5→SQLAlchemy models and functions for persisting assistant conversations.
     6→Each project has its own assistant.db file in the project directory.
     7→"""
     8→
     9→import logging
    10→from datetime import datetime
    11→from pathlib import Path
    12→from typing import Optional
    13→
    14→from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine
    15→from sqlalchemy.orm import declarative_base, relationship, sessionmaker
    16→
    17→logger = logging.getLogger(__name__)
    18→
    19→Base = declarative_base()
    20→
    21→
    22→class Conversation(Base):
    23→    """A conversation with the assistant for a project."""
    24→    __tablename__ = "conversations"
    25→
    26→    id = Column(Integer, primary_key=True, index=True)
    27→    project_name = Column(String(100), nullable=False, index=True)
    28→    title = Column(String(200), nullable=True)  # Optional title, derived from first message
    29→    created_at = Column(DateTime, default=datetime.utcnow)
    30→    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    31→
    32→    messages = relationship("ConversationMessage", back_populates="conversation", cascade="all, delete-orphan")
    33→
    34→
    35→class ConversationMessage(Base):
    36→    """A single message within a conversation."""
    37→    __tablename__ = "conversation_messages"
    38→
    39→    id = Column(Integer, primary_key=True, index=True)
    40→    conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True)
    41→    role = Column(String(20), nullable=False)  # "user" | "assistant" | "system"
    42→    content = Column(Text, nullable=False)
    43→    timestamp = Column(DateTime, default=datetime.utcnow)
    44→
    45→    conversation = relationship("Conversation", back_populates="messages")
    46→
    47→
    48→def get_db_path(project_dir: Path) -> Path:
    49→    """Get the path to the assistant database for a project."""
    50→    return project_dir / "assistant.db"
    51→
    52→
    53→def get_engine(project_dir: Path):
    54→    """Get or create a SQLAlchemy engine for a project's assistant database."""
    55→    db_path = get_db_path(project_dir)
    56→    # Use as_posix() for cross-platform compatibility with SQLite connection strings
    57→    db_url = f"sqlite:///{db_path.as_posix()}"
    58→    engine = create_engine(db_url, echo=False)
    59→    Base.metadata.create_all(engine)
    60→    return engine
    61→
    62→
    63→def get_session(project_dir: Path):
    64→    """Get a new database session for a project."""
    65→    engine = get_engine(project_dir)
    66→    Session = sessionmaker(bind=engine)
    67→    return Session()
    68→
    69→
    70→# ============================================================================
    71→# Conversation Operations
    72→# ============================================================================
    73→
    74→def create_conversation(project_dir: Path, project_name: str, title: Optional[str] = None) -> Conversation:
    75→    """Create a new conversation for a project."""
    76→    session = get_session(project_dir)
    77→    try:
    78→        conversation = Conversation(
    79→            project_name=project_name,
    80→            title=title,
    81→        )
    82→        session.add(conversation)
    83→        session.commit()
    84→        session.refresh(conversation)
    85→        logger.info(f"Created conversation {conversation.id} for project {project_name}")
    86→        return conversation
    87→    finally:
    88→        session.close()
    89→
    90→
    91→def get_conversations(project_dir: Path, project_name: str) -> list[dict]:
    92→    """Get all conversations for a project with message counts."""
    93→    session = get_session(project_dir)
    94→    try:
    95→        conversations = (
    96→            session.query(Conversation)
    97→            .filter(Conversation.project_name == project_name)
    98→            .order_by(Conversation.updated_at.desc())
    99→            .all()
   100→        )
   101→        return [
   102→            {
   103→                "id": c.id,
   104→                "project_name": c.project_name,
   105→                "title": c.title,
   106→                "created_at": c.created_at.isoformat() if c.created_at else None,
   107→                "updated_at": c.updated_at.isoformat() if c.updated_at else None,
   108→                "message_count": len(c.messages),
   109→            }
   110→            for c in conversations
   111→        ]
   112→    finally:
   113→        session.close()
   114→
   115→
   116→def get_conversation(project_dir: Path, conversation_id: int) -> Optional[dict]:
   117→    """Get a conversation with all its messages."""
   118→    session = get_session(project_dir)
   119→    try:
   120→        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
   121→        if not conversation:
   122→            return None
   123→        return {
   124→            "id": conversation.id,
   125→            "project_name": conversation.project_name,
   126→            "title": conversation.title,
   127→            "created_at": conversation.created_at.isoformat() if conversation.created_at else None,
   128→            "updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
   129→            "messages": [
   130→                {
   131→                    "id": m.id,
   132→                    "role": m.role,
   133→                    "content": m.content,
   134→                    "timestamp": m.timestamp.isoformat() if m.timestamp else None,
   135→                }
   136→                for m in sorted(conversation.messages, key=lambda x: x.timestamp or datetime.min)
   137→            ],
   138→        }
   139→    finally:
   140→        session.close()
   141→
   142→
   143→def delete_conversation(project_dir: Path, conversation_id: int) -> bool:
   144→    """Delete a conversation and all its messages."""
   145→    session = get_session(project_dir)
   146→    try:
   147→        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
   148→        if not conversation:
   149→            return False
   150→        session.delete(conversation)
   151→        session.commit()
   152→        logger.info(f"Deleted conversation {conversation_id}")
   153→        return True
   154→    finally:
   155→        session.close()
   156→
   157→
   158→# ============================================================================
   159→# Message Operations
   160→# ============================================================================
   161→
   162→def add_message(project_dir: Path, conversation_id: int, role: str, content: str) -> Optional[dict]:
   163→    """Add a message to a conversation."""
   164→    session = get_session(project_dir)
   165→    try:
   166→        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
   167→        if not conversation:
   168→            return None
   169→
   170→        message = ConversationMessage(
   171→            conversation_id=conversation_id,
   172→            role=role,
   173→            content=content,
   174→        )
   175→        session.add(message)
   176→
   177→        # Update conversation's updated_at timestamp
   178→        conversation.updated_at = datetime.utcnow()
   179→
   180→        # Auto-generate title from first user message if not set
   181→        if not conversation.title and role == "user":
   182→            # Take first 50 chars of first user message as title
   183→            conversation.title = content[:50] + ("..." if len(content) > 50 else "")
   184→
   185→        session.commit()
   186→        session.refresh(message)
   187→
   188→        logger.debug(f"Added {role} message to conversation {conversation_id}")
   189→        return {
   190→            "id": message.id,
   191→            "role": message.role,
   192→            "content": message.content,
   193→            "timestamp": message.timestamp.isoformat() if message.timestamp else None,
   194→        }
   195→    finally:
   196→        session.close()
   197→
   198→
   199→def get_messages(project_dir: Path, conversation_id: int) -> list[dict]:
   200→    """Get all messages for a conversation."""
   201→    session = get_session(project_dir)
   202→    try:
   203→        messages = (
   204→            session.query(ConversationMessage)
   205→            .filter(ConversationMessage.conversation_id == conversation_id)
   206→            .order_by(ConversationMessage.timestamp.asc())
   207→            .all()
   208→        )
   209→        return [
   210→            {
   211→                "id": m.id,
   212→                "role": m.role,
   213→                "content": m.content,
   214→                "timestamp": m.timestamp.isoformat() if m.timestamp else None,
   215→            }
   216→            for m in messages
   217→        ]
   218→    finally:
   219→        session.close()
   220→

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

"""
Assistant Database
==================

SQLAlchemy models and functions for persisting assistant conversations.
Each project has its own assistant.db file in the project directory.
"""

import logging
from datetime import datetime
from pathlib import Path
from typing import Optional

from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine
from sqlalchemy.orm import declarative_base, relationship, sessionmaker

logger = logging.getLogger(__name__)

Base = declarative_base()

class Conversation(Base):
    """A conversation with the assistant for a project."""
    __tablename__ = "conversations"

    id = Column(Integer, primary_key=True, index=True)
    project_name = Column(String(100), nullable=False, index=True)
    title = Column(String(200), nullable=True)  # Optional title, derived from first message
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

    messages = relationship("ConversationMessage", back_populates="conversation", cascade="all, delete-orphan")

class ConversationMessage(Base):
    """A single message within a conversation."""
    __tablename__ = "conversation_messages"

    id = Column(Integer, primary_key=True, index=True)
    conversation_id = Column(Integer, ForeignKey("conversations.id"), nullable=False, index=True)
    role = Column(String(20), nullable=False)  # "user" | "assistant" | "system"
    content = Column(Text, nullable=False)
    timestamp = Column(DateTime, default=datetime.utcnow)

    conversation = relationship("Conversation", back_populates="messages")

def get_db_path(project_dir: Path) -> Path:
    """Get the path to the assistant database for a project."""
    return project_dir / "assistant.db"

def get_engine(project_dir: Path):
    """Get or create a SQLAlchemy engine for a project's assistant database."""
    db_path = get_db_path(project_dir)
    # Use as_posix() for cross-platform compatibility with SQLite connection strings
    db_url = f"sqlite:///{db_path.as_posix()}"
    engine = create_engine(db_url, echo=False)
    Base.metadata.create_all(engine)
    return engine

def get_session(project_dir: Path):
    """Get a new database session for a project."""
    engine = get_engine(project_dir)
    Session = sessionmaker(bind=engine)
    return Session()

# ============================================================================
# Conversation Operations
# ============================================================================

def create_conversation(project_dir: Path, project_name: str, title: Optional[str] = None) -> Conversation:
    """Create a new conversation for a project."""
    session = get_session(project_dir)
    try:
        conversation = Conversation(
            project_name=project_name,
            title=title,
        )
        session.add(conversation)
        session.commit()
        session.refresh(conversation)
        logger.info(f"Created conversation {conversation.id} for project {project_name}")
        return conversation
    finally:
        session.close()

def get_conversations(project_dir: Path, project_name: str) -> list[dict]:
    """Get all conversations for a project with message counts."""
    session = get_session(project_dir)
    try:
        conversations = (
            session.query(Conversation)
            .filter(Conversation.project_name == project_name)
            .order_by(Conversation.updated_at.desc())
            .all()
        )
        return [
            {
                "id": c.id,
                "project_name": c.project_name,
                "title": c.title,
                "created_at": c.created_at.isoformat() if c.created_at else None,
                "updated_at": c.updated_at.isoformat() if c.updated_at else None,
                "message_count": len(c.messages),
            }
            for c in conversations
        ]
    finally:
        session.close()

def get_conversation(project_dir: Path, conversation_id: int) -> Optional[dict]:
    """Get a conversation with all its messages."""
    session = get_session(project_dir)
    try:
        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
        if not conversation:
            return None
        return {
            "id": conversation.id,
            "project_name": conversation.project_name,
            "title": conversation.title,
            "created_at": conversation.created_at.isoformat() if conversation.created_at else None,
            "updated_at": conversation.updated_at.isoformat() if conversation.updated_at else None,
            "messages": [
                {
                    "id": m.id,
                    "role": m.role,
                    "content": m.content,
                    "timestamp": m.timestamp.isoformat() if m.timestamp else None,
                }
                for m in sorted(conversation.messages, key=lambda x: x.timestamp or datetime.min)
            ],
        }
    finally:
        session.close()

def delete_conversation(project_dir: Path, conversation_id: int) -> bool:
    """Delete a conversation and all its messages."""
    session = get_session(project_dir)
    try:
        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
        if not conversation:
            return False
        session.delete(conversation)
        session.commit()
        logger.info(f"Deleted conversation {conversation_id}")
        return True
    finally:
        session.close()

# ============================================================================
# Message Operations
# ============================================================================

def add_message(project_dir: Path, conversation_id: int, role: str, content: str) -> Optional[dict]:
    """Add a message to a conversation."""
    session = get_session(project_dir)
    try:
        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
        if not conversation:
            return None

        message = ConversationMessage(
            conversation_id=conversation_id,
            role=role,
            content=content,
        )
        session.add(message)

        # Update conversation's updated_at timestamp
        conversation.updated_at = datetime.utcnow()

        # Auto-generate title from first user message if not set
        if not conversation.title and role == "user":
            # Take first 50 chars of first user message as title
            conversation.title = content[:50] + ("..." if len(content) > 50 else "")

        session.commit()
        session.refresh(message)

        logger.debug(f"Added {role} message to conversation {conversation_id}")
        return {
            "id": message.id,
            "role": message.role,
            "content": message.content,
            "timestamp": message.timestamp.isoformat() if message.timestamp else None,
        }
    finally:
        session.close()

def get_messages(project_dir: Path, conversation_id: int) -> list[dict]:
    """Get all messages for a conversation."""
    session = get_session(project_dir)
    try:
        messages = (
            session.query(ConversationMessage)
            .filter(ConversationMessage.conversation_id == conversation_id)
            .order_by(ConversationMessage.timestamp.asc())
            .all()
        )
        return [
            {
                "id": m.id,
                "role": m.role,
                "content": m.content,
                "timestamp": m.timestamp.isoformat() if m.timestamp else None,
            }
            for m in messages
        ]
    finally:
        session.close()


C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\agent.py:55:        # Send the query
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\agent.py:56:        await client.query(message)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\agent.py:60:        async for msg in client.receive_response():
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\client.py:193:                "PreToolUse": [
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\api\migration.py:47:        existing_count = session.query(Feature).count()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\api\migration.py:92:        final_count = session.query(Feature).count()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\api\migration.py:140:            session.query(Feature)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:129:        total = session.query(Feature).count()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:130:        passing = session.query(Feature).filter(Feature.passes == True).count()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:131:        in_progress = session.query(Feature).filter(Feature.in_progress == True).count()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:158:            session.query(Feature)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:191:            session.query(Feature)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:223:        feature = session.query(Feature).filter(Feature.id == feature_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:261:        feature = session.query(Feature).filter(Feature.id == feature_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:272:        max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:308:        feature = session.query(Feature).filter(Feature.id == feature_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:345:        feature = session.query(Feature).filter(Feature.id == feature_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py:384:        max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:170:        existing = session.query(Project).filter(Project.name == name).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:196:        project = session.query(Project).filter(Project.name == name).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:220:        project = session.query(Project).filter(Project.name == name).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:238:        projects = session.query(Project).all()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:263:        project = session.query(Project).filter(Project.name == name).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:288:        project = session.query(Project).filter(Project.name == name).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:342:        projects = session.query(Project).all()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:365:        projects = session.query(Project).all()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:417:        project = session.query(Project).filter(Project.name == project_name).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:422:        existing = session.query(ReferenceFolder).filter(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:455:        ref = session.query(ReferenceFolder).filter(ReferenceFolder.id == reference_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:477:        refs = session.query(ReferenceFolder).filter(
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:508:        ref = session.query(ReferenceFolder).filter(ReferenceFolder.id == reference_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\registry.py:541:        ref = session.query(ReferenceFolder).filter(ReferenceFolder.id == reference_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\parallel_agent.py:183:                await client.query(prompt)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\parallel_agent.py:185:                async for msg in client.receive_response():
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\parallel_agent.py:444:            await client.query(prompt)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\parallel_agent.py:446:            async for msg in client.receive_response():
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:248:            async for chunk in self._query_claude(user_message):
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:252:            logger.exception("Error during Claude query")
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:255:    async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:257:        Internal method to query Claude and stream responses.
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:265:        await self.client.query(message)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_chat_session.py:270:        async for msg in self.client.receive_response():
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\credential_resolver.py:320:    def search(self, query: str) -> list[dict[str, Any]]:
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\credential_resolver.py:325:            query: Search term (matches service names, hosts, URLs)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\credential_resolver.py:331:        query_lower = query.lower()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\credential_resolver.py:336:            if query_lower in service_name:
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\credential_resolver.py:342:                if isinstance(value, str) and query_lower in value.lower():
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_database.py:96:            session.query(Conversation)
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_database.py:120:        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()
C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\assistant_database.py:147:        conversation = session.query(Conversation).filter(Conversation.id == conversation_id).first()

[Showing results with pagination = limit: 50, offset: 0]


Let me check the schema and see what API endpoints already exist:


     1→"""
     2→Pydantic Schemas
     3→================
     4→
     5→Request/Response models for the API endpoints.
     6→"""
     7→
     8→import base64
     9→from datetime import datetime
    10→from typing import Literal
    11→
    12→from pydantic import BaseModel, Field, field_validator
    13→
    14→# ============================================================================
    15→# Project Schemas
    16→# ============================================================================
    17→
    18→class ProjectCreate(BaseModel):
    19→    """Request schema for creating a new project."""
    20→    name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
    21→    path: str = Field(..., min_length=1, description="Absolute path to project directory")
    22→    spec_method: Literal["claude", "manual"] = "claude"
    23→
    24→
    25→class ProjectStats(BaseModel):
    26→    """Project statistics."""
    27→    passing: int = 0
    28→    in_progress: int = 0
    29→    total: int = 0
    30→    percentage: float = 0.0
    31→
    32→
    33→class ProjectSummary(BaseModel):
    34→    """Summary of a project for list view."""
    35→    name: str
    36→    path: str
    37→    has_spec: bool
    38→    stats: ProjectStats
    39→
    40→
    41→class ProjectDetail(BaseModel):
    42→    """Detailed project information."""
    43→    name: str
    44→    path: str
    45→    has_spec: bool
    46→    stats: ProjectStats
    47→    prompts_dir: str
    48→
    49→
    50→class ProjectPrompts(BaseModel):
    51→    """Project prompt files content."""
    52→    app_spec: str = ""
    53→    initializer_prompt: str = ""
    54→    coding_prompt: str = ""
    55→
    56→
    57→class ProjectPromptsUpdate(BaseModel):
    58→    """Request schema for updating project prompts."""
    59→    app_spec: str | None = None
    60→    initializer_prompt: str | None = None
    61→    coding_prompt: str | None = None
    62→
    63→
    64→class ProjectImport(BaseModel):
    65→    """Request schema for importing an existing project."""
    66→    name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
    67→    path: str = Field(..., min_length=1, description="Absolute path to existing project")
    68→
    69→
    70→class CodebaseAnalysis(BaseModel):
    71→    """Analysis of an existing codebase."""
    72→    total_files: int = 0
    73→    file_types: dict[str, int] = {}
    74→    detected_stack: dict[str, str] = {}
    75→    has_claude_md: bool = False
    76→    claude_md_content: str | None = None
    77→    has_readme: bool = False
    78→    readme_content: str | None = None
    79→    directories: list[str] = []
    80→    entry_points: list[str] = []
    81→    project_name: str | None = None
    82→
    83→
    84→class ProjectImportResponse(BaseModel):
    85→    """Response for project import."""
    86→    project: ProjectSummary
    87→    analysis: CodebaseAnalysis
    88→    is_import: bool = True
    89→
    90→
    91→# ============================================================================
    92→# Feature Schemas
    93→# ============================================================================
    94→
    95→class FeatureBase(BaseModel):
    96→    """Base feature attributes."""
    97→    category: str
    98→    name: str
    99→    description: str
   100→    steps: list[str]
   101→
   102→
   103→class FeatureCreate(FeatureBase):
   104→    """Request schema for creating a new feature."""
   105→    priority: int | None = None
   106→
   107→
   108→class FeatureResponse(FeatureBase):
   109→    """Response schema for a feature."""
   110→    id: int
   111→    priority: int
   112→    passes: bool
   113→    in_progress: bool
   114→
   115→    class Config:
   116→        from_attributes = True
   117→
   118→
   119→class FeatureListResponse(BaseModel):
   120→    """Response containing list of features organized by status."""
   121→    pending: list[FeatureResponse]
   122→    in_progress: list[FeatureResponse]
   123→    done: list[FeatureResponse]
   124→
   125→
   126→# ============================================================================
   127→# Agent Schemas
   128→# ============================================================================
   129→
   130→class AgentStartRequest(BaseModel):
   131→    """Request schema for starting the agent."""
   132→    yolo_mode: bool = False
   133→
   134→
   135→class AgentStatus(BaseModel):
   136→    """Current agent status."""
   137→    status: Literal["stopped", "running", "paused", "crashed"]
   138→    pid: int | None = None
   139→    started_at: datetime | None = None
   140→    yolo_mode: bool = False
   141→
   142→
   143→class AgentActionResponse(BaseModel):
   144→    """Response for agent control actions."""
   145→    success: bool
   146→    status: str
   147→    message: str = ""
   148→
   149→
   150→# ============================================================================
   151→# Setup Schemas
   152→# ============================================================================
   153→
   154→class SetupStatus(BaseModel):
   155→    """System setup status."""
   156→    claude_cli: bool
   157→    credentials: bool
   158→    node: bool
   159→    npm: bool
   160→
   161→
   162→# ============================================================================
   163→# WebSocket Message Schemas
   164→# ============================================================================
   165→
   166→class WSProgressMessage(BaseModel):
   167→    """WebSocket message for progress updates."""
   168→    type: Literal["progress"] = "progress"
   169→    passing: int
   170→    total: int
   171→    percentage: float
   172→
   173→
   174→class WSFeatureUpdateMessage(BaseModel):
   175→    """WebSocket message for feature status updates."""
   176→    type: Literal["feature_update"] = "feature_update"
   177→    feature_id: int
   178→    passes: bool
   179→
   180→
   181→class WSLogMessage(BaseModel):
   182→    """WebSocket message for agent log output."""
   183→    type: Literal["log"] = "log"
   184→    line: str
   185→    timestamp: datetime
   186→
   187→
   188→class WSAgentStatusMessage(BaseModel):
   189→    """WebSocket message for agent status changes."""
   190→    type: Literal["agent_status"] = "agent_status"
   191→    status: str
   192→
   193→
   194→# ============================================================================
   195→# Spec Chat Schemas
   196→# ============================================================================
   197→
   198→# Maximum image file size: 5 MB
   199→MAX_IMAGE_SIZE = 5 * 1024 * 1024
   200→
   201→
   202→class ImageAttachment(BaseModel):
   203→    """Image attachment from client for spec creation chat."""
   204→    filename: str = Field(..., min_length=1, max_length=255)
   205→    mimeType: Literal['image/jpeg', 'image/png']
   206→    base64Data: str
   207→
   208→    @field_validator('base64Data')
   209→    @classmethod
   210→    def validate_base64_and_size(cls, v: str) -> str:
   211→        """Validate that base64 data is valid and within size limit."""
   212→        try:
   213→            decoded = base64.b64decode(v)
   214→            if len(decoded) > MAX_IMAGE_SIZE:
   215→                raise ValueError(
   216→                    f'Image size ({len(decoded) / (1024 * 1024):.1f} MB) exceeds '
   217→                    f'maximum of {MAX_IMAGE_SIZE // (1024 * 1024)} MB'
   218→                )
   219→            return v
   220→        except Exception as e:
   221→            if 'Image size' in str(e):
   222→                raise
   223→            raise ValueError(f'Invalid base64 data: {e}')
   224→
   225→
   226→# ============================================================================
   227→# Filesystem Schemas
   228→# ============================================================================
   229→
   230→class DriveInfo(BaseModel):
   231→    """Information about a drive (Windows only)."""
   232→    letter: str
   233→    label: str
   234→    available: bool = True
   235→
   236→
   237→class DirectoryEntry(BaseModel):
   238→    """An entry in a directory listing."""
   239→    name: str
   240→    path: str  # POSIX format
   241→    is_directory: bool
   242→    is_hidden: bool = False
   243→    size: int | None = None  # Bytes, for files
   244→    has_children: bool = False  # True if directory has subdirectories
   245→
   246→
   247→class DirectoryListResponse(BaseModel):
   248→    """Response for directory listing."""
   249→    current_path: str  # POSIX format
   250→    parent_path: str | None
   251→    entries: list[DirectoryEntry]
   252→    drives: list[DriveInfo] | None = None  # Windows only
   253→
   254→
   255→class PathValidationResponse(BaseModel):
   256→    """Response for path validation."""
   257→    valid: bool
   258→    exists: bool
   259→    is_directory: bool
   260→    can_read: bool
   261→    can_write: bool
   262→    message: str = ""
   263→
   264→
   265→class CreateDirectoryRequest(BaseModel):
   266→    """Request to create a new directory."""
   267→    parent_path: str
   268→    name: str = Field(..., min_length=1, max_length=255)
   269→
   270→
   271→# ============================================================================
   272→# Reference Folder Schemas
   273→# ============================================================================
   274→
   275→class ReferenceFolderCreate(BaseModel):
   276→    """Request schema for adding a reference folder."""
   277→    path: str = Field(..., min_length=1, description="Absolute path to reference folder")
   278→    alias: str | None = Field(None, max_length=100, description="Optional friendly name")
   279→    description: str | None = Field(None, max_length=500, description="What this reference is for")
   280→
   281→
   282→class ReferenceFolderUpdate(BaseModel):
   283→    """Request schema for updating a reference folder."""
   284→    alias: str | None = Field(None, max_length=100)
   285→    description: str | None = Field(None, max_length=500)
   286→
   287→
   288→class ReferenceFolderResponse(BaseModel):
   289→    """Response schema for a reference folder."""
   290→    id: int
   291→    path: str
   292→    alias: str | None
   293→    description: str | None
   294→    created_at: str | None
   295→    exists: bool  # Whether the path still exists on disk
   296→

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

"""
Pydantic Schemas
================

Request/Response models for the API endpoints.
"""

import base64
from datetime import datetime
from typing import Literal

from pydantic import BaseModel, Field, field_validator

# ============================================================================
# Project Schemas
# ============================================================================

class ProjectCreate(BaseModel):
    """Request schema for creating a new project."""
    name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
    path: str = Field(..., min_length=1, description="Absolute path to project directory")
    spec_method: Literal["claude", "manual"] = "claude"

class ProjectStats(BaseModel):
    """Project statistics."""
    passing: int = 0
    in_progress: int = 0
    total: int = 0
    percentage: float = 0.0

class ProjectSummary(BaseModel):
    """Summary of a project for list view."""
    name: str
    path: str
    has_spec: bool
    stats: ProjectStats

class ProjectDetail(BaseModel):
    """Detailed project information."""
    name: str
    path: str
    has_spec: bool
    stats: ProjectStats
    prompts_dir: str

class ProjectPrompts(BaseModel):
    """Project prompt files content."""
    app_spec: str = ""
    initializer_prompt: str = ""
    coding_prompt: str = ""

class ProjectPromptsUpdate(BaseModel):
    """Request schema for updating project prompts."""
    app_spec: str | None = None
    initializer_prompt: str | None = None
    coding_prompt: str | None = None

class ProjectImport(BaseModel):
    """Request schema for importing an existing project."""
    name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
    path: str = Field(..., min_length=1, description="Absolute path to existing project")

class CodebaseAnalysis(BaseModel):
    """Analysis of an existing codebase."""
    total_files: int = 0
    file_types: dict[str, int] = {}
    detected_stack: dict[str, str] = {}
    has_claude_md: bool = False
    claude_md_content: str | None = None
    has_readme: bool = False
    readme_content: str | None = None
    directories: list[str] = []
    entry_points: list[str] = []
    project_name: str | None = None

class ProjectImportResponse(BaseModel):
    """Response for project import."""
    project: ProjectSummary
    analysis: CodebaseAnalysis
    is_import: bool = True

# ============================================================================
# Feature Schemas
# ============================================================================

class FeatureBase(BaseModel):
    """Base feature attributes."""
    category: str
    name: str
    description: str
    steps: list[str]

class FeatureCreate(FeatureBase):
    """Request schema for creating a new feature."""
    priority: int | None = None

class FeatureResponse(FeatureBase):
    """Response schema for a feature."""
    id: int
    priority: int
    passes: bool
    in_progress: bool

    class Config:
        from_attributes = True

class FeatureListResponse(BaseModel):
    """Response containing list of features organized by status."""
    pending: list[FeatureResponse]
    in_progress: list[FeatureResponse]
    done: list[FeatureResponse]

# ============================================================================
# Agent Schemas
# ============================================================================

class AgentStartRequest(BaseModel):
    """Request schema for starting the agent."""
    yolo_mode: bool = False

class AgentStatus(BaseModel):
    """Current agent status."""
    status: Literal["stopped", "running", "paused", "crashed"]
    pid: int | None = None
    started_at: datetime | None = None
    yolo_mode: bool = False

class AgentActionResponse(BaseModel):
    """Response for agent control actions."""
    success: bool
    status: str
    message: str = ""

# ============================================================================
# Setup Schemas
# ============================================================================

class SetupStatus(BaseModel):
    """System setup status."""
    claude_cli: bool
    credentials: bool
    node: bool
    npm: bool

# ============================================================================
# WebSocket Message Schemas
# ============================================================================

class WSProgressMessage(BaseModel):
    """WebSocket message for progress updates."""
    type: Literal["progress"] = "progress"
    passing: int
    total: int
    percentage: float

class WSFeatureUpdateMessage(BaseModel):
    """WebSocket message for feature status updates."""
    type: Literal["feature_update"] = "feature_update"
    feature_id: int
    passes: bool

class WSLogMessage(BaseModel):
    """WebSocket message for agent log output."""
    type: Literal["log"] = "log"
    line: str
    timestamp: datetime

class WSAgentStatusMessage(BaseModel):
    """WebSocket message for agent status changes."""
    type: Literal["agent_status"] = "agent_status"
    status: str

# ============================================================================
# Spec Chat Schemas
# ============================================================================

# Maximum image file size: 5 MB
MAX_IMAGE_SIZE = 5 * 1024 * 1024

class ImageAttachment(BaseModel):
    """Image attachment from client for spec creation chat."""
    filename: str = Field(..., min_length=1, max_length=255)
    mimeType: Literal['image/jpeg', 'image/png']
    base64Data: str

    @field_validator('base64Data')
    @classmethod
    def validate_base64_and_size(cls, v: str) -> str:
        """Validate that base64 data is valid and within size limit."""
        try:
            decoded = base64.b64decode(v)
            if len(decoded) > MAX_IMAGE_SIZE:
                raise ValueError(
                    f'Image size ({len(decoded) / (1024 * 1024):.1f} MB) exceeds '
                    f'maximum of {MAX_IMAGE_SIZE // (1024 * 1024)} MB'
                )
            return v
        except Exception as e:
            if 'Image size' in str(e):
                raise
            raise ValueError(f'Invalid base64 data: {e}')

# ============================================================================
# Filesystem Schemas
# ============================================================================

class DriveInfo(BaseModel):
    """Information about a drive (Windows only)."""
    letter: str
    label: str
    available: bool = True

class DirectoryEntry(BaseModel):
    """An entry in a directory listing."""
    name: str
    path: str  # POSIX format
    is_directory: bool
    is_hidden: bool = False
    size: int | None = None  # Bytes, for files
    has_children: bool = False  # True if directory has subdirectories

class DirectoryListResponse(BaseModel):
    """Response for directory listing."""
    current_path: str  # POSIX format
    parent_path: str | None
    entries: list[DirectoryEntry]
    drives: list[DriveInfo] | None = None  # Windows only

class PathValidationResponse(BaseModel):
    """Response for path validation."""
    valid: bool
    exists: bool
    is_directory: bool
    can_read: bool
    can_write: bool
    message: str = ""

class CreateDirectoryRequest(BaseModel):
    """Request to create a new directory."""
    parent_path: str
    name: str = Field(..., min_length=1, max_length=255)

# ============================================================================
# Reference Folder Schemas
# ============================================================================

class ReferenceFolderCreate(BaseModel):
    """Request schema for adding a reference folder."""
    path: str = Field(..., min_length=1, description="Absolute path to reference folder")
    alias: str | None = Field(None, max_length=100, description="Optional friendly name")
    description: str | None = Field(None, max_length=500, description="What this reference is for")

class ReferenceFolderUpdate(BaseModel):
    """Request schema for updating a reference folder."""
    alias: str | None = Field(None, max_length=100)
    description: str | None = Field(None, max_length=500)

class ReferenceFolderResponse(BaseModel):
    """Response schema for a reference folder."""
    id: int
    path: str
    alias: str | None
    description: str | None
    created_at: str | None
    exists: bool  # Whether the path still exists on disk


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

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

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

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

import re
from pathlib import Path

from fastapi import APIRouter, HTTPException

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

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

    from registry import get_project_path
    return get_project_path(project_name)

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

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

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

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

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

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

    return get_manager(project_name, project_dir, ROOT_DIR)

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

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

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

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

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

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

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

    success, message = await manager.stop()

    return AgentActionResponse(
        success=success,


Perfect! Now let me check the UI hooks for WebSocket handling:


Bash

find "C:\Users\MikeSwanson\claude-projects\ACPP\AutoCoderPlus4\..\..\Autocode-remix\Autocoder-2.0\ui\src" -name "*.ts" -o -name "*.tsx" | grep -i hook