import asyncio import os import json import logging from typing import AsyncGenerator, Optional logger = logging.getLogger(__name__) class CLISession: """Manages a single persistent Claude Code CLI subprocess.""" def __init__( self, workspace_path: str, api_url: str, allowed_dirs: Optional[list[str]] = None, ): self.workspace = workspace_path self.api_url = api_url self.allowed_dirs = allowed_dirs or [] self.process: Optional[asyncio.subprocess.Process] = None async def start_task(self, prompt: str) -> AsyncGenerator[dict, None]: """Runs a single prompt and yields JSON events from the CLI.""" env = os.environ.copy() # FIX for 404: # ANTHROPIC_API_URL usually wants the full path to the API endpoint or version root env["ANTHROPIC_API_URL"] = self.api_url # ANTHROPIC_BASE_URL usually wants the server root, and the client appends /v1/messages # If self.api_url is "http://localhost:8082/v1", base should be "http://localhost:8082" if self.api_url.endswith("/v1"): env["ANTHROPIC_BASE_URL"] = self.api_url[:-3] else: env["ANTHROPIC_BASE_URL"] = self.api_url # Ensure we don't try to use interactive TTY features env["TERM"] = "dumb" # Ensure path is normalized for Windows to avoid \a (bell) character issues normalized_workspace = os.path.normpath(self.workspace) cmd = [ "claude", "-p", prompt, "--output-format", "stream-json", "--dangerously-skip-permissions", "--verbose", ] if self.allowed_dirs: for d in self.allowed_dirs: cmd.extend(["--add-dir", os.path.normpath(d)]) prompt_preview = prompt[:100].replace("\n", " ") + ( "..." if len(prompt) > 100 else "" ) logger.info( f'CLI_TASK: workspace={normalized_workspace} prompt_len={len(prompt)} preview="{prompt_preview}"' ) self.process = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=normalized_workspace, env=env, ) # Read stdout in chunks to handle long lines buffer = bytearray() while True: chunk = await self.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): 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): yield event async def _handle_line_gen(self, line_str: str) -> AsyncGenerator[dict, None]: try: event = json.loads(line_str) yield event except json.JSONDecodeError: # Log non-JSON lines for debugging but don't crash logger.debug(f"Non-JSON output: {line_str}") yield {"type": "raw", "content": line_str} # Capture remaining stderr if the process crashed stderr_output = await self.process.stderr.read() if stderr_output: logger.error( f"Claude CLI Stderr: {stderr_output.decode('utf-8', errors='replace')}" ) return_code = await self.process.wait() logger.info(f"Claude CLI exited with code {return_code}") yield {"type": "exit", "code": return_code}