| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111 |
- """Backend config loader and command builder."""
- from __future__ import annotations
- import os
- import re
- from dataclasses import dataclass
- from pathlib import Path
- from typing import Any
- import yaml
- @dataclass
- class Backend:
- name: str
- cli: str
- args: list[str]
- required_env: list[str]
- hooks: dict[str, list[str]]
- shutdown: str
- idle: dict[str, Any]
- startup_timeout: int
- terminal: dict[str, int]
- session_logs: dict[str, str]
- turn_timeout: int | None = None
- busy_pattern: str = ""
- max_busy_seconds: int = 1800
- def build_command(self, workdir: str) -> list[str]:
- resolved = [_interpolate_env(arg) for arg in self.args]
- return [self.cli, *resolved]
- def validate_env(self) -> None:
- missing = [v for v in self.required_env if not os.environ.get(v)]
- if missing:
- raise OSError(
- f"Missing required environment variables for {self.name} backend: "
- + ", ".join(missing)
- )
- def is_ready_line(self, line: str) -> bool:
- pattern = self.idle.get("ready_pattern", "")
- return bool(re.search(pattern, line))
- def is_busy_line(self, line: str) -> bool:
- if not self.busy_pattern:
- return False
- return bool(re.search(self.busy_pattern, line))
- @property
- def quiescence_seconds(self) -> float:
- return self.idle.get("quiescence_seconds", 5)
- @property
- def cols(self) -> int:
- return self.terminal.get("cols", 200)
- @property
- def rows(self) -> int:
- return self.terminal.get("rows", 50)
- @property
- def model(self) -> str | None:
- """Model name from args (looks for --model or -m flag)."""
- for i, arg in enumerate(self.args):
- if arg in ("--model", "-m") and i + 1 < len(self.args):
- return self.args[i + 1]
- return None
- @property
- def family(self) -> str:
- """Normalize backend name to a family for log-dir / normalizer dispatch."""
- for fam in ("claude", "codex", "gemini"):
- if self.name == fam or self.name.startswith(f"{fam}-"):
- return fam
- return "other"
- def load_backend(name: str, backends_dir: Path) -> Backend:
- path = backends_dir / f"{name}.yaml"
- if not path.exists():
- raise FileNotFoundError(f"Backend config not found: {path}")
- with open(path) as f:
- data = yaml.safe_load(f)
- return Backend(
- name=data["name"],
- cli=data["cli"],
- args=data.get("args", []),
- required_env=data.get("required_env", []),
- hooks=data.get("hooks", {"pre_run": [], "post_run": []}),
- shutdown=data.get("shutdown", "/exit"),
- idle=data.get("idle", {}),
- startup_timeout=data.get("startup_timeout", 30),
- terminal=data.get("terminal", {"cols": 200, "rows": 50}),
- session_logs=data.get("session_logs", {}),
- turn_timeout=data.get("turn_timeout"),
- busy_pattern=data.get("busy_pattern", ""),
- max_busy_seconds=data.get("max_busy_seconds", 1800),
- )
- def _interpolate_env(value: str) -> str:
- def replacer(match: re.Match[str]) -> str:
- var = match.group(1)
- val = os.environ.get(var)
- if val is None:
- raise OSError(f"Environment variable {var} not set")
- return val
- return re.sub(r"\$\{(\w+)\}", replacer, value)
|