Spaces:
Sleeping
Sleeping
| """Managed Claude Code subprocess session.""" | |
| import asyncio | |
| import os | |
| from collections.abc import AsyncGenerator | |
| from loguru import logger | |
| from free_claude_code.cli.process_registry import ( | |
| kill_pid_tree_best_effort, | |
| register_pid, | |
| unregister_pid, | |
| ) | |
| from free_claude_code.core.trace import trace_event | |
| from .claude import ( | |
| ManagedClaudeConfig, | |
| ManagedClaudeParseState, | |
| ManagedClaudeTaskRequest, | |
| build_managed_claude_invocation, | |
| parse_managed_claude_stdout_line, | |
| ) | |
| from .diagnostics import classify_managed_claude_stderr | |
| # Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained. | |
| _MAX_STDERR_CAPTURE_BYTES = 256 * 1024 | |
| class ManagedClaudeSession: | |
| """Manages a single persistent Claude Code subprocess.""" | |
| def __init__( | |
| self, | |
| workspace_path: str, | |
| proxy_root_url: str, | |
| allowed_dirs: list[str] | None = None, | |
| claude_bin: str = "claude", | |
| auth_token: str = "", | |
| *, | |
| log_raw_cli_diagnostics: bool = False, | |
| ): | |
| self.config = ManagedClaudeConfig( | |
| workspace_path=os.path.normpath(os.path.abspath(workspace_path)), | |
| proxy_root_url=proxy_root_url, | |
| allowed_dirs=[os.path.normpath(d) for d in (allowed_dirs or [])], | |
| claude_bin=claude_bin, | |
| auth_token=auth_token, | |
| ) | |
| self.workspace = self.config.workspace_path | |
| self.proxy_root_url = self.config.proxy_root_url | |
| self.allowed_dirs = self.config.allowed_dirs | |
| self.claude_bin = self.config.claude_bin | |
| self.auth_token = self.config.auth_token | |
| self._log_raw_cli_diagnostics = log_raw_cli_diagnostics | |
| self.process: asyncio.subprocess.Process | None = None | |
| self.current_session_id: str | None = None | |
| self._is_busy = False | |
| self._cli_lock = asyncio.Lock() | |
| self._lifecycle_lock = asyncio.Lock() | |
| self._closed = False | |
| async def _drain_stderr_bounded( | |
| process: asyncio.subprocess.Process, | |
| *, | |
| max_bytes: int = _MAX_STDERR_CAPTURE_BYTES, | |
| ) -> bytes: | |
| """Read stderr concurrently with stdout to avoid subprocess pipe deadlocks. | |
| Retains at most ``max_bytes`` for logging; any excess is discarded, but | |
| the pipe is read until EOF so a noisy child cannot fill the buffer and | |
| block forever. | |
| """ | |
| if not process.stderr: | |
| return b"" | |
| parts: list[bytes] = [] | |
| received = 0 | |
| while True: | |
| chunk = await process.stderr.read(65_536) | |
| if not chunk: | |
| break | |
| if received < max_bytes: | |
| take = min(len(chunk), max_bytes - received) | |
| if take: | |
| parts.append(chunk[:take]) | |
| received += take | |
| # If already at cap, keep reading and discarding until EOF. | |
| return b"".join(parts) | |
| def is_busy(self) -> bool: | |
| """Check if a task is currently running.""" | |
| return self._is_busy | |
| async def start_task( | |
| self, prompt: str, session_id: str | None = None, fork_session: bool = False | |
| ) -> AsyncGenerator[dict]: | |
| """ | |
| Start a new task or continue an existing session. | |
| Args: | |
| prompt: The user's message/prompt | |
| session_id: Optional session ID to resume | |
| Yields: | |
| Event dictionaries from the CLI | |
| """ | |
| async with self._cli_lock: | |
| process: asyncio.subprocess.Process | None = None | |
| termination_confirmed = False | |
| try: | |
| async with self._lifecycle_lock: | |
| if self._closed: | |
| raise RuntimeError("Managed Claude session is closed.") | |
| self._is_busy = True | |
| invocation = build_managed_claude_invocation( | |
| config=self.config, | |
| request=ManagedClaudeTaskRequest( | |
| prompt=prompt, | |
| session_id=session_id, | |
| fork_session=fork_session, | |
| ), | |
| base_env=os.environ, | |
| ) | |
| trace_event( | |
| stage="claude_cli", | |
| event="claude_cli.process.launch", | |
| source="claude_cli", | |
| **invocation.trace_metadata, | |
| ) | |
| process = await asyncio.create_subprocess_exec( | |
| *invocation.argv, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE, | |
| cwd=invocation.cwd, | |
| env=invocation.env, | |
| ) | |
| self.process = process | |
| if process.pid: | |
| register_pid(process.pid) | |
| if not process.stdout: | |
| yield {"type": "exit", "code": 1} | |
| return | |
| parse_state = ManagedClaudeParseState( | |
| log_raw_cli_diagnostics=self._log_raw_cli_diagnostics | |
| ) | |
| buffer = bytearray() | |
| stderr_task: asyncio.Task[bytes] | None = None | |
| if process.stderr: | |
| stderr_task = asyncio.create_task( | |
| self._drain_stderr_bounded(process) | |
| ) | |
| try: | |
| while True: | |
| chunk = await process.stdout.read(65536) | |
| if not chunk: | |
| if buffer: | |
| line_str = buffer.decode( | |
| "utf-8", errors="replace" | |
| ).strip() | |
| if line_str: | |
| async for event in self._handle_line_gen( | |
| line_str, parse_state | |
| ): | |
| yield event | |
| break | |
| buffer.extend(chunk) | |
| while True: | |
| newline_pos = buffer.find(b"\n") | |
| if newline_pos == -1: | |
| break | |
| line = buffer[:newline_pos] | |
| buffer = buffer[newline_pos + 1 :] | |
| line_str = line.decode("utf-8", errors="replace").strip() | |
| if line_str: | |
| async for event in self._handle_line_gen( | |
| line_str, parse_state | |
| ): | |
| yield event | |
| except asyncio.CancelledError: | |
| # Cancelling the handler task should not leave a Claude CLI | |
| # subprocess running in the background. | |
| await asyncio.shield(self.stop()) | |
| raise | |
| finally: | |
| stderr_bytes = b"" | |
| if stderr_task is not None: | |
| stderr_bytes = await stderr_task | |
| stderr_text = None | |
| if stderr_bytes: | |
| raw_stderr_text = stderr_bytes.decode( | |
| "utf-8", errors="replace" | |
| ).strip() | |
| if raw_stderr_text: | |
| diagnostics = classify_managed_claude_stderr(raw_stderr_text) | |
| if diagnostics.has_benign: | |
| logger.info( | |
| "Claude CLI benign stderr diagnostics: lines={}", | |
| len(diagnostics.benign_lines), | |
| ) | |
| stderr_text = diagnostics.fatal_text | |
| if stderr_text: | |
| if self._log_raw_cli_diagnostics: | |
| logger.error("Claude CLI stderr: {}", stderr_text) | |
| else: | |
| logger.error( | |
| "Claude CLI stderr: bytes={} text_chars={}", | |
| len(stderr_bytes), | |
| len(stderr_text), | |
| ) | |
| logger.info("CLI_SESSION: Yielding error event from stderr") | |
| yield {"type": "error", "error": {"message": stderr_text}} | |
| return_code = await process.wait() | |
| termination_confirmed = True | |
| logger.info( | |
| f"Claude CLI exited with code {return_code}, stderr_present={bool(stderr_text)}" | |
| ) | |
| if return_code != 0 and not stderr_text: | |
| logger.warning( | |
| f"CLI_SESSION: Process exited with code {return_code} but no stderr captured" | |
| ) | |
| yield { | |
| "type": "exit", | |
| "code": return_code, | |
| "stderr": stderr_text, | |
| } | |
| finally: | |
| self._is_busy = False | |
| if ( | |
| process | |
| and process.pid | |
| and (termination_confirmed or process.returncode is not None) | |
| ): | |
| unregister_pid(process.pid) | |
| async def _handle_line_gen( | |
| self, line_str: str, parse_state: ManagedClaudeParseState | |
| ) -> AsyncGenerator[dict]: | |
| """Process a single line and yield events.""" | |
| for event in parse_managed_claude_stdout_line(line_str, parse_state): | |
| if isinstance(event, dict) and event.get("type") == "session_info": | |
| session_id = event.get("session_id") | |
| if isinstance(session_id, str): | |
| self.current_session_id = session_id | |
| yield event | |
| async def stop(self) -> bool: | |
| """Stop the CLI process, retaining PID ownership until exit is confirmed.""" | |
| async with self._lifecycle_lock: | |
| self._closed = True | |
| process = self.process | |
| if process is None: | |
| return True | |
| if process.returncode is not None: | |
| if process.pid: | |
| unregister_pid(process.pid) | |
| return True | |
| try: | |
| logger.info(f"Stopping Claude CLI process {process.pid}") | |
| kill_pid_tree_best_effort(process.pid) | |
| try: | |
| await asyncio.wait_for(process.wait(), timeout=5.0) | |
| except TimeoutError: | |
| process.kill() | |
| await process.wait() | |
| if process.pid: | |
| unregister_pid(process.pid) | |
| return True | |
| except Exception as e: | |
| if self._log_raw_cli_diagnostics: | |
| logger.error( | |
| "Error stopping process: {}: {}", | |
| type(e).__name__, | |
| e, | |
| ) | |
| else: | |
| logger.error( | |
| "Error stopping process: exc_type={}", | |
| type(e).__name__, | |
| ) | |
| return False | |