| """ |
| OpenClaude gRPC client β replaces the legacy aider subprocess shell-out. |
| |
| Design contract |
| --------------- |
| Every callable in this module returns an :class:`OpenClaudeResult` (or the |
| ``(combined_output, exit_code)`` tuple that legacy callers expect) so that the |
| existing validation, SAST gate, conviction engine, and red-team loops keep |
| plugging in unchanged. |
| |
| Streaming events from the daemon (text chunks, tool start/result, action |
| required) are accumulated into a single transcript so that downstream code |
| that previously parsed aider stdout still sees a useful blob. |
| |
| Operational guarantees |
| ---------------------- |
| * Connection drops, ``StatusCode.UNAVAILABLE`` and stream-level RPC errors |
| are converted into a non-zero exit code with the error message embedded in |
| the combined output (mirrors aider's crash semantics). |
| * Per-call wall-clock timeout (defaults to 600 s) β same as the legacy |
| aider invocation. |
| * Bidi stream auto-answers any ``ActionRequired`` prompt with ``"y"`` so that |
| headless mode never deadlocks waiting for human input. The daemon also |
| honours ``OPENCLAUDE_AUTO_APPROVE=1`` server-side as a belt-and-braces. |
| * Each call produces a fresh stream β sessions are not shared across calls |
| to keep failures isolated. |
| """ |
| from __future__ import annotations |
|
|
| import logging |
| import os |
| import queue |
| import threading |
| import time |
| from dataclasses import dataclass, field |
| from typing import Iterable, Optional |
|
|
| import grpc |
|
|
| from . import openclaude_pb2 as pb |
| from . import openclaude_pb2_grpc as pb_grpc |
|
|
| logger = logging.getLogger("openclaude_grpc") |
|
|
| DEFAULT_HOST = os.getenv("OPENCLAUDE_GRPC_HOST", "127.0.0.1") |
| DEFAULT_PORT_DO = int(os.getenv("OPENCLAUDE_GRPC_PORT_DO", "50051")) |
| DEFAULT_PORT_OR = int(os.getenv("OPENCLAUDE_GRPC_PORT_OR", "50052")) |
| DEFAULT_TIMEOUT = int(os.getenv("OPENCLAUDE_TIMEOUT", "600")) |
|
|
|
|
| class OpenClaudeError(RuntimeError): |
| """Raised for unrecoverable client-side errors (connect, decode, etc).""" |
|
|
|
|
| @dataclass |
| class OpenClaudeResult: |
| """Mirror of the legacy aider return contract. |
| |
| ``stdout`` is the model's narrative + tool stdout, ``stderr`` is tool |
| failures and gRPC errors, ``exit_code`` is 0 on a clean ``done`` event, |
| non-zero on any error event or transport failure. ``model_used`` lets |
| downstream telemetry attribute findings to the right provider. |
| """ |
|
|
| stdout: str = "" |
| stderr: str = "" |
| exit_code: int = 0 |
| model_used: str = "" |
| prompt_tokens: int = 0 |
| completion_tokens: int = 0 |
| tool_calls: list[dict] = field(default_factory=list) |
|
|
| @property |
| def combined_output(self) -> str: |
| if self.stderr: |
| return f"{self.stdout}\n{self.stderr}".strip() |
| return self.stdout.strip() |
|
|
| def as_legacy_tuple(self) -> tuple[str, int]: |
| return self.combined_output, self.exit_code |
|
|
|
|
| class OpenClaudeClient: |
| """Bidirectional-streaming client for one OpenClaude daemon.""" |
|
|
| def __init__( |
| self, |
| host: str = DEFAULT_HOST, |
| port: int = DEFAULT_PORT_DO, |
| timeout: int = DEFAULT_TIMEOUT, |
| max_message_mb: int = 64, |
| ) -> None: |
| self.host = host |
| self.port = port |
| self.timeout = timeout |
| opts = [ |
| ("grpc.max_send_message_length", max_message_mb * 1024 * 1024), |
| ("grpc.max_receive_message_length", max_message_mb * 1024 * 1024), |
| ("grpc.keepalive_time_ms", 30_000), |
| ("grpc.keepalive_timeout_ms", 10_000), |
| ("grpc.keepalive_permit_without_calls", 1), |
| ] |
| self._channel = grpc.insecure_channel(f"{host}:{port}", options=opts) |
| self._stub = pb_grpc.AgentServiceStub(self._channel) |
|
|
| |
| |
| |
| def wait_ready(self, deadline_s: float = 60.0) -> bool: |
| """Block until the daemon's gRPC channel is READY or the deadline |
| elapses. Used by the orchestrator at boot to ensure the bun-built |
| daemon is alive before issuing the first healing call.""" |
| start = time.monotonic() |
| while time.monotonic() - start < deadline_s: |
| try: |
| grpc.channel_ready_future(self._channel).result(timeout=2.0) |
| return True |
| except grpc.FutureTimeoutError: |
| continue |
| except Exception: |
| time.sleep(0.5) |
| return False |
|
|
| |
| |
| |
| def chat( |
| self, |
| message: str, |
| working_directory: str, |
| model: str = "", |
| session_id: str = "", |
| timeout: Optional[int] = None, |
| ) -> OpenClaudeResult: |
| """Send one prompt, drain the bidi stream, return an aggregated |
| :class:`OpenClaudeResult`.""" |
| deadline = timeout if timeout is not None else self.timeout |
| result = OpenClaudeResult(model_used=model) |
|
|
| outbound: "queue.Queue[Optional[pb.ClientMessage]]" = queue.Queue() |
| outbound.put( |
| pb.ClientMessage( |
| request=pb.ChatRequest( |
| message=message, |
| working_directory=working_directory, |
| model=model, |
| session_id=session_id, |
| ) |
| ) |
| ) |
|
|
| def _send_iter() -> Iterable[pb.ClientMessage]: |
| while True: |
| item = outbound.get() |
| if item is None: |
| return |
| yield item |
|
|
| done = threading.Event() |
| try: |
| stream = self._stub.Chat(_send_iter(), timeout=deadline) |
| for ev in stream: |
| kind = ev.WhichOneof("event") |
| if kind == "text_chunk": |
| result.stdout += ev.text_chunk.text |
| elif kind == "tool_start": |
| result.tool_calls.append( |
| { |
| "tool": ev.tool_start.tool_name, |
| "args": ev.tool_start.arguments_json, |
| "id": ev.tool_start.tool_use_id, |
| } |
| ) |
| result.stdout += ( |
| f"\n[tool βΆ {ev.tool_start.tool_name}]" |
| f" {ev.tool_start.arguments_json}\n" |
| ) |
| elif kind == "tool_result": |
| prefix = "[tool β]" if ev.tool_result.is_error else "[tool β]" |
| line = ( |
| f"\n{prefix} {ev.tool_result.tool_name}:" |
| f" {ev.tool_result.output}\n" |
| ) |
| if ev.tool_result.is_error: |
| result.stderr += line |
| else: |
| result.stdout += line |
| elif kind == "action_required": |
| |
| |
| |
| |
| outbound.put( |
| pb.ClientMessage( |
| input=pb.UserInput( |
| prompt_id=ev.action_required.prompt_id, |
| reply="y", |
| ) |
| ) |
| ) |
| elif kind == "done": |
| if not result.stdout and ev.done.full_text: |
| result.stdout = ev.done.full_text |
| result.prompt_tokens = ev.done.prompt_tokens |
| result.completion_tokens = ev.done.completion_tokens |
| result.exit_code = 0 |
| done.set() |
| break |
| elif kind == "error": |
| result.stderr += ( |
| f"\n[openclaude {ev.error.code}] {ev.error.message}" |
| ) |
| result.exit_code = 1 |
| done.set() |
| break |
| except grpc.RpcError as exc: |
| result.stderr += ( |
| f"\n[grpc {exc.code().name}] {exc.details() or str(exc)}" |
| ) |
| result.exit_code = 2 |
| except Exception as exc: |
| result.stderr += f"\n[client] {type(exc).__name__}: {exc}" |
| result.exit_code = 3 |
| finally: |
| outbound.put(None) |
| try: |
| self._channel |
| except Exception: |
| pass |
| if not done.is_set() and result.exit_code == 0: |
| |
| result.exit_code = 4 |
| result.stderr += "\n[client] stream ended without done event" |
|
|
| return result |
|
|
| def close(self) -> None: |
| try: |
| self._channel.close() |
| except Exception: |
| pass |
|
|
| def __enter__(self) -> "OpenClaudeClient": |
| return self |
|
|
| def __exit__(self, *exc) -> None: |
| self.close() |
|
|
|
|
| |
| |
| |
| def _format_prompt(prompt: str, context_files: list[str]) -> str: |
| """Aider received context files as CLI args; OpenClaude gets them |
| inline so it knows which files matter. The agent already has full |
| file-tool access via MCP/native tools, so this is just hinting.""" |
| if not context_files: |
| return prompt |
| file_list = "\n".join(f"- {p}" for p in context_files) |
| return ( |
| f"{prompt}\n\n" |
| "ββ Context files (focus your edits here) ββ\n" |
| f"{file_list}\n" |
| ) |
|
|
|
|
| def run_openclaude( |
| mcp_config_path: str, |
| prompt: str, |
| context_files: list[str], |
| *, |
| repo_dir: str, |
| primary_port: int = DEFAULT_PORT_DO, |
| fallback_port: int = DEFAULT_PORT_OR, |
| primary_label: str = "DigitalOcean", |
| fallback_label: str = "OpenRouter", |
| primary_model: str = "", |
| fallback_model: str = "", |
| timeout: int = DEFAULT_TIMEOUT, |
| log_fn=None, |
| ) -> tuple[str, int]: |
| """Drop-in replacement for ``run_aider`` β preserves the |
| ``(combined_output, exit_code)`` return shape so every caller in |
| ``app.py`` works unchanged. |
| |
| Tries the **primary** daemon (DigitalOcean Inference) first; on any |
| non-zero exit code it falls back to the **OpenRouter** daemon so the |
| healing loop's existing 15-attempt retry semantics still apply. |
| |
| ``mcp_config_path`` is honoured by the daemon itself β it re-reads |
| ``MCP_RUNTIME_CONFIG`` per chat session β so we just forward the |
| path via env and pass the prompt verbatim. |
| """ |
| |
| |
| |
| os.environ.setdefault("MCP_RUNTIME_CONFIG", mcp_config_path) |
|
|
| full_prompt = _format_prompt(prompt, context_files) |
|
|
| chain: list[tuple[int, str, str]] = [] |
| if primary_port: |
| chain.append((primary_port, primary_label, primary_model)) |
| if fallback_port and fallback_port != primary_port: |
| chain.append((fallback_port, fallback_label, fallback_model)) |
|
|
| last_output, last_code = "", 1 |
| for idx, (port, label, model) in enumerate(chain): |
| if log_fn: |
| log_fn( |
| f"OpenClaude attempt via {label} (port {port}, model={model or 'default'})", |
| "INFO", |
| ) |
| try: |
| with OpenClaudeClient( |
| host=DEFAULT_HOST, port=port, timeout=timeout |
| ) as client: |
| if not client.wait_ready(deadline_s=15.0): |
| msg = ( |
| f"OpenClaude daemon at {DEFAULT_HOST}:{port} " |
| "not ready within 15s" |
| ) |
| if log_fn: |
| log_fn(msg, "WARN") |
| last_output, last_code = msg, 5 |
| continue |
| result = client.chat( |
| message=full_prompt, |
| working_directory=repo_dir, |
| model=model, |
| timeout=timeout, |
| ) |
| except Exception as exc: |
| last_output = f"[client] {type(exc).__name__}: {exc}" |
| last_code = 6 |
| if log_fn: |
| log_fn(f"{label} client crash: {exc}", "FAIL") |
| continue |
|
|
| last_output, last_code = result.as_legacy_tuple() |
| if last_code == 0: |
| return last_output, last_code |
| if log_fn and idx < len(chain) - 1: |
| log_fn( |
| f"{label} failed (exit {last_code}) β falling back", |
| "WARN", |
| ) |
| return last_output, last_code |
|
|