     1→"""
     2→Terminal Chat Session
     3→=====================
     4→
     5→Manages interactive Claude terminal sessions for resource management,
     6→infrastructure discovery, and nuanced configuration tasks.
     7→
     8→The terminal provides full system access (no sandbox) and allows users to:
     9→- Add and configure servers, services, and credentials
    10→- Test connectivity to resources
    11→- Save resources as project-specific or global
    12→- Execute system commands for infrastructure tasks
    13→"""
    14→
    15→import json
    16→import logging
    17→import shutil
    18→import threading
    19→from datetime import datetime
    20→from pathlib import Path
    21→from typing import AsyncGenerator, Optional
    22→
    23→from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
    24→
    25→from .credential_resolver import get_resolver
    26→from .resource_manager import (
    27→    get_manager,
    28→    Resource,
    29→    ResourceType,
    30→    ResourceScope,
    31→    AuthType,
    32→    ResourceAuth,
    33→)
    34→
    35→logger = logging.getLogger(__name__)
    36→
    37→# Root directory of the project
    38→ROOT_DIR = Path(__file__).parent.parent.parent
    39→
    40→# Terminal system prompt
    41→TERMINAL_SYSTEM_PROMPT = """You are a resource management assistant for Autocoder 2.0.
    42→
    43→## Your Role
    44→
    45→You help users manage infrastructure resources (servers, services, databases) that coding agents can use. Your job is to:
    46→
    47→1. **Add Resources** - When users mention a server or service, help add it to their resources
    48→2. **Test Connectivity** - Verify resources are accessible before saving
    49→3. **Probe Capabilities** - Discover what tools/runtimes are installed on servers
    50→4. **Manage Scope** - Help users decide if resources should be project-specific or global
    51→
    52→## Available Tools
    53→
    54→You have full system access:
    55→- **Bash**: SSH, curl, git, docker, and any system command
    56→- **Read/Write/Edit**: File operations
    57→- **Glob/Grep**: Search files
    58→
    59→## When Adding a Resource
    60→
    61→Follow this workflow:
    62→1. Get connection details from user (host, user, credentials)
    63→2. Test connectivity (SSH for servers, curl for services)
    64→3. Probe for relevant capabilities if it's a server
    65→4. Ask user: "Should this be a global resource (all projects) or project-specific?"
    66→5. Save to the appropriate resources.json
    67→
    68→## Resource Storage
    69→
    70→Resources are stored in JSON files:
    71→- **Global**: `~/.autocoder/global_resources.json`
    72→- **Project**: `{project_dir}/resources.json`
    73→
    74→Use the resource management functions to save resources programmatically.
    75→
    76→## Credential Handling
    77→
    78→Credentials are stored separately in `credentials.md`. When saving a resource:
    79→- Don't store passwords directly in resources.json
    80→- Instead, use a `credential_ref` that points to credentials.md
    81→- Example: `{"auth": {"type": "ssh-password", "credential_ref": "jupiter"}}`
    82→
    83→## Example Interactions
    84→
    85→User: "Add my build server at 172.16.3.30"
    86→You: Test SSH, probe for rust/node/docker, ask about scope, save resource
    87→
    88→User: "What servers do I have configured?"
    89→You: List resources from global + project resources
    90→
    91→User: "Test if the Gitea API is working"
    92→You: curl the API endpoint, report results
    93→
    94→## Project Context
    95→
    96→{PROJECT_CONTEXT}
    97→
    98→## Current Working Directory
    99→
   100→{CWD}
   101→"""
   102→
   103→
   104→class TerminalSession:
   105→    """
   106→    Manages an interactive Claude terminal session for one project.
   107→
   108→    The terminal provides full system access for:
   109→    - Resource management (add servers, services)
   110→    - Connection testing and capability probing
   111→    - Infrastructure configuration
   112→    """
   113→
   114→    def __init__(
   115→        self,
   116→        project_name: str,
   117→        project_dir: Path,
   118→    ):
   119→        """
   120→        Initialize the terminal session.
   121→
   122→        Args:
   123→            project_name: Name of the project
   124→            project_dir: Absolute path to the project directory
   125→        """
   126→        self.project_name = project_name
   127→        self.project_dir = project_dir
   128→        self.client: Optional[ClaudeSDKClient] = None
   129→        self.messages: list[dict] = []
   130→        self.created_at = datetime.now()
   131→        self._client_entered: bool = False
   132→        self._resource_manager = get_manager()
   133→        self._credential_resolver = get_resolver()
   134→
   135→    def get_resources(self) -> dict:
   136→        """Get current resources for the project."""
   137→        resources = self._resource_manager.get_merged_resources(
   138→            self.project_name, self.project_dir
   139→        )
   140→        return {
   141→            name: resource.to_dict()
   142→            for name, resource in resources.items()
   143→        }
   144→
   145→    async def close(self) -> None:
   146→        """Clean up resources and close the Claude client."""
   147→        if self.client and self._client_entered:
   148→            try:
   149→                await self.client.__aexit__(None, None, None)
   150→            except Exception as e:
   151→                logger.warning(f"Error closing Claude client: {e}")
   152→            finally:
   153→                self._client_entered = False
   154→                self.client = None
   155→
   156→    def _get_project_context(self) -> str:
   157→        """Generate project context for the system prompt."""
   158→        lines = [f"Project: **{self.project_name}**"]
   159→        lines.append(f"Directory: `{self.project_dir}`")
   160→        lines.append("")
   161→
   162→        # Get current resources
   163→        resources = self.get_resources()
   164→        if resources:
   165→            lines.append("### Current Resources")
   166→            for name, data in resources.items():
   167→                rtype = data.get("type", "unknown")
   168→                scope = data.get("scope", "project")
   169→                host = data.get("host") or data.get("url") or "no host"
   170→                lines.append(f"- **{name}** ({rtype}, {scope}): {host}")
   171→        else:
   172→            lines.append("_No resources configured yet._")
   173→
   174→        return "\n".join(lines)
   175→
   176→    async def start(self) -> AsyncGenerator[dict, None]:
   177→        """
   178→        Initialize session and send initial greeting.
   179→
   180→        Yields message chunks as they stream in.
   181→        """
   182→        # Ensure project directory exists
   183→        self.project_dir.mkdir(parents=True, exist_ok=True)
   184→
   185→        # Create security settings with full access
   186→        security_settings = {
   187→            "sandbox": {"enabled": False},  # Full system access
   188→            "permissions": {
   189→                "defaultMode": "acceptEdits",
   190→                "allow": [
   191→                    "Read(./**)",
   192→                    "Read(~/**)",  # Allow reading home directory
   193→                    "Write(./**)",
   194→                    "Edit(./**)",
   195→                    "Glob(./**)",
   196→                    "Grep(./**)",
   197→                    "Bash(*)",  # Full bash access
   198→                    "WebFetch",
   199→                    "WebSearch",
   200→                ],
   201→            },
   202→        }
   203→        settings_file = self.project_dir / ".claude_terminal_settings.json"
   204→        with open(settings_file, "w") as f:
   205→            json.dump(security_settings, f, indent=2)
   206→
   207→        # Build system prompt with context
   208→        system_prompt = TERMINAL_SYSTEM_PROMPT.replace(
   209→            "{PROJECT_CONTEXT}",
   210→            self._get_project_context()
   211→        ).replace(
   212→            "{CWD}",
   213→            str(self.project_dir.resolve())
   214→        )
   215→
   216→        # Use system Claude CLI
   217→        system_cli = shutil.which("claude")
   218→
   219→        try:
   220→            self.client = ClaudeSDKClient(
   221→                options=ClaudeAgentOptions(
   222→                    model="claude-sonnet-4-20250514",  # Sonnet for faster terminal responses
   223→                    cli_path=system_cli,
   224→                    system_prompt=system_prompt,
   225→                    allowed_tools=[
   226→                        "Read",
   227→                        "Write",
   228→                        "Edit",
   229→                        "Glob",
   230→                        "Grep",
   231→                        "Bash",
   232→                        "WebFetch",
   233→                        "WebSearch",
   234→                    ],
   235→                    permission_mode="acceptEdits",
   236→                    max_turns=100,
   237→                    cwd=str(self.project_dir.resolve()),
   238→                    settings=str(settings_file.resolve()),
   239→                )
   240→            )
   241→            await self.client.__aenter__()
   242→            self._client_entered = True
   243→        except Exception as e:
   244→            logger.exception("Failed to create Claude client for terminal")
   245→            yield {
   246→                "type": "error",
   247→                "content": f"Failed to initialize terminal: {str(e)}"
   248→            }
   249→            return
   250→
   251→        # Send initial greeting
   252→        initial_message = (
   253→            f"Terminal session started for project **{self.project_name}**.\n\n"
   254→            "I can help you manage resources (servers, services, databases). "
   255→            "What would you like to do?"
   256→        )
   257→
   258→        yield {"type": "text", "content": initial_message}
   259→        yield {"type": "resources", "data": self.get_resources()}
   260→        yield {"type": "response_done"}
   261→
   262→    async def send_message(
   263→        self,
   264→        user_message: str,
   265→    ) -> AsyncGenerator[dict, None]:
   266→        """
   267→        Send user message and stream Claude's response.
   268→
   269→        Args:
   270→            user_message: The user's message
   271→
   272→        Yields:
   273→            Message chunks of various types:
   274→            - {"type": "text", "content": str}
   275→            - {"type": "resource_added", "resource": dict}
   276→            - {"type": "verification_result", "result": dict}
   277→            - {"type": "error", "content": str}
   278→        """
   279→        if not self.client:
   280→            yield {
   281→                "type": "error",
   282→                "content": "Session not initialized. Call start() first."
   283→            }
   284→            return
   285→
   286→        # Store the user message
   287→        self.messages.append({
   288→            "role": "user",
   289→            "content": user_message,
   290→            "timestamp": datetime.now().isoformat()
   291→        })
   292→
   293→        try:
   294→            async for chunk in self._query_claude(user_message):
   295→                yield chunk
   296→            yield {"type": "response_done"}
   297→        except Exception as e:
   298→            logger.exception("Error during terminal query")
   299→            yield {
   300→                "type": "error",
   301→                "content": f"Error: {str(e)}"
   302→            }
   303→
   304→    async def _query_claude(
   305→        self,
   306→        message: str,
   307→    ) -> AsyncGenerator[dict, None]:
   308→        """
   309→        Query Claude and stream responses.
   310→
   311→        Handles tool calls and text responses.
   312→        Detects resource management operations and yields appropriate events.
   313→        """
   314→        if not self.client:
   315→            return
   316→
   317→        await self.client.query(message)
   318→
   319→        current_text = ""
   320→
   321→        # Stream the response
   322→        async for msg in self.client.receive_response():
   323→            msg_type = type(msg).__name__
   324→
   325→            if msg_type == "AssistantMessage" and hasattr(msg, "content"):
   326→                for block in msg.content:
   327→                    block_type = type(block).__name__
   328→
   329→                    if block_type == "TextBlock" and hasattr(block, "text"):
   330→                        text = block.text
   331→                        if text:
   332→                            current_text += text
   333→                            yield {"type": "text", "content": text}
   334→
   335→                            # Store in message history
   336→                            self.messages.append({
   337→                                "role": "assistant",
   338→                                "content": text,
   339→                                "timestamp": datetime.now().isoformat()
   340→                            })
   341→
   342→                    elif block_type == "ToolUseBlock":
   343→                        tool_name = getattr(block, "name", "")
   344→                        tool_input = getattr(block, "input", {})
   345→
   346→                        # Detect resource file writes
   347→                        if tool_name == "Write":
   348→                            file_path = tool_input.get("file_path", "")
   349→                            if "resources.json" in file_path:
   350→                                yield {
   351→                                    "type": "resource_updated",
   352→                                    "path": file_path
   353→                                }
   354→                                # Refresh resources
   355→                                yield {
   356→                                    "type": "resources",
   357→                                    "data": self.get_resources()
   358→                                }
   359→
   360→                        # Notify about bash commands (for transparency)
   361→                        if tool_name == "Bash":
   362→                            command = tool_input.get("command", "")
   363→                            yield {
   364→                                "type": "command_executing",
   365→                                "command": command[:100] + ("..." if len(command) > 100 else "")
   366→                            }
   367→
   368→            elif msg_type == "UserMessage" and hasattr(msg, "content"):
   369→                # Tool results - look for verification results
   370→                for block in msg.content:
   371→                    block_type = type(block).__name__
   372→                    if block_type == "ToolResultBlock":
   373→                        result = getattr(block, "result", "")
   374→                        # Check for SSH or connection test results
   375→                        if "connected" in str(result).lower() or "success" in str(result).lower():
   376→                            yield {
   377→                                "type": "connection_verified",
   378→                                "result": str(result)[:200]
   379→                            }
   380→
   381→
   382→# Session registry - thread-safe storage of active sessions
   383→_sessions: dict[str, TerminalSession] = {}
   384→_sessions_lock = threading.Lock()
   385→
   386→
   387→async def get_or_create_terminal_session(
   388→    project_name: str,
   389→    project_dir: Path,
   390→) -> TerminalSession:
   391→    """
   392→    Get existing terminal session or create a new one.
   393→
   394→    Args:
   395→        project_name: Name of the project
   396→        project_dir: Path to the project directory
   397→
   398→    Returns:
   399→        TerminalSession instance
   400→    """
   401→    with _sessions_lock:
   402→        # Check for existing session
   403→        if project_name in _sessions:
   404→            session = _sessions[project_name]
   405→            # Verify it's still valid
   406→            if session.client and session._client_entered:
   407→                return session
   408→            # Invalid session - remove it
   409→            del _sessions[project_name]
   410→
   411→        # Create new session
   412→        session = TerminalSession(project_name, project_dir)
   413→        _sessions[project_name] = session
   414→
   415→    return session
   416→
   417→
   418→async def close_terminal_session(project_name: str) -> None:
   419→    """
   420→    Close and remove a terminal session.
   421→
   422→    Args:
   423→        project_name: Name of the project
   424→    """
   425→    session = None
   426→    with _sessions_lock:
   427→        session = _sessions.pop(project_name, None)
   428→
   429→    if session:
   430→        await session.close()
   431→
   432→
   433→def get_active_sessions() -> list[str]:
   434→    """Get list of project names with active terminal sessions."""
   435→    with _sessions_lock:
   436→        return list(_sessions.keys())
   437→

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

