"""Session registry: tracks active project sessions by conversation ID.""" from __future__ import annotations import asyncio import logging import uuid from dataclasses import dataclass, field from typing import Dict, List, Optional from agent.pty_process import run_claude logger = logging.getLogger(__name__) IDLE_TIMEOUT = 30 * 60 # 30 minutes in seconds @dataclass class Session: conv_id: str cwd: str # Stable UUID passed to `claude --session-id` so CC owns the history cc_session_id: str = field(default_factory=lambda: str(uuid.uuid4())) last_activity: float = field(default_factory=lambda: asyncio.get_event_loop().time()) # True after the first message has been sent (so we know to use --resume) started: bool = False def touch(self) -> None: self.last_activity = asyncio.get_event_loop().time() class SessionManager: """Registry of active Claude Code project sessions.""" def __init__(self) -> None: self._sessions: Dict[str, Session] = {} self._lock = asyncio.Lock() self._reaper_task: Optional[asyncio.Task] = None # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ async def start(self) -> None: loop = asyncio.get_event_loop() self._reaper_task = loop.create_task(self._reaper_loop()) async def stop(self) -> None: if self._reaper_task: self._reaper_task.cancel() async with self._lock: self._sessions.clear() async def create(self, conv_id: str, working_dir: str) -> Session: """Register a new session for the given working directory.""" async with self._lock: session = Session(conv_id=conv_id, cwd=working_dir) self._sessions[conv_id] = session logger.info( "Created session %s (cc_session_id=%s) in %s", conv_id, session.cc_session_id, working_dir, ) return session async def send(self, conv_id: str, message: str) -> str: """ Run claude -p with the message in the session's directory. - First message: uses --session-id to establish the CC session. - Subsequent messages: uses --resume so CC has full history. """ async with self._lock: session = self._sessions.get(conv_id) if session is None: raise KeyError(f"No session for conv_id={conv_id!r}") session.touch() cwd = session.cwd cc_session_id = session.cc_session_id first_message = not session.started if first_message: session.started = True output = await run_claude( message, cwd=cwd, cc_session_id=cc_session_id, resume=not first_message, ) return output async def close(self, conv_id: str) -> bool: """Remove a session. Returns True if it existed.""" async with self._lock: if conv_id not in self._sessions: return False del self._sessions[conv_id] logger.info("Closed session %s", conv_id) return True def list_sessions(self) -> list[dict]: return [ {"conv_id": s.conv_id, "cwd": s.cwd, "cc_session_id": s.cc_session_id} for s in self._sessions.values() ] # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ async def _reaper_loop(self) -> None: while True: await asyncio.sleep(60) await self._reap_idle() async def _reap_idle(self) -> None: now = asyncio.get_event_loop().time() async with self._lock: to_close = [ cid for cid, s in self._sessions.items() if (now - s.last_activity) > IDLE_TIMEOUT ] for cid in to_close: del self._sessions[cid] logger.info("Reaped idle session %s", cid) # Module-level singleton manager = SessionManager()