"""Isolated opencode process runtime.""" import os import subprocess import time from .budget import Budget, BudgetState, equivalent_cost _ENV_ALLOW = frozenset({ "PATH", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TERM", "SSL_CERT_FILE", "SSL_CERT_DIR", "NODE_EXTRA_CA_CERTS", "NO_PROXY", "no_proxy", }) def shared_home(work_root): home = os.path.join(work_root, ".opencode-home") os.makedirs(home, exist_ok=True) return home def ensure_global_config(home, factory_dir, install_config): dst_dir = os.path.join(home, ".config", "opencode") os.makedirs(dst_dir, exist_ok=True) dst = os.path.join(dst_dir, "opencode.json") src = os.path.join(factory_dir, "opencode.json") if not os.path.isfile(src): return if not os.path.isfile(dst) or os.path.getmtime(src) > os.path.getmtime(dst): install_config(src, dst) def build_env(home, rtk_dir, source_env=None): source = os.environ if source_env is None else source_env env = {key: value for key, value in source.items() if key in _ENV_ALLOW} env["HOME"] = home path = env.get("PATH", "/usr/local/bin:/usr/bin:/bin") env["PATH"] = (rtk_dir + os.pathsep + path) if rtk_dir else path env["OPENCODE_EXPERIMENTAL_LSP_TOOL"] = source.get( "OPENCODE_EXPERIMENTAL_LSP_TOOL", "true" ) return env def warm_opencode( home, model, *, opencode_bin, ensure_config, build_environment, runner=subprocess.run, ): marker = os.path.join(home, ".pragent.warmed") if os.path.exists(marker): return ensure_config(home) env = build_environment(home) try: runner( [opencode_bin, "run", "--pure", "--model", model, "ok"], cwd=home, env=env, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=240, ) except (subprocess.TimeoutExpired, Exception): pass try: open(marker, "w").close() except OSError: pass def run_opencode( workdir, model, *, opencode_bin, shared_home_fn, warm_fn, build_environment, parse_events, prompt, timeout, budget: Budget | None = None, budget_state: BudgetState | None = None, runner=subprocess.run, ): home = shared_home_fn() warm_fn(home, model) env = build_environment(home) cmd = [ opencode_bin, "run", "--pure", "--format", "json", "--agent", "pragent", "--dir", workdir, "--model", model, prompt, ] last_err = "" for _ in range(2): try: proc = _run_process( cmd, cwd=workdir, env=env, timeout=timeout, parse_events=parse_events, budget=budget, budget_state=budget_state, model=model, runner=runner, ) except subprocess.TimeoutExpired as exc: last_err = f"opencode timed out after {exc.timeout}s" continue text, usage = parse_events(proc.stdout or "") if usage and budget_state: usage.update({f"budget_{k}": v for k, v in budget_state.snapshot().items()}) if text.strip(): return text, usage if budget_state and budget_state.cap_reason: reason = budget_state.cap_reason empty_usage = usage or { "input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0, "total": 0, "cost": 0.0, "steps": 0, "tool_calls": 0, "iterations": [], } empty_usage.update({ f"budget_{k}": v for k, v in budget_state.snapshot().items() }) return ( "Review stopped before a complete response was produced " f"because the budget reached {reason}.\n\n" "```json\n{\"summary\": \"Review budget reached\", " "\"findings\": []}\n```\n", empty_usage, ) if usage and usage.get("budget_cap_hit"): return text, usage last_err = ( f"opencode empty text (rc={proc.returncode}); " f"stderr: {(proc.stderr or '')[-1500:]}" ) raise RuntimeError(last_err or "opencode produced no output") def _run_process( cmd, *, cwd, env, timeout, parse_events, budget, budget_state, model, runner, ): """Run a process, terminating it after a completed event exceeds budget.""" if budget is None or budget_state is None: return runner( cmd, cwd=cwd, env=env, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=timeout, ) existing_reason = budget_state.reason() if existing_reason: budget_state.cap_reason = existing_reason return subprocess.CompletedProcess(cmd, 0, "", "") proc = subprocess.Popen( cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, text=True, ) output: list[str] = [] previous = {"steps": 0, "total": 0, "output": 0, "cost": 0.0} cap_reason = "" started = time.monotonic() try: assert proc.stdout is not None for line in proc.stdout: output.append(line) _, usage = parse_events("".join(output)) if usage: delta = { "steps": usage.get("steps", 0) - previous["steps"], "input": usage.get("input", 0) - previous.get("input", 0), "cache_read": usage.get("cache_read", 0) - previous.get("cache_read", 0), "cache_write": usage.get("cache_write", 0) - previous.get("cache_write", 0), "total": usage.get("total", 0) - previous["total"], "output": usage.get("output", 0) - previous["output"], } cost = float(usage.get("cost", 0.0)) - previous["cost"] previous.update({ "steps": usage.get("steps", 0), "input": usage.get("input", 0), "cache_read": usage.get("cache_read", 0), "cache_write": usage.get("cache_write", 0), "total": usage.get("total", 0), "output": usage.get("output", 0), "cost": float(usage.get("cost", 0.0)), }) cap_reason = budget_state.record( delta, equivalent_cost(delta, model, budget.price_target), ) if cap_reason or time.monotonic() - started >= budget.max_duration_seconds: cap_reason = cap_reason or "max_duration_seconds" budget_state.cap_reason = cap_reason proc.terminate() break try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() proc.wait() finally: if proc.stdout: proc.stdout.close() stderr = proc.stderr.read() if proc.stderr else "" return subprocess.CompletedProcess( cmd, proc.returncode, "".join(output), stderr, )