"""
Terminal Chat Session
=====================

Manages interactive Claude terminal sessions for resource management,
infrastructure discovery, and nuanced configuration tasks.

The terminal provides full system access (no sandbox) and allows users to:
- Add and configure servers, services, and credentials
- Test connectivity to resources
- Save resources as project-specific or global
- Execute system commands for infrastructure tasks
"""

import json
import logging
import shutil
import threading
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator, Optional

from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient

from .credential_resolver import get_resolver
from .resource_manager import (
    get_manager,
    Resource,
    ResourceType,
    ResourceScope,
    AuthType,
    ResourceAuth,
)

logger = logging.getLogger(__name__)

# Root directory of the project
ROOT_DIR = Path(__file__).parent.parent.parent

# Terminal system prompt
TERMINAL_SYSTEM_PROMPT = """You are a resource management assistant for Autocoder 2.0.

## Your Role

You help users manage infrastructure resources (servers, services, databases) that coding agents can use. Your job is to:

1. **Add Resources** - When users mention a server or service, help add it to their resources
2. **Test Connectivity** - Verify resources are accessible before saving
3. **Probe Capabilities** - Discover what tools/runtimes are installed on servers
4. **Manage Scope** - Help users decide if resources should be project-specific or global

## Available Tools

You have full system access:
- **Bash**: SSH, curl, git, docker, and any system command
- **Read/Write/Edit**: File operations
- **Glob/Grep**: Search files

## When Adding a Resource

Follow this workflow:
1. Get connection details from user (host, user, credentials)
2. Test connectivity (SSH for servers, curl for services)
3. Probe for relevant capabilities if it's a server
4. Ask user: "Should this be a global resource (all projects) or project-specific?"
5. Save to the appropriate resources.json

## Resource Storage

Resources are stored in JSON files:
- **Global**: `~/.autocoder/global_resources.json`
- **Project**: `{project_dir}/resources.json`

Use the resource management functions to save resources programmatically.

## Credential Handling

Credentials are stored separately in `credentials.md`. When saving a resource:
- Don't store passwords directly in resources.json
- Instead, use a `credential_ref` that points to credentials.md
- Example: `{"auth": {"type": "ssh-password", "credential_ref": "jupiter"}}`

## Example Interactions

User: "Add my build server at 172.16.3.30"
You: Test SSH, probe for rust/node/docker, ask about scope, save resource

User: "What servers do I have configured?"
You: List resources from global + project resources

User: "Test if the Gitea API is working"
You: curl the API endpoint, report results

## Project Context

{PROJECT_CONTEXT}

## Current Working Directory

{CWD}
"""

