File size: 7,787 Bytes
2415446
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""Managed Claude Code session pool for messaging."""

import asyncio
import uuid

from loguru import logger

from free_claude_code.cli.claude_env import CLAUDE_BINARY_NAME

from .session import ManagedClaudeSession


class ManagedClaudeSessionManager:
    """
    Manages multiple Claude Code sessions for parallel conversation processing.

    Each new conversation gets its own subprocess. Replies to existing
    conversations reuse the same session instance.
    """

    def __init__(
        self,
        workspace_path: str,
        proxy_root_url: str,
        allowed_dirs: list[str] | None = None,
        claude_bin: str = CLAUDE_BINARY_NAME,
        auth_token: str = "",
        *,
        log_raw_cli_diagnostics: bool = False,
        log_messaging_error_details: bool = False,
    ):
        """
        Initialize the session manager.

        Args:
            workspace_path: Working directory for CLI processes
            proxy_root_url: Root URL for the local proxy
            allowed_dirs: Directories the CLI is allowed to access
        """
        self.workspace = workspace_path
        self.proxy_root_url = proxy_root_url
        self.allowed_dirs = allowed_dirs or []
        self.claude_bin = claude_bin
        self.auth_token = auth_token
        self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
        self._log_messaging_error_details = log_messaging_error_details

        self._sessions: dict[str, ManagedClaudeSession] = {}
        self._pending_sessions: dict[str, ManagedClaudeSession] = {}
        self._temp_to_real: dict[str, str] = {}
        self._real_to_temp: dict[str, str] = {}
        self._closing_sessions: set[ManagedClaudeSession] = set()
        self._lock = asyncio.Lock()

    def _session_for_id(self, session_id: str) -> ManagedClaudeSession | None:
        lookup_id = self._temp_to_real.get(session_id, session_id)
        session = self._sessions.get(lookup_id)
        if session is not None:
            return session
        return self._pending_sessions.get(lookup_id)

    def _forget_session(self, session: ManagedClaudeSession) -> None:
        pending_ids = [
            session_id
            for session_id, owned in self._pending_sessions.items()
            if owned is session
        ]
        real_ids = [
            session_id
            for session_id, owned in self._sessions.items()
            if owned is session
        ]
        for session_id in pending_ids:
            self._pending_sessions.pop(session_id, None)
        for real_id in real_ids:
            self._sessions.pop(real_id, None)
            temp_id = self._real_to_temp.pop(real_id, None)
            if temp_id is not None:
                self._temp_to_real.pop(temp_id, None)
        self._closing_sessions.discard(session)

    async def get_or_create_session(
        self, session_id: str | None = None
    ) -> tuple[ManagedClaudeSession, str, bool]:
        """
        Get an existing session or create a new one.

        Returns:
            Tuple of (session instance, session_id, is_new_session)
        """
        async with self._lock:
            if session_id:
                lookup_id = self._temp_to_real.get(session_id, session_id)

                if lookup_id in self._sessions:
                    session = self._sessions[lookup_id]
                    if session in self._closing_sessions:
                        raise RuntimeError("Managed Claude session is closing.")
                    return session, lookup_id, False
                if lookup_id in self._pending_sessions:
                    session = self._pending_sessions[lookup_id]
                    if session in self._closing_sessions:
                        raise RuntimeError("Managed Claude session is closing.")
                    return session, lookup_id, False

            temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}"

            new_session = ManagedClaudeSession(
                workspace_path=self.workspace,
                proxy_root_url=self.proxy_root_url,
                allowed_dirs=self.allowed_dirs,
                claude_bin=self.claude_bin,
                auth_token=self.auth_token,
                log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
            )
            self._pending_sessions[temp_id] = new_session

            return new_session, temp_id, True

    async def register_real_session_id(
        self, temp_id: str, real_session_id: str
    ) -> bool:
        """Register the real session ID from CLI output."""
        async with self._lock:
            session = self._pending_sessions.get(temp_id)
            if session is None:
                logger.warning(f"Temp session {temp_id} not found")
                return False
            if session in self._closing_sessions:
                logger.warning("Cannot register a closing managed Claude session")
                return False
            existing = self._session_for_id(real_session_id)
            if existing is not None and existing is not session:
                logger.warning(
                    "Cannot register managed Claude session: real ID is already owned"
                )
                return False

            self._pending_sessions.pop(temp_id)
            self._sessions[real_session_id] = session
            self._temp_to_real[temp_id] = real_session_id
            self._real_to_temp[real_session_id] = temp_id

            logger.info(f"Registered session: {temp_id} -> {real_session_id}")
            return True

    async def remove_session(self, session_id: str) -> bool:
        """Remove a session from the manager."""
        async with self._lock:
            session = self._session_for_id(session_id)
            if session is None:
                return False
            self._closing_sessions.add(session)
            stopped = await session.stop()
            if not stopped:
                return False
            self._forget_session(session)
            return True

    async def stop_all(self) -> None:
        """Stop all sessions."""
        async with self._lock:
            all_sessions = list(
                dict.fromkeys(
                    [
                        *self._sessions.values(),
                        *self._pending_sessions.values(),
                        *self._closing_sessions,
                    ]
                )
            )
            self._closing_sessions.update(all_sessions)
            failures = 0
            for session in all_sessions:
                try:
                    stopped = await session.stop()
                except Exception as e:
                    stopped = False
                    if self._log_messaging_error_details:
                        logger.error(
                            "Error stopping session: {}: {}",
                            type(e).__name__,
                            e,
                        )
                    else:
                        logger.error(
                            "Error stopping session: exc_type={}",
                            type(e).__name__,
                        )
                if stopped:
                    self._forget_session(session)
                else:
                    failures += 1

            if failures:
                raise RuntimeError(
                    f"Managed Claude session shutdown failures: {failures}."
                )
            logger.info("All sessions stopped")

    def get_stats(self) -> dict:
        """Get session statistics."""
        return {
            "active_sessions": len(self._sessions),
            "pending_sessions": len(self._pending_sessions),
            "busy_count": sum(1 for s in self._sessions.values() if s.is_busy),
        }