Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/claude/facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ async def _find_resumable_session(
for s in sessions
if s.project_path == working_directory
and bool(s.session_id)
and not s.is_expired(self.config.session_timeout_hours)
and not self.session_manager._is_session_expired(s)
]

if not matching_sessions:
Expand Down Expand Up @@ -259,7 +259,7 @@ async def get_user_sessions(self, user_id: int) -> List[Dict[str, Any]]:
"total_cost": s.total_cost,
"message_count": s.message_count,
"tools_used": s.tools_used,
"expired": s.is_expired(self.config.session_timeout_hours),
"expired": self.session_manager._is_session_expired(s),
}
for s in sessions
]
Expand Down
52 changes: 43 additions & 9 deletions src/claude/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional
from zoneinfo import ZoneInfo

import structlog

Expand Down Expand Up @@ -39,10 +40,35 @@ class ClaudeSession:
tools_used: List[str] = field(default_factory=list)
is_new_session: bool = False # True if session hasn't been sent to Claude Code yet

def is_expired(self, timeout_hours: int) -> bool:
"""Check if session has expired."""
age = datetime.now(UTC) - _to_utc(self.last_used)
return age > timedelta(hours=timeout_hours)
def is_expired(
self,
timeout_hours: int,
daily_reset_hour: Optional[int] = None,
daily_reset_tz: str = "UTC",
) -> bool:
"""Check if session has expired.

Expires if age exceeds timeout_hours OR if the session spans
the daily reset boundary (e.g. 3:00 AM local time).
"""
now = datetime.now(UTC)
age = now - _to_utc(self.last_used)
if age > timedelta(hours=timeout_hours):
return True

if daily_reset_hour is not None:
tz = ZoneInfo(daily_reset_tz)
now_local = now.astimezone(tz)
today_reset = now_local.replace(
hour=daily_reset_hour, minute=0, second=0, microsecond=0
)
if now_local < today_reset:
today_reset -= timedelta(days=1)
last_used_local = _to_utc(self.last_used).astimezone(tz)
if last_used_local < today_reset:
return True

return False

def update_usage(self, response: ClaudeResponse) -> None:
"""Update session with usage from response."""
Expand Down Expand Up @@ -123,6 +149,14 @@ def __init__(self, config: Settings, storage: SessionStorage):
self.storage = storage
self.active_sessions: Dict[str, ClaudeSession] = {}

def _is_session_expired(self, session: ClaudeSession) -> bool:
"""Check if session is expired using all configured rules."""
return session.is_expired(
self.config.session_timeout_hours,
daily_reset_hour=self.config.session_daily_reset_hour,
daily_reset_tz=self.config.session_daily_reset_timezone,
)

async def get_or_create_session(
self,
user_id: int,
Expand All @@ -147,14 +181,14 @@ async def get_or_create_session(
session_owner=session.user_id,
requesting_user=user_id,
)
elif not session.is_expired(self.config.session_timeout_hours):
elif not self._is_session_expired(session):
logger.debug("Using active session", session_id=session_id)
return session

# Try to load from storage (filtered by user_id)
if session_id:
session = await self.storage.load_session(session_id, user_id)
if session and not session.is_expired(self.config.session_timeout_hours):
if session and not self._is_session_expired(session):
self.active_sessions[session_id] = session
logger.info("Loaded session from storage", session_id=session_id)
return session
Expand Down Expand Up @@ -244,7 +278,7 @@ async def cleanup_expired_sessions(self) -> int:
expired_count = 0

for session in all_sessions:
if session.is_expired(self.config.session_timeout_hours):
if self._is_session_expired(session):
await self.remove_session(session.session_id)
expired_count += 1

Expand Down Expand Up @@ -281,7 +315,7 @@ async def get_session_info(self, session_id: str, user_id: int) -> Optional[Dict
"turns": session.total_turns,
"messages": session.message_count,
"tools_used": session.tools_used,
"expired": session.is_expired(self.config.session_timeout_hours),
"expired": self._is_session_expired(session),
}

return None
Expand All @@ -293,7 +327,7 @@ async def get_user_session_summary(self, user_id: int) -> Dict:
total_cost = sum(s.total_cost for s in sessions)
total_messages = sum(s.message_count for s in sessions)
active_sessions = [
s for s in sessions if not s.is_expired(self.config.session_timeout_hours)
s for s in sessions if not self._is_session_expired(s)
]

return {
Expand Down
10 changes: 10 additions & 0 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ class Settings(BaseSettings):
max_sessions_per_user: int = Field(
DEFAULT_MAX_SESSIONS_PER_USER, description="Max concurrent sessions"
)
session_daily_reset_hour: Optional[int] = Field(
default=None,
description="Hour of day (0-23) to force session reset. None = disabled.",
ge=0,
le=23,
)
session_daily_reset_timezone: str = Field(
default="UTC",
description="Timezone for daily reset hour (e.g. 'Europe/Lisbon')",
)

# Features
enable_mcp: bool = Field(False, description="Enable Model Context Protocol")
Expand Down