class TerminalSession:
    """
    Manages an interactive Claude terminal session for one project.

    The terminal provides full system access for:
    - Resource management (add servers, services)
    - Connection testing and capability probing
    - Infrastructure configuration
    """

    def __init__(
        self,
        project_name: str,
        project_dir: Path,
    ):
        """
        Initialize the terminal session.

        Args:
            project_name: Name of the project
            project_dir: Absolute path to the project directory
        """
        self.project_name = project_name
        self.project_dir = project_dir
        self.client: Optional[ClaudeSDKClient] = None
        self.messages: list[dict] = []
        self.created_at = datetime.now()
        self._client_entered: bool = False
        self._resource_manager = get_manager()
        self._credential_resolver = get_resolver()

    def get_resources(self) -> dict:
        """Get current resources for the project."""
        resources = self._resource_manager.get_merged_resources(
            self.project_name, self.project_dir
        )
        return {
            name: resource.to_dict()
            for name, resource in resources.items()
        }

    async def close(self) -> None:
        """Clean up resources and close the Claude client."""
        if self.client and self._client_entered:
            try:
                await self.client.__aexit__(None, None, None)
            except Exception as e:
                logger.warning(f"Error closing Claude client: {e}")
            finally:
                self._client_entered = False
                self.client = None

    def _get_project_context(self) -> str:
        """Generate project context for the system prompt."""
        lines = [f"Project: **{self.project_name}**"]
        lines.append(f"Directory: `{self.project_dir}`")
        lines.append("")

        # Get current resources
        resources = self.get_resources()
        if resources:
            lines.append("### Current Resources")
            for name, data in resources.items():
                rtype = data.get("type", "unknown")
                scope = data.get("scope", "project")
                host = data.get("host") or data.get("url") or "no host"
                lines.append(f"- **{name}** ({rtype}, {scope}): {host}")
        else:
            lines.append("_No resources configured yet._")

        return "\n".join(lines)

    async def start(self) -> AsyncGenerator[dict, None]:
        """
        Initialize session and send initial greeting.

        Yields message chunks as they stream in.
        """
        # Ensure project directory exists
        self.project_dir.mkdir(parents=True, exist_ok=True)

        # Create security settings with full access
        security_settings = {
            "sandbox": {"enabled": False},  # Full system access
            "permissions": {
                "defaultMode": "acceptEdits",
                "allow": [
                    "Read(./**)",
                    "Read(~/**)",  # Allow reading home directory
                    "Write(./**)",
                    "Edit(./**)",
                    "Glob(./**)",
                    "Grep(./**)",
                    "Bash(*)",  # Full bash access
                    "WebFetch",
                    "WebSearch",
                ],
            },
        }
        settings_file = self.project_dir / ".claude_terminal_settings.json"
        with open(settings_file, "w") as f:
            json.dump(security_settings, f, indent=2)

        # Build system prompt with context
        system_prompt = TERMINAL_SYSTEM_PROMPT.replace(
            "{PROJECT_CONTEXT}",
            self._get_project_context()
        ).replace(
            "{CWD}",
            str(self.project_dir.resolve())
        )

        # Use system Claude CLI
        system_cli = shutil.which("claude")

        try:
            self.client = ClaudeSDKClient(
                options=ClaudeAgentOptions(
                    model="claude-sonnet-4-20250514",  # Sonnet for faster terminal responses
                    cli_path=system_cli,
                    system_prompt=system_prompt,
                    allowed_tools=[
                        "Read",
                        "Write",
                        "Edit",
                        "Glob",
                        "Grep",
                        "Bash",
                        "WebFetch",
                        "WebSearch",
                    ],
                    permission_mode="acceptEdits",
                    max_turns=100,
                    cwd=str(self.project_dir.resolve()),
                    settings=str(settings_file.resolve()),
                )
            )
            await self.client.__aenter__()
            self._client_entered = True
        except Exception as e:
            logger.exception("Failed to create Claude client for terminal")
            yield {
                "type": "error",
                "content": f"Failed to initialize terminal: {str(e)}"
            }
            return

        # Send initial greeting
        initial_message = (
            f"Terminal session started for project **{self.project_name}**.\n\n"
            "I can help you manage resources (servers, services, databases). "
            "What would you like to do?"
        )

        yield {"type": "text", "content": initial_message}
        yield {"type": "resources", "data": self.get_resources()}
        yield {"type": "response_done"}

    async def send_message(
        self,
        user_message: str,
    ) -> AsyncGenerator[dict, None]:
        """
        Send user message and stream Claude's response.

        Args:
            user_message: The user's message

        Yields:
            Message chunks of various types:
            - {"type": "text", "content": str}
            - {"type": "resource_added", "resource": dict}
            - {"type": "verification_result", "result": dict}
            - {"type": "error", "content": str}
        """
        if not self.client:
            yield {
                "type": "error",
                "content": "Session not initialized. Call start() first."
            }
            return

        # Store the user message
        self.messages.append({
            "role": "user",
            "content": user_message,
            "timestamp": datetime.now().isoformat()
        })

        try:
            async for chunk in self._query_claude(user_message):
                yield chunk
            yield {"type": "response_done"}
        except Exception as e:
            logger.exception("Error during terminal query")
            yield {
                "type": "error",
                "content": f"Error: {str(e)}"
            }

    async def _query_claude(
        self,
        message: str,
    ) -> AsyncGenerator[dict, None]:
        """
        Query Claude and stream responses.

        Handles tool calls and text responses.
        Detects resource management operations and yields appropriate events.
        """
        if not self.client:
            return

        await self.client.query(message)

        current_text = ""

        # Stream the response
        async for msg in self.client.receive_response():
            msg_type = type(msg).__name__

            if msg_type == "AssistantMessage" and hasattr(msg, "content"):
                for block in msg.content:
                    block_type = type(block).__name__

                    if block_type == "TextBlock" and hasattr(block, "text"):
                        text = block.text
                        if text:
                            current_text += text
                            yield {"type": "text", "content": text}

                            # Store in message history
                            self.messages.append({
                                "role": "assistant",
                                "content": text,
                                "timestamp": datetime.now().isoformat()
                            })

                    elif block_type == "ToolUseBlock":
                        tool_name = getattr(block, "name", "")
                        tool_input = getattr(block, "input", {})

                        # Detect resource file writes
                        if tool_name == "Write":
                            file_path = tool_input.get("file_path", "")
                            if "resources.json" in file_path:
                                yield {
                                    "type": "resource_updated",
                                    "path": file_path
                                }
                                # Refresh resources
                                yield {
                                    "type": "resources",
                                    "data": self.get_resources()
                                }

                        # Notify about bash commands (for transparency)
                        if tool_name == "Bash":
                            command = tool_input.get("command", "")
                            yield {
                                "type": "command_executing",
                                "command": command[:100] + ("..." if len(command) > 100 else "")
                            }

            elif msg_type == "UserMessage" and hasattr(msg, "content"):
                # Tool results - look for verification results
                for block in msg.content:
                    block_type = type(block).__name__
                    if block_type == "ToolResultBlock":
                        result = getattr(block, "result", "")
                        # Check for SSH or connection test results
                        if "connected" in str(result).lower() or "success" in str(result).lower():
                            yield {
                                "type": "connection_verified",
                                "result": str(result)[:200]
                            }

