""" Session management for Claude Code Telegram bot. This module provides the SessionManager class that handles lifecycle management for isolated Claude Code conversation sessions. Each session is a directory containing metadata, persona configuration, and Claude Code's .claude/ data. Sessions enable: - Multiple independent Claude Code conversations - Persona-based behavior customization - Context isolation per conversation thread - Session switching without losing state Directory structure: ~/telegram/sessions// metadata.json # Session state (status, timestamps, PID) persona.json # Persona configuration (copied from library) .claude/ # Auto-created by Claude Code CLI """ import json import logging import re import shutil import subprocess from datetime import datetime, timezone from pathlib import Path from typing import Optional logger = logging.getLogger(__name__) class SessionManager: """Manages Claude Code session lifecycle and persona library.""" SESSION_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$') MAX_NAME_LENGTH = 50 def __init__(self, base_dir: Optional[Path] = None): """ Initialize SessionManager. Args: base_dir: Base directory for sessions. Defaults to ~/homelab/telegram/sessions/ """ if base_dir is None: # Use homelab directory (where bot.py lives) homelab_dir = Path.home() / "homelab" base_dir = homelab_dir / "telegram" / "sessions" self.base_dir = Path(base_dir) # Personas are always in homelab/telegram/personas self.personas_dir = Path.home() / "homelab" / "telegram" / "personas" self.active_session: Optional[str] = None # Create directories if they don't exist self.base_dir.mkdir(parents=True, exist_ok=True) self.personas_dir.mkdir(parents=True, exist_ok=True) # Load active session from disk if any self._load_active_session() logger.info(f"SessionManager initialized: base_dir={self.base_dir}") def _load_active_session(self) -> None: """Load active session name from existing sessions.""" try: sessions = self.list_sessions() for session in sessions: if session.get('status') == 'active': self.active_session = session['name'] logger.debug(f"Loaded active session: {self.active_session}") return except Exception as e: logger.warning(f"Failed to load active session: {e}") def _validate_session_name(self, name: str) -> None: """ Validate session name. Args: name: Session name to validate Raises: ValueError: If name is invalid """ if not name: raise ValueError("Session name cannot be empty") if len(name) > self.MAX_NAME_LENGTH: raise ValueError( f"Session name too long (max {self.MAX_NAME_LENGTH} chars): {name}" ) if not self.SESSION_NAME_PATTERN.match(name): raise ValueError( f"Invalid session name '{name}': only alphanumeric, hyphens, " "and underscores allowed" ) def _read_metadata(self, name: str) -> dict: """Read session metadata from disk.""" metadata_path = self.base_dir / name / "metadata.json" if not metadata_path.exists(): raise ValueError(f"Session '{name}' does not exist") with metadata_path.open('r') as f: return json.load(f) def _write_metadata(self, name: str, metadata: dict) -> None: """Write session metadata to disk.""" metadata_path = self.base_dir / name / "metadata.json" with metadata_path.open('w') as f: json.dump(metadata, f, indent=2) logger.debug(f"Wrote metadata for session '{name}'") def create_session(self, name: str, persona: Optional[str] = None) -> Path: """ Create a new session. Args: name: Session name (alphanumeric, hyphens, underscores only) persona: Persona name from library (defaults to 'default') Returns: Path to created session directory Raises: ValueError: If session already exists or name is invalid """ self._validate_session_name(name) session_dir = self.base_dir / name if session_dir.exists(): raise ValueError(f"Session '{name}' already exists") # Use default persona if not specified if persona is None: persona = 'default' # Load persona from library persona_source = self.personas_dir / f"{persona}.json" if not persona_source.exists(): raise FileNotFoundError( f"Persona '{persona}' not found in library: {persona_source}" ) # Create session directory session_dir.mkdir(parents=True, exist_ok=False) logger.info(f"Created session directory: {session_dir}") # Copy persona to session persona_dest = session_dir / "persona.json" persona_dest.write_text(persona_source.read_text()) logger.debug(f"Copied persona '{persona}' to session") # Create metadata now = datetime.now(timezone.utc).isoformat() metadata = { "name": name, "created": now, "last_active": now, "persona": persona, "pid": None, "status": "idle", "idle_timeout": 600 } self._write_metadata(name, metadata) logger.info(f"Created session '{name}' with persona '{persona}'") return session_dir def switch_session(self, name: str) -> Optional[str]: """ Switch to a different session. Args: name: Session name to switch to Returns: Previous active session name, or None if no previous session Raises: ValueError: If session does not exist """ self._validate_session_name(name) if not self.session_exists(name): raise ValueError(f"Session '{name}' does not exist") # No-op if already active if self.active_session == name: logger.debug(f"Session '{name}' already active") return None previous_session = self.active_session # Mark previous session as suspended if previous_session: try: prev_meta = self._read_metadata(previous_session) if prev_meta['status'] == 'active': prev_meta['status'] = 'suspended' self._write_metadata(previous_session, prev_meta) logger.debug(f"Suspended previous session: {previous_session}") except Exception as e: logger.warning(f"Failed to suspend previous session: {e}") # Mark new session as active new_meta = self._read_metadata(name) new_meta['status'] = 'active' new_meta['last_active'] = datetime.now(timezone.utc).isoformat() self._write_metadata(name, new_meta) self.active_session = name logger.info(f"Switched to session '{name}'") return previous_session def get_session(self, name: str) -> dict: """ Get session metadata. Args: name: Session name Returns: Session metadata dict Raises: ValueError: If session does not exist """ return self._read_metadata(name) def list_sessions(self) -> list[dict]: """ List all sessions. Returns: List of session metadata dicts, sorted by last_active (most recent first) """ sessions = [] if not self.base_dir.exists(): return sessions for session_dir in self.base_dir.iterdir(): if not session_dir.is_dir(): continue metadata_path = session_dir / "metadata.json" if not metadata_path.exists(): logger.warning(f"Session directory missing metadata: {session_dir}") continue try: with metadata_path.open('r') as f: metadata = json.load(f) sessions.append(metadata) except Exception as e: logger.error(f"Failed to read metadata for {session_dir}: {e}") continue # Sort by last_active, most recent first sessions.sort( key=lambda s: s.get('last_active', ''), reverse=True ) return sessions def get_active_session(self) -> Optional[str]: """ Get active session name. Returns: Active session name, or None if no active session """ return self.active_session def update_session(self, name: str, **kwargs) -> None: """ Update session metadata fields. Args: name: Session name **kwargs: Fields to update Raises: ValueError: If session does not exist """ metadata = self._read_metadata(name) metadata.update(kwargs) self._write_metadata(name, metadata) logger.debug(f"Updated session '{name}': {kwargs}") def get_session_timeout(self, name: str) -> int: """ Get session idle timeout in seconds. Args: name: Session name Returns: Idle timeout in seconds (defaults to 600s if not set) Raises: ValueError: If session does not exist """ metadata = self._read_metadata(name) return metadata.get('idle_timeout', 600) def session_exists(self, name: str) -> bool: """ Check if session exists. Args: name: Session name Returns: True if session exists, False otherwise """ session_dir = self.base_dir / name return session_dir.exists() and (session_dir / "metadata.json").exists() def get_session_dir(self, name: str) -> Path: """ Get session directory path. Args: name: Session name Returns: Path to session directory """ return self.base_dir / name def archive_session(self, name: str) -> Path: """ Archive a session by compressing it with tar+pigz and removing the original. Args: name: Session name to archive Returns: Path to the archive file Raises: ValueError: If session does not exist """ if not self.session_exists(name): raise ValueError(f"Session '{name}' does not exist") # Clear active session if archiving the active one if self.active_session == name: self.active_session = None # Create archive directory archive_dir = self.base_dir.parent / "sessions_archive" archive_dir.mkdir(parents=True, exist_ok=True) # Build archive filename with timestamp timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") archive_name = f"{name}_{timestamp}.tar.gz" archive_path = archive_dir / archive_name # Compress with tar + pigz session_dir = self.base_dir / name subprocess.run( ["tar", "--use-compress-program=pigz", "-cf", str(archive_path), "-C", str(self.base_dir), name], check=True, ) # Remove original session directory shutil.rmtree(session_dir) logger.info(f"Archived session '{name}' to {archive_path}") return archive_path def load_persona(self, name: str) -> dict: """ Load persona from library. Args: name: Persona name Returns: Persona configuration dict Raises: FileNotFoundError: If persona does not exist """ persona_path = self.personas_dir / f"{name}.json" if not persona_path.exists(): raise FileNotFoundError( f"Persona '{name}' not found in library: {persona_path}" ) with persona_path.open('r') as f: return json.load(f)