| """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 |
|
|
| |
| _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 |
|
|
| @staticmethod |
| 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 |
| |
| return b"".join(parts) |
|
|
| @property |
| 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: |
| |
| |
| 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 |
|
|