# Session registry - thread-safe storage of active sessions
_sessions: dict[str, TerminalSession] = {}
_sessions_lock = threading.Lock()

async def get_or_create_terminal_session(
    project_name: str,
    project_dir: Path,
) -> TerminalSession:
    """
    Get existing terminal session or create a new one.

    Args:
        project_name: Name of the project
        project_dir: Path to the project directory

    Returns:
        TerminalSession instance
    """
    with _sessions_lock:
        # Check for existing session
        if project_name in _sessions:
            session = _sessions[project_name]
            # Verify it's still valid
            if session.client and session._client_entered:
                return session
            # Invalid session - remove it
            del _sessions[project_name]

        # Create new session
        session = TerminalSession(project_name, project_dir)
        _sessions[project_name] = session

    return session

async def close_terminal_session(project_name: str) -> None:
    """
    Close and remove a terminal session.

    Args:
        project_name: Name of the project
    """
    session = None
    with _sessions_lock:
        session = _sessions.pop(project_name, None)

    if session:
        await session.close()

def get_active_sessions() -> list[str]:
    """Get list of project names with active terminal sessions."""
    with _sessions_lock:
        return list(_sessions.keys())


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

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

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

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

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

import psutil

logger = logging.getLogger(__name__)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

                await self._broadcast_output(sanitized)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            # Terminate gracefully first
            self.process.terminate()

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

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

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

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

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

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

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

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

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

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

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

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

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

        return True

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

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

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

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

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

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

    with _managers_lock:
        _managers.clear()


