homelab/telegram/session_manager.py
Mikkel Georgsen 74f12a13fc feat(03-01): extend session metadata and PID tracking
- Add idle_timeout field (default 600s) to session metadata
- Add get_session_timeout() helper to SessionManager
- Add pid property to ClaudeSubprocess for PID tracking
- Enables lifecycle management to query timeout values and track process PIDs
2026-02-04 23:28:40 +00:00

401 lines
12 KiB
Python

"""
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/<name>/
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)