Team Ai
Apppublic

Jack1808/Claude_Code

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
session.py258 linesDownload Raw Back to cli
1"""Claude Code CLI session management."""2 3import asyncio4import json5import os6from collections.abc import AsyncGenerator7from typing import Any8 9from loguru import logger10 11from .process_registry import register_pid, unregister_pid12 13 14class CLISession:15    """Manages a single persistent Claude Code CLI subprocess."""16 17    def __init__(18        self,19        workspace_path: str,20        api_url: str,21        allowed_dirs: list[str] | None = None,22        plans_directory: str | None = None,23    ):24        self.workspace = os.path.normpath(os.path.abspath(workspace_path))25        self.api_url = api_url26        self.allowed_dirs = [os.path.normpath(d) for d in (allowed_dirs or [])]27        self.plans_directory = plans_directory28        self.process: asyncio.subprocess.Process | None = None29        self.current_session_id: str | None = None30        self._is_busy = False31        self._cli_lock = asyncio.Lock()32 33    @property34    def is_busy(self) -> bool:35        """Check if a task is currently running."""36        return self._is_busy37 38    async def start_task(39        self, prompt: str, session_id: str | None = None, fork_session: bool = False40    ) -> AsyncGenerator[dict]:41        """42        Start a new task or continue an existing session.43 44        Args:45            prompt: The user's message/prompt46            session_id: Optional session ID to resume47 48        Yields:49            Event dictionaries from the CLI50        """51        async with self._cli_lock:52            self._is_busy = True53            env = os.environ.copy()54 55            if "ANTHROPIC_API_KEY" not in env:56                env["ANTHROPIC_API_KEY"] = "sk-placeholder-key-for-proxy"57 58            env["ANTHROPIC_API_URL"] = self.api_url59            if self.api_url.endswith("/v1"):60                env["ANTHROPIC_BASE_URL"] = self.api_url[:-3]61            else:62                env["ANTHROPIC_BASE_URL"] = self.api_url63 64            env["TERM"] = "dumb"65            env["PYTHONIOENCODING"] = "utf-8"66 67            # Build command68            if session_id and not session_id.startswith("pending_"):69                cmd = [70                    "claude",71                    "--resume",72                    session_id,73                ]74                if fork_session:75                    cmd.append("--fork-session")76                cmd += [77                    "-p",78                    prompt,79                    "--output-format",80                    "stream-json",81                    "--dangerously-skip-permissions",82                    "--verbose",83                ]84                logger.info(f"Resuming Claude session {session_id}")85            else:86                cmd = [87                    "claude",88                    "-p",89                    prompt,90                    "--output-format",91                    "stream-json",92                    "--dangerously-skip-permissions",93                    "--verbose",94                ]95                logger.info("Starting new Claude session")96 97            if self.allowed_dirs:98                for d in self.allowed_dirs:99                    cmd.extend(["--add-dir", d])100 101            if self.plans_directory is not None:102                settings_json = json.dumps({"plansDirectory": self.plans_directory})103                cmd.extend(["--settings", settings_json])104 105            try:106                self.process = await asyncio.create_subprocess_exec(107                    *cmd,108                    stdout=asyncio.subprocess.PIPE,109                    stderr=asyncio.subprocess.PIPE,110                    cwd=self.workspace,111                    env=env,112                )113                if self.process and self.process.pid:114                    register_pid(self.process.pid)115 116                if not self.process or not self.process.stdout:117                    yield {"type": "exit", "code": 1}118                    return119 120                session_id_extracted = False121                buffer = bytearray()122 123                try:124                    while True:125                        chunk = await self.process.stdout.read(65536)126                        if not chunk:127                            if buffer:128                                line_str = buffer.decode(129                                    "utf-8", errors="replace"130                                ).strip()131                                if line_str:132                                    async for event in self._handle_line_gen(133                                        line_str, session_id_extracted134                                    ):135                                        if event.get("type") == "session_info":136                                            session_id_extracted = True137                                        yield event138                            break139 140                        buffer.extend(chunk)141 142                        while True:143                            newline_pos = buffer.find(b"\n")144                            if newline_pos == -1:145                                break146 147                            line = buffer[:newline_pos]148                            buffer = buffer[newline_pos + 1 :]149 150                            line_str = line.decode("utf-8", errors="replace").strip()151                            if line_str:152                                async for event in self._handle_line_gen(153                                    line_str, session_id_extracted154                                ):155                                    if event.get("type") == "session_info":156                                        session_id_extracted = True157                                    yield event158                except asyncio.CancelledError:159                    # Cancelling the handler task should not leave a Claude CLI160                    # subprocess running in the background.161                    try:162                        await asyncio.shield(self.stop())163                    finally:164                        raise165 166                stderr_text = None167                if self.process.stderr:168                    stderr_output = await self.process.stderr.read()169                    if stderr_output:170                        stderr_text = stderr_output.decode(171                            "utf-8", errors="replace"172                        ).strip()173                        logger.error(f"Claude CLI Stderr: {stderr_text}")174                        # Yield stderr as error event so it shows in UI175                        if stderr_text:176                            logger.info("CLI_SESSION: Yielding error event from stderr")177                            yield {"type": "error", "error": {"message": stderr_text}}178 179                return_code = await self.process.wait()180                logger.info(181                    f"Claude CLI exited with code {return_code}, stderr_present={bool(stderr_text)}"182                )183                if return_code != 0 and not stderr_text:184                    logger.warning(185                        f"CLI_SESSION: Process exited with code {return_code} but no stderr captured"186                    )187                yield {188                    "type": "exit",189                    "code": return_code,190                    "stderr": stderr_text,191                }192            finally:193                self._is_busy = False194                if self.process and self.process.pid:195                    unregister_pid(self.process.pid)196 197    async def _handle_line_gen(198        self, line_str: str, session_id_extracted: bool199    ) -> AsyncGenerator[dict]:200        """Process a single line and yield events."""201        try:202            event = json.loads(line_str)203            if not session_id_extracted:204                extracted_id = self._extract_session_id(event)205                if extracted_id:206                    self.current_session_id = extracted_id207                    logger.info(f"Extracted session ID: {extracted_id}")208                    yield {"type": "session_info", "session_id": extracted_id}209 210            yield event211        except json.JSONDecodeError:212            logger.debug(f"Non-JSON output: {line_str}")213            yield {"type": "raw", "content": line_str}214 215    def _extract_session_id(self, event: Any) -> str | None:216        """Extract session ID from CLI event."""217        if not isinstance(event, dict):218            return None219 220        if "session_id" in event:221            return event["session_id"]222        if "sessionId" in event:223            return event["sessionId"]224 225        for key in ["init", "system", "result", "metadata"]:226            if key in event and isinstance(event[key], dict):227                nested = event[key]228                if "session_id" in nested:229                    return nested["session_id"]230                if "sessionId" in nested:231                    return nested["sessionId"]232 233        if "conversation" in event and isinstance(event["conversation"], dict):234            conv = event["conversation"]235            if "id" in conv:236                return conv["id"]237 238        return None239 240    async def stop(self):241        """Stop the CLI process."""242        if self.process and self.process.returncode is None:243            try:244                logger.info(f"Stopping Claude CLI process {self.process.pid}")245                self.process.terminate()246                try:247                    await asyncio.wait_for(self.process.wait(), timeout=5.0)248                except TimeoutError:249                    self.process.kill()250                    await self.process.wait()251                if self.process and self.process.pid:252                    unregister_pid(self.process.pid)253                return True254            except Exception as e:255                logger.error(f"Error stopping process: {e}")256                return False257        return False258