Now let me read the two chat session files:


     1→"""
     2→Assistant Chat Session
     3→======================
     4→
     5→Manages read-only conversational assistant sessions for projects.
     6→The assistant can answer questions about the codebase and features
     7→but cannot modify any files.
     8→"""
     9→
    10→import json
    11→import logging
    12→import os
    13→import shutil
    14→import sys
    15→import threading
    16→from datetime import datetime
    17→from pathlib import Path
    18→from typing import AsyncGenerator, Optional
    19→
    20→from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
    21→
    22→from .assistant_database import (
    23→    add_message,
    24→    create_conversation,
    25→)
    26→
    27→logger = logging.getLogger(__name__)
    28→
    29→# Root directory of the project
    30→ROOT_DIR = Path(__file__).parent.parent.parent
    31→
    32→# Read-only feature MCP tools (no mark_passing, skip, create_bulk)
    33→READONLY_FEATURE_MCP_TOOLS = [
    34→    "mcp__features__feature_get_stats",
    35→    "mcp__features__feature_get_next",
    36→    "mcp__features__feature_get_for_regression",
    37→]
    38→
    39→# Read-only built-in tools (no Write, Edit, Bash)
    40→READONLY_BUILTIN_TOOLS = [
    41→    "Read",
    42→    "Glob",
    43→    "Grep",
    44→    "WebFetch",
    45→    "WebSearch",
    46→]
    47→
    48→
    49→def get_system_prompt(project_name: str, project_dir: Path) -> str:
    50→    """Generate the system prompt for the assistant with project context."""
    51→    # Try to load app_spec.txt for context
    52→    app_spec_content = ""
    53→    app_spec_path = project_dir / "prompts" / "app_spec.txt"
    54→    if app_spec_path.exists():
    55→        try:
    56→            app_spec_content = app_spec_path.read_text(encoding="utf-8")
    57→            # Truncate if too long
    58→            if len(app_spec_content) > 5000:
    59→                app_spec_content = app_spec_content[:5000] + "\n... (truncated)"
    60→        except Exception as e:
    61→            logger.warning(f"Failed to read app_spec.txt: {e}")
    62→
    63→    return f"""You are a helpful project assistant for the "{project_name}" project.
    64→
    65→Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files.
    66→
    67→IMPORTANT: You CANNOT modify any files. You can only:
    68→- Read and analyze source code files
    69→- Search for patterns in the codebase
    70→- Look up documentation online
    71→- Check feature progress and status
    72→
    73→If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications.
    74→
    75→## Project Specification
    76→
    77→{app_spec_content if app_spec_content else "(No app specification found)"}
    78→
    79→## Available Tools
    80→
    81→You have access to these read-only tools:
    82→- **Read**: Read file contents
    83→- **Glob**: Find files by pattern (e.g., "**/*.tsx")
    84→- **Grep**: Search file contents with regex
    85→- **WebFetch/WebSearch**: Look up documentation online
    86→- **feature_get_stats**: Get feature completion progress
    87→- **feature_get_next**: See the next pending feature
    88→- **feature_get_for_regression**: See passing features
    89→
    90→## Guidelines
    91→
    92→1. Be concise and helpful
    93→2. When explaining code, reference specific file paths and line numbers
    94→3. Use the feature tools to answer questions about project progress
    95→4. Search the codebase to find relevant information before answering
    96→5. If you're unsure, say so rather than guessing"""
    97→
    98→
    99→class AssistantChatSession:
   100→    """
   101→    Manages a read-only assistant conversation for a project.
   102→
   103→    Uses Claude Opus 4.5 with only read-only tools enabled.
   104→    Persists conversation history to SQLite.
   105→    """
   106→
   107→    def __init__(self, project_name: str, project_dir: Path, conversation_id: Optional[int] = None):
   108→        """
   109→        Initialize the session.
   110→
   111→        Args:
   112→            project_name: Name of the project
   113→            project_dir: Absolute path to the project directory
   114→            conversation_id: Optional existing conversation ID to resume
   115→        """
   116→        self.project_name = project_name
   117→        self.project_dir = project_dir
   118→        self.conversation_id = conversation_id
   119→        self.client: Optional[ClaudeSDKClient] = None
   120→        self._client_entered: bool = False
   121→        self.created_at = datetime.now()
   122→
   123→    async def close(self) -> None:
   124→        """Clean up resources and close the Claude client."""
   125→        if self.client and self._client_entered:
   126→            try:
   127→                await self.client.__aexit__(None, None, None)
   128→            except Exception as e:
   129→                logger.warning(f"Error closing Claude client: {e}")
   130→            finally:
   131→                self._client_entered = False
   132→                self.client = None
   133→
   134→    async def start(self) -> AsyncGenerator[dict, None]:
   135→        """
   136→        Initialize session with the Claude client.
   137→
   138→        Creates a new conversation if none exists, then sends an initial greeting.
   139→        Yields message chunks as they stream in.
   140→        """
   141→        # Create a new conversation if we don't have one
   142→        if self.conversation_id is None:
   143→            conv = create_conversation(self.project_dir, self.project_name)
   144→            self.conversation_id = conv.id
   145→            yield {"type": "conversation_created", "conversation_id": self.conversation_id}
   146→
   147→        # Build permissions list for read-only access
   148→        permissions_list = [
   149→            "Read(./**)",
   150→            "Glob(./**)",
   151→            "Grep(./**)",
   152→            "WebFetch",
   153→            "WebSearch",
   154→            *READONLY_FEATURE_MCP_TOOLS,
   155→        ]
   156→
   157→        # Create security settings file
   158→        security_settings = {
   159→            "sandbox": {"enabled": False},  # No bash, so sandbox not needed
   160→            "permissions": {
   161→                "defaultMode": "bypassPermissions",  # Read-only, no dangerous ops
   162→                "allow": permissions_list,
   163→            },
   164→        }
   165→        settings_file = self.project_dir / ".claude_assistant_settings.json"
   166→        with open(settings_file, "w") as f:
   167→            json.dump(security_settings, f, indent=2)
   168→
   169→        # Build MCP servers config - only features MCP for read-only access
   170→        mcp_servers = {
   171→            "features": {
   172→                "command": sys.executable,
   173→                "args": ["-m", "mcp_server.feature_mcp"],
   174→                "env": {
   175→                    **os.environ,
   176→                    "PROJECT_DIR": str(self.project_dir.resolve()),
   177→                    "PYTHONPATH": str(ROOT_DIR.resolve()),
   178→                },
   179→            },
   180→        }
   181→
   182→        # Get system prompt with project context
   183→        system_prompt = get_system_prompt(self.project_name, self.project_dir)
   184→
   185→        # Use system Claude CLI
   186→        system_cli = shutil.which("claude")
   187→
   188→        try:
   189→            self.client = ClaudeSDKClient(
   190→                options=ClaudeAgentOptions(
   191→                    model="claude-opus-4-5-20251101",
   192→                    cli_path=system_cli,
   193→                    system_prompt=system_prompt,
   194→                    allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS],
   195→                    mcp_servers=mcp_servers,
   196→                    permission_mode="bypassPermissions",
   197→                    max_turns=100,
   198→                    cwd=str(self.project_dir.resolve()),
   199→                    settings=str(settings_file.resolve()),
   200→                )
   201→            )
   202→            await self.client.__aenter__()
   203→            self._client_entered = True
   204→        except Exception as e:
   205→            logger.exception("Failed to create Claude client")
   206→            yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
   207→            return
   208→
   209→        # Send initial greeting
   210→        try:
   211→            greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
   212→
   213→            # Store the greeting in the database
   214→            add_message(self.project_dir, self.conversation_id, "assistant", greeting)
   215→
   216→            yield {"type": "text", "content": greeting}
   217→            yield {"type": "response_done"}
   218→        except Exception as e:
   219→            logger.exception("Failed to send greeting")
   220→            yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}
   221→
   222→    async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
   223→        """
   224→        Send user message and stream Claude's response.
   225→
   226→        Args:
   227→            user_message: The user's message
   228→
   229→        Yields:
   230→            Message chunks:
   231→            - {"type": "text", "content": str}
   232→            - {"type": "tool_call", "tool": str, "input": dict}
   233→            - {"type": "response_done"}
   234→            - {"type": "error", "content": str}
   235→        """
   236→        if not self.client:
   237→            yield {"type": "error", "content": "Session not initialized. Call start() first."}
   238→            return
   239→
   240→        if self.conversation_id is None:
   241→            yield {"type": "error", "content": "No conversation ID set."}
   242→            return
   243→
   244→        # Store user message in database
   245→        add_message(self.project_dir, self.conversation_id, "user", user_message)
   246→
   247→        try:
   248→            async for chunk in self._query_claude(user_message):
   249→                yield chunk
   250→            yield {"type": "response_done"}
   251→        except Exception as e:
   252→            logger.exception("Error during Claude query")
   253→            yield {"type": "error", "content": f"Error: {str(e)}"}
   254→
   255→    async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
   256→        """
   257→        Internal method to query Claude and stream responses.
   258→
   259→        Handles tool calls and text responses.
   260→        """
   261→        if not self.client:
   262→            return
   263→
   264→        # Send message to Claude
   265→        await self.client.query(message)
   266→
   267→        full_response = ""
   268→
   269→        # Stream the response
   270→        async for msg in self.client.receive_response():
   271→            msg_type = type(msg).__name__
   272→
   273→            if msg_type == "AssistantMessage" and hasattr(msg, "content"):
   274→                for block in msg.content:
   275→                    block_type = type(block).__name__
   276→
   277→                    if block_type == "TextBlock" and hasattr(block, "text"):
   278→                        text = block.text
   279→                        if text:
   280→                            full_response += text
   281→                            yield {"type": "text", "content": text}
   282→
   283→                    elif block_type == "ToolUseBlock" and hasattr(block, "name"):
   284→                        tool_name = block.name
   285→                        tool_input = getattr(block, "input", {})
   286→                        yield {
   287→                            "type": "tool_call",
   288→                            "tool": tool_name,
   289→                            "input": tool_input,
   290→                        }
   291→
   292→        # Store the complete response in the database
   293→        if full_response and self.conversation_id:
   294→            add_message(self.project_dir, self.conversation_id, "assistant", full_response)
   295→
   296→    def get_conversation_id(self) -> Optional[int]:
   297→        """Get the current conversation ID."""
   298→        return self.conversation_id
   299→
   300→
   301→# Session registry with thread safety
   302→_sessions: dict[str, AssistantChatSession] = {}
   303→_sessions_lock = threading.Lock()
   304→
   305→
   306→def get_session(project_name: str) -> Optional[AssistantChatSession]:
   307→    """Get an existing session for a project."""
   308→    with _sessions_lock:
   309→        return _sessions.get(project_name)
   310→
   311→
   312→async def create_session(
   313→    project_name: str,
   314→    project_dir: Path,
   315→    conversation_id: Optional[int] = None
   316→) -> AssistantChatSession:
   317→    """
   318→    Create a new session for a project, closing any existing one.
   319→
   320→    Args:
   321→        project_name: Name of the project
   322→        project_dir: Absolute path to the project directory
   323→        conversation_id: Optional conversation ID to resume
   324→    """
   325→    old_session: Optional[AssistantChatSession] = None
   326→
   327→    with _sessions_lock:
   328→        old_session = _sessions.pop(project_name, None)
   329→        session = AssistantChatSession(project_name, project_dir, conversation_id)
   330→        _sessions[project_name] = session
   331→
   332→    if old_session:
   333→        try:
   334→            await old_session.close()
   335→        except Exception as e:
   336→            logger.warning(f"Error closing old session for {project_name}: {e}")
   337→
   338→    return session
   339→
   340→
   341→async def remove_session(project_name: str) -> None:
   342→    """Remove and close a session."""
   343→    session: Optional[AssistantChatSession] = None
   344→
   345→    with _sessions_lock:
   346→        session = _sessions.pop(project_name, None)
   347→
   348→    if session:
   349→        try:
   350→            await session.close()
   351→        except Exception as e:
   352→            logger.warning(f"Error closing session for {project_name}: {e}")
   353→
   354→
   355→def list_sessions() -> list[str]:
   356→    """List all active session project names."""
   357→    with _sessions_lock:
   358→        return list(_sessions.keys())
   359→
   360→
   361→async def cleanup_all_sessions() -> None:
   362→    """Close all active sessions. Called on server shutdown."""
   363→    sessions_to_close: list[AssistantChatSession] = []
   364→
   365→    with _sessions_lock:
   366→        sessions_to_close = list(_sessions.values())
   367→        _sessions.clear()
   368→
   369→    for session in sessions_to_close:
   370→        try:
   371→            await session.close()
   372→        except Exception as e:
   373→            logger.warning(f"Error closing session {session.project_name}: {e}")
   374→

<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 Chat Session
======================

Manages read-only conversational assistant sessions for projects.
The assistant can answer questions about the codebase and features
but cannot modify any files.
"""

import json
import logging
import os
import shutil
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator, Optional

from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient

from .assistant_database import (
    add_message,
    create_conversation,
)

logger = logging.getLogger(__name__)

# Root directory of the project
ROOT_DIR = Path(__file__).parent.parent.parent

# Read-only feature MCP tools (no mark_passing, skip, create_bulk)
READONLY_FEATURE_MCP_TOOLS = [
    "mcp__features__feature_get_stats",
    "mcp__features__feature_get_next",
    "mcp__features__feature_get_for_regression",
]

# Read-only built-in tools (no Write, Edit, Bash)
READONLY_BUILTIN_TOOLS = [
    "Read",
    "Glob",
    "Grep",
    "WebFetch",
    "WebSearch",
]

def get_system_prompt(project_name: str, project_dir: Path) -> str:
    """Generate the system prompt for the assistant with project context."""
    # Try to load app_spec.txt for context
    app_spec_content = ""
    app_spec_path = project_dir / "prompts" / "app_spec.txt"
    if app_spec_path.exists():
        try:
            app_spec_content = app_spec_path.read_text(encoding="utf-8")
            # Truncate if too long
            if len(app_spec_content) > 5000:
                app_spec_content = app_spec_content[:5000] + "\n... (truncated)"
        except Exception as e:
            logger.warning(f"Failed to read app_spec.txt: {e}")

    return f"""You are a helpful project assistant for the "{project_name}" project.

Your role is to help users understand the codebase, answer questions about features, and explain how code works. You have READ-ONLY access to the project files.

IMPORTANT: You CANNOT modify any files. You can only:
- Read and analyze source code files
- Search for patterns in the codebase
- Look up documentation online
- Check feature progress and status

If the user asks you to make changes, politely explain that you're a read-only assistant and they should use the main coding agent for modifications.

## Project Specification

{app_spec_content if app_spec_content else "(No app specification found)"}

## Available Tools

You have access to these read-only tools:
- **Read**: Read file contents
- **Glob**: Find files by pattern (e.g., "**/*.tsx")
- **Grep**: Search file contents with regex
- **WebFetch/WebSearch**: Look up documentation online
- **feature_get_stats**: Get feature completion progress
- **feature_get_next**: See the next pending feature
- **feature_get_for_regression**: See passing features

## Guidelines

1. Be concise and helpful
2. When explaining code, reference specific file paths and line numbers
3. Use the feature tools to answer questions about project progress
4. Search the codebase to find relevant information before answering
5. If you're unsure, say so rather than guessing"""

class AssistantChatSession:
    """
    Manages a read-only assistant conversation for a project.

    Uses Claude Opus 4.5 with only read-only tools enabled.
    Persists conversation history to SQLite.
    """

    def __init__(self, project_name: str, project_dir: Path, conversation_id: Optional[int] = None):
        """
        Initialize the session.

        Args:
            project_name: Name of the project
            project_dir: Absolute path to the project directory
            conversation_id: Optional existing conversation ID to resume
        """
        self.project_name = project_name
        self.project_dir = project_dir
        self.conversation_id = conversation_id
        self.client: Optional[ClaudeSDKClient] = None
        self._client_entered: bool = False
        self.created_at = datetime.now()

    async def close(self) -> None:
        """Clean up resources and close the Claude client."""
        if self.client and self._client_entered:
            try:
                await self.client.__aexit__(None, None, None)
            except Exception as e:
                logger.warning(f"Error closing Claude client: {e}")
            finally:
                self._client_entered = False
                self.client = None

    async def start(self) -> AsyncGenerator[dict, None]:
        """
        Initialize session with the Claude client.

        Creates a new conversation if none exists, then sends an initial greeting.
        Yields message chunks as they stream in.
        """
        # Create a new conversation if we don't have one
        if self.conversation_id is None:
            conv = create_conversation(self.project_dir, self.project_name)
            self.conversation_id = conv.id
            yield {"type": "conversation_created", "conversation_id": self.conversation_id}

        # Build permissions list for read-only access
        permissions_list = [
            "Read(./**)",
            "Glob(./**)",
            "Grep(./**)",
            "WebFetch",
            "WebSearch",
            *READONLY_FEATURE_MCP_TOOLS,
        ]

        # Create security settings file
        security_settings = {
            "sandbox": {"enabled": False},  # No bash, so sandbox not needed
            "permissions": {
                "defaultMode": "bypassPermissions",  # Read-only, no dangerous ops
                "allow": permissions_list,
            },
        }
        settings_file = self.project_dir / ".claude_assistant_settings.json"
        with open(settings_file, "w") as f:
            json.dump(security_settings, f, indent=2)

        # Build MCP servers config - only features MCP for read-only access
        mcp_servers = {
            "features": {
                "command": sys.executable,
                "args": ["-m", "mcp_server.feature_mcp"],
                "env": {
                    **os.environ,
                    "PROJECT_DIR": str(self.project_dir.resolve()),
                    "PYTHONPATH": str(ROOT_DIR.resolve()),
                },
            },
        }

        # Get system prompt with project context
        system_prompt = get_system_prompt(self.project_name, self.project_dir)

        # Use system Claude CLI
        system_cli = shutil.which("claude")

        try:
            self.client = ClaudeSDKClient(
                options=ClaudeAgentOptions(
                    model="claude-opus-4-5-20251101",
                    cli_path=system_cli,
                    system_prompt=system_prompt,
                    allowed_tools=[*READONLY_BUILTIN_TOOLS, *READONLY_FEATURE_MCP_TOOLS],
                    mcp_servers=mcp_servers,
                    permission_mode="bypassPermissions",
                    max_turns=100,
                    cwd=str(self.project_dir.resolve()),
                    settings=str(settings_file.resolve()),
                )
            )
            await self.client.__aenter__()
            self._client_entered = True
        except Exception as e:
            logger.exception("Failed to create Claude client")
            yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
            return

        # Send initial greeting
        try:
            greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"

            # Store the greeting in the database
            add_message(self.project_dir, self.conversation_id, "assistant", greeting)

            yield {"type": "text", "content": greeting}
            yield {"type": "response_done"}
        except Exception as e:
            logger.exception("Failed to send greeting")
            yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}

    async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
        """
        Send user message and stream Claude's response.

        Args:
            user_message: The user's message

        Yields:
            Message chunks:
            - {"type": "text", "content": str}
            - {"type": "tool_call", "tool": str, "input": dict}
            - {"type": "response_done"}
            - {"type": "error", "content": str}
        """
        if not self.client:
            yield {"type": "error", "content": "Session not initialized. Call start() first."}
            return

        if self.conversation_id is None:
            yield {"type": "error", "content": "No conversation ID set."}
            return

        # Store user message in database
        add_message(self.project_dir, self.conversation_id, "user", user_message)

        try:
            async for chunk in self._query_claude(user_message):
                yield chunk
            yield {"type": "response_done"}
        except Exception as e:
            logger.exception("Error during Claude query")
            yield {"type": "error", "content": f"Error: {str(e)}"}

    async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
        """
        Internal method to query Claude and stream responses.

        Handles tool calls and text responses.
        """
        if not self.client:
            return

        # Send message to Claude
        await self.client.query(message)

        full_response = ""

        # Stream the response
        async for msg in self.client.receive_response():
            msg_type = type(msg).__name__

            if msg_type == "AssistantMessage" and hasattr(msg, "content"):
                for block in msg.content:
                    block_type = type(block).__name__

                    if block_type == "TextBlock" and hasattr(block, "text"):
                        text = block.text
                        if text:
                            full_response += text
                            yield {"type": "text", "content": text}

                    elif block_type == "ToolUseBlock" and hasattr(block, "name"):
                        tool_name = block.name
                        tool_input = getattr(block, "input", {})
                        yield {
                            "type": "tool_call",
                            "tool": tool_name,
                            "input": tool_input,
                        }

        # Store the complete response in the database
        if full_response and self.conversation_id:
            add_message(self.project_dir, self.conversation_id, "assistant", full_response)

    def get_conversation_id(self) -> Optional[int]:
        """Get the current conversation ID."""
        return self.conversation_id

# Session registry with thread safety
_sessions: dict[str, AssistantChatSession] = {}
_sessions_lock = threading.Lock()

def get_session(project_name: str) -> Optional[AssistantChatSession]:
    """Get an existing session for a project."""
    with _sessions_lock:
        return _sessions.get(project_name)

async def create_session(
    project_name: str,
    project_dir: Path,
    conversation_id: Optional[int] = None
) -> AssistantChatSession:
    """
    Create a new session for a project, closing any existing one.

    Args:
        project_name: Name of the project
        project_dir: Absolute path to the project directory
        conversation_id: Optional conversation ID to resume
    """
    old_session: Optional[AssistantChatSession] = None

    with _sessions_lock:
        old_session = _sessions.pop(project_name, None)
        session = AssistantChatSession(project_name, project_dir, conversation_id)
        _sessions[project_name] = session

    if old_session:
        try:
            await old_session.close()
        except Exception as e:
            logger.warning(f"Error closing old session for {project_name}: {e}")

    return session

async def remove_session(project_name: str) -> None:
    """Remove and close a session."""
    session: Optional[AssistantChatSession] = None

    with _sessions_lock:
        session = _sessions.pop(project_name, None)

    if session:
        try:
            await session.close()
        except Exception as e:
            logger.warning(f"Error closing session for {project_name}: {e}")

def list_sessions() -> list[str]:
    """List all active session project names."""
    with _sessions_lock:
        return list(_sessions.keys())

async def cleanup_all_sessions() -> None:
    """Close all active sessions. Called on server shutdown."""
    sessions_to_close: list[AssistantChatSession] = []

    with _sessions_lock:
        sessions_to_close = list(_sessions.values())
        _sessions.clear()

    for session in sessions_to_close:
        try:
            await session.close()
        except Exception as e:
            logger.warning(f"Error closing session {session.project_name}: {e}")