"""Top-down orchestration for the bootstrap flow.

Implementation is intentionally skeletal; individual steps will be filled in once
design details are finalized.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Any
import os
import sys
import threading
import time
import json
import re
import shutil
from typing import Literal

from bootstrap.app_server import AppServerStdioClient
from bootstrap.constants import (
    CONTROL_POLL_INTERVAL_SECONDS,
    DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES,
    DEFAULT_ARTIFACT_MANIFEST,
    DEFAULT_RUN_ROOT,
    DEFAULT_SERVER_URL,
    ENV_ARTIFACT_BLOB_THRESHOLD_BYTES,
    ENV_RUN_ID,
    ENV_RUN_TOKEN,
    ENV_SERVER_URL,
    HEARTBEAT_INTERVAL_SECONDS,
    MAX_ARTIFACT_RETRIES,
    TURN_COMPLETION_POLL_SECONDS,
    MAX_TERMINAL_OUTPUT_CHARS,
)
from bootstrap.config_loader import UserConfig, load_codex_config
from bootstrap.git_ops import (
    GitConfig,
    commit_changes,
    initialize_repo,
    push_changes,
    update_git_auth,
)
from bootstrap.install import ensure_codex
from bootstrap.payload import (
    BootstrapAlreadyClaimed,
    BootstrapPayload,
    fetch_bootstrap_payload,
    fetch_github_token,
)
from bootstrap.prompts import build_prompt_text
from bootstrap.telemetry import (
    ack_run_control,
    poll_run_control,
    post_artifacts,
    post_completion,
    post_error,
    post_app_server_event,
    post_heartbeat,
    post_log,
)
from bootstrap.artifacts import ManifestResult, ManifestStatus, read_manifest

_ANSI_ESCAPE_PATTERN = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
_AUTONOMOUS_RESUME_PROMPT = (
    "Continue autonomously on the active Flywheel task. "
    "If all requested work is complete, finalize artifacts and exit."
)

# We receive a high-frequency stream of `item/*/delta` notifications from the
# Codex app-server. Forwarding each delta as an HTTP request is expensive, and
# persisting per-token deltas is not part of the durable session log. We batch
# deltas into short bursts and send them as app-server events so the platform
# can stream them live without bloating storage.
_DELTA_FLUSH_INTERVAL_SECONDS = 0.10
_DELTA_FLUSH_CHARS = 1024

# Only these delta notifications are forwarded to the interaction plane.
_DELTA_METHODS: set[str] = {
    "item/agentMessage/delta",
    "item/commandExecution/outputDelta",
    "item/fileChange/outputDelta",
}

_IGNORED_DELTA_METHODS: set[str] = {
    "item/plan/delta",
    "item/reasoning/summaryTextDelta",
    "item/reasoning/textDelta",
}


def _max_suffix_prefix_overlap(left: str, right: str) -> int:
    """Return the max k where left.endswith(right[:k]).

    Codex app-server delta payloads are not guaranteed to be strictly "append-only"
    chunks. In practice deltas can be incremental, cumulative, or overlapping
    (e.g. token/word boundary shifts). Normalizing via maximum overlap avoids
    duplicate text like "InspectInspectinging".
    """

    if not left or not right:
        return 0
    max_k = min(len(left), len(right))
    for k in range(max_k, 0, -1):
        if left.endswith(right[:k]):
            return k
    return 0


def _normalize_stream_delta(*, assembled: str, incoming: str) -> tuple[str, str]:
    """Normalize an app-server delta stream into (incremental, new_assembled)."""

    if not incoming:
        return ("", assembled)
    if not assembled:
        return (incoming, incoming)

    # Cumulative update: incoming is the full text so far.
    if incoming.startswith(assembled):
        inc = incoming[len(assembled) :]
        return (inc, incoming)

    # Backtracking: some servers re-emit a shorter prefix of the assembled text
    # (e.g. due to token boundary shifts or partial replays). We can't retract
    # already-rendered output, so treat this as a no-op to avoid duplicated text
    # like "InspectInspecting" or "the the".
    if assembled.startswith(incoming):
        return ("", assembled)

    # Overlap update: append only the portion after the greatest overlap.
    overlap = _max_suffix_prefix_overlap(assembled, incoming)
    inc = incoming[overlap:] if overlap else incoming
    # Avoid ugly artifacts around word boundaries where the server shifts a
    # space from the "previous" chunk into the next one.
    if assembled.endswith(" ") and inc.startswith(" "):
        inc = inc[1:]
    return (inc, assembled + inc)


def _resolve_artifact_blob_threshold_bytes() -> int:
    """Resolve artifact-to-blob transfer threshold from environment."""
    raw = os.environ.get(ENV_ARTIFACT_BLOB_THRESHOLD_BYTES)
    if raw is None:
        return DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES
    try:
        value = int(raw)
    except ValueError:
        return DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES
    if value <= 0:
        return DEFAULT_ARTIFACT_BLOB_THRESHOLD_BYTES
    return value


@dataclass
class BootstrapConfig:
    """User-supplied CLI arguments plus derived defaults."""

    run_id: str
    capability_token: str
    config_path: Path
    server_url: str = DEFAULT_SERVER_URL
    run_root: Path = DEFAULT_RUN_ROOT
    artifact_manifest: str = DEFAULT_ARTIFACT_MANIFEST


class BootstrapOrchestrator:
    """Coordinates install, payload fetch, Codex launch, telemetry, and artifacts."""

    def __init__(self, config: BootstrapConfig) -> None:
        self.config = config
        self._mock_engineer = bool(os.environ.get("BOOTSTRAP_MOCK_ENGINEER"))
        self.user_config: UserConfig | None = None
        self.bootstrap_payload: BootstrapPayload | None = None
        self.workspace: Path | None = None
        self.codex_executable: Path | None = None
        self._app: AppServerStdioClient | None = None
        self._thread_id: str | None = None
        self._codex_model: str | None = None
        self.heartbeat_thread: threading.Thread | None = None
        self.control_thread: threading.Thread | None = None
        self._stop_heartbeats = threading.Event()
        self._stop_control = threading.Event()
        self._active_prompt_lock = threading.Lock()
        self._active_prompt: bool = False
        self._turn_state_lock = threading.Lock()
        self._active_turn_id: str | None = None
        self._turn_done_events: dict[str, threading.Event] = {}
        self._turn_status_by_id: dict[str, str] = {}
        self._turn_error_by_id: dict[str, object] = {}
        self._turns_completed: set[str] = set()
        self._token_usage_by_turn: dict[str, tuple[int, int, int, int]] = {}
        self._interactive_input_lock = threading.Lock()
        self._interactive_input_thread: threading.Thread | None = None
        self._interactive_input_command_id: str | None = None
        self._interactive_input_queue: list[tuple[str, str]] = []
        self._interaction_mode_lock = threading.Lock()
        self._interaction_mode: Literal["autonomous", "interactive"] = "autonomous"
        self._last_stop_reason: str | None = None
        # Usage deltas are computed server-side. We emit cumulative usage totals
        # after prompt turns and avoid emitting duplicates locally.
        self._last_usage_totals: dict[str, tuple[int, int, int, int]] = {}
        self.git_config: GitConfig | None = None  # Git config for code persistence
        self._artifact_blob_threshold_bytes = _resolve_artifact_blob_threshold_bytes()
        self._delta_buffers_lock = threading.Lock()
        # stream_key -> buffer state
        self._delta_buffers: dict[str, dict[str, object]] = {}
        # Track last-seen delta text per stream_key to handle cumulative delta semantics.
        self._last_delta_by_stream_key: dict[str, str] = {}
        # Some Codex app-server delta notifications intermittently omit `itemId`.
        # Keep a best-effort mapping of (method, threadId, turnId) -> itemId so we can
        # keep streaming on a stable key and avoid duplicated text.
        self._last_item_id_by_delta_stream: dict[tuple[str, str, str], str] = {}

    def _flush_delta_buffers(self) -> None:
        """Flush any buffered app-server deltas via the backend event endpoint."""
        now = time.monotonic()
        to_flush: list[tuple[str, dict[str, object]]] = []
        # (method, params)
        with self._delta_buffers_lock:
            for _stream_key, buf in self._delta_buffers.items():
                parts = buf.get("parts")
                if not isinstance(parts, list) or not parts:
                    continue
                message = "".join(p for p in parts if isinstance(p, str))
                if not message:
                    buf["parts"] = []
                    buf["chars"] = 0
                    buf["last_flush"] = now
                    continue
                method = buf.get("method")
                params = buf.get("params")
                if not isinstance(method, str) or not method:
                    buf["parts"] = []
                    buf["chars"] = 0
                    buf["last_flush"] = now
                    continue
                params_dict = dict(params) if isinstance(params, dict) else {}
                params_dict["delta"] = message
                to_flush.append((method, params_dict))

                # Reset buffer.
                buf["parts"] = []
                buf["chars"] = 0
                buf["last_flush"] = now

        for method, params in to_flush:
            post_app_server_event(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                method=method,
                params=params,
            )

    def _buffer_delta_log(
        self,
        *,
        stream_key: str,
        method: str,
        params: dict[str, object],
        message: str,
    ) -> None:
        """Append a delta to a buffer and flush if it crosses time/size thresholds."""
        now = time.monotonic()
        should_flush = False
        with self._delta_buffers_lock:
            buf = self._delta_buffers.get(stream_key)
            if buf is None:
                buf = {
                    "parts": [],
                    "chars": 0,
                    "last_flush": now,
                    "method": method,
                    "params": dict(params),
                }
                self._delta_buffers[stream_key] = buf

            parts = buf.get("parts")
            if not isinstance(parts, list):
                parts = []
                buf["parts"] = parts
            parts.append(message)
            chars_val = buf.get("chars", 0)
            buf["chars"] = (
                int(chars_val) if isinstance(chars_val, (int, float, str)) else 0
            ) + len(message)
            buf["method"] = method
            buf["params"] = dict(params)

            last_flush = buf.get("last_flush")
            last_flush_value = (
                float(last_flush) if isinstance(last_flush, (int, float)) else now
            )

            chars_count = buf["chars"]
            if (
                int(chars_count) if isinstance(chars_count, (int, float, str)) else 0
            ) >= _DELTA_FLUSH_CHARS:
                should_flush = True
            elif now - last_flush_value >= _DELTA_FLUSH_INTERVAL_SECONDS:
                should_flush = True

        if should_flush:
            self._flush_delta_buffers()

    def _maybe_emit_engineer_usage_for_turn(self, turn_id: str) -> None:
        """Best-effort: emit a Codex `turn.completed` usage log for billing."""
        if self._mock_engineer:
            return
        if not turn_id:
            return

        with self._turn_state_lock:
            if turn_id not in self._turns_completed:
                return
            totals_key = self._token_usage_by_turn.get(turn_id)
            if totals_key is None:
                return
            model = (self._codex_model or "").strip()

        if totals_key == (0, 0, 0, 0):
            return
        prev = self._last_usage_totals.get(model)
        if prev == totals_key:
            return
        self._last_usage_totals[model] = totals_key

        input_tokens, cached_input_tokens, output_tokens, reasoning_output_tokens = (
            totals_key
        )
        event: dict[str, object] = {
            "type": "turn.completed",
            "usage": {
                "input_tokens": input_tokens,
                "cached_input_tokens": cached_input_tokens,
                "output_tokens": output_tokens,
                "reasoning_output_tokens": reasoning_output_tokens,
            },
        }
        if model:
            event["model"] = model

        self._log(
            "codex usage updated",
            extra={
                "run_event_kind": "usage",
                "run_event_source": "codex-app-server",
                "thread_id": self._thread_id or "",
                "turn_id": turn_id,
                # Server-side billing expects Codex `turn.completed` usage in extra.event.
                "event": event,
            },
        )

    def _ensure_workspace_git_info_exclude_entry(self, entry: str) -> None:
        """Ensure `.git/info/exclude` contains the provided entry.

        This avoids modifying the user's tracked `.gitignore` (which could be
        committed and pushed), while still preventing bootstrap-only files from
        being staged by `git add -A`.
        """
        assert self.workspace is not None

        git_dir = self.workspace / ".git"
        if not git_dir.is_dir():
            return

        exclude_path = git_dir / "info" / "exclude"
        exclude_path.parent.mkdir(parents=True, exist_ok=True)
        existing = ""
        if exclude_path.exists():
            existing = exclude_path.read_text(encoding="utf-8")
        if any(line.strip() == entry for line in existing.splitlines()):
            return

        separator = ""
        if existing:
            separator = "" if existing.endswith("\n") else "\n"
        exclude_path.write_text(
            f"{existing}{separator}{entry}\n",
            encoding="utf-8",
        )

    def run(self) -> int:
        """Execute the bootstrap flow.

        Returns:
            Process exit code (0 for success, non-zero for failure).
        """

        try:
            self._ensure_prerequisites()
            self._load_user_config()
            self._resolve_workspace()
            self._ensure_engineer_available()
            try:
                self._fetch_bootstrap_payload()
            except BootstrapAlreadyClaimed as exc:
                self._log(str(exc), level="warning")
                return 0
            self._initialize_git_repo()  # Clone repo if code persistence enabled
            exit_code = self._launch_engineer_and_stream()
            self._finalize_git_repo(
                exit_code
            )  # Commit and push if code persistence enabled
            self._collect_and_post_artifacts(exit_code)
            return 0
        except SystemExit:
            raise
        except Exception as exc:  # pragma: no cover - defensive
            post_error(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                reason=repr(exc),
                summary="bootstrap failure",
            )
            print(f"bootstrap failed: {exc}", file=sys.stderr)
            return 1
        finally:
            # Best-effort: flush any buffered delta output so the UI sees the
            # latest engineer transcript before teardown.
            self._flush_delta_buffers()
            self._cancel_active_prompt()
            self._shutdown_app_server()
            self._stop_heartbeats.set()
            if self.heartbeat_thread and self.heartbeat_thread.is_alive():
                self.heartbeat_thread.join(timeout=2)
            self._stop_control.set()
            if self.control_thread and self.control_thread.is_alive():
                self.control_thread.join(timeout=2)
            with self._interactive_input_lock:
                thread = self._interactive_input_thread
            if thread and thread.is_alive():
                thread.join(timeout=2)

    # --- individual steps (to be implemented) ---

    def _ensure_prerequisites(self) -> None:
        """Validate required binaries/env vars and fail fast if missing."""
        if not self.config.run_id:
            raise SystemExit("missing run id")
        if not self.config.capability_token:
            raise SystemExit("missing capability token")
        if not self.config.config_path.exists():
            raise SystemExit(f"config file not found: {self.config.config_path}")

    def _load_user_config(self) -> None:
        """Read the user's Codex config.toml for sandbox/workspace settings."""
        self.user_config = load_codex_config(self.config.config_path)
        for warning in self.user_config.warnings:
            print(f"bootstrap warning: {warning}", file=sys.stderr)

    def _resolve_workspace(self) -> None:
        """Decide which working directory to hand to Codex (respect user config if set)."""
        assert self.user_config is not None
        if self.user_config.working_dir:
            # Support the documented "<run_id>" placeholder in config paths.
            raw = str(self.user_config.working_dir)
            if "<run_id>" in raw:
                raw = raw.replace("<run_id>", self.config.run_id)
            workdir = Path(raw).expanduser().resolve()
        else:
            workdir = self.config.run_root / self.config.run_id
        workdir.mkdir(parents=True, exist_ok=True)

        # Auto-create writable_roots directories if they don't exist
        for root in self.user_config.writable_roots:
            root.mkdir(parents=True, exist_ok=True)

        manifest_path = workdir / self.config.artifact_manifest
        if self.user_config.writable_roots:
            ok = False
            for root in self.user_config.writable_roots:
                try:
                    manifest_path.relative_to(root)
                    ok = True
                    break
                except ValueError:
                    continue
            if not ok:
                roots = ", ".join(str(r) for r in self.user_config.writable_roots)
                raise SystemExit(
                    f"manifest path {manifest_path} not in sandbox writable_roots ({roots}); "
                    "please add a writable root or adjust config"
                )
        self.workspace = workdir

    def _ensure_engineer_available(self) -> None:
        """Ensure the engineer runtime (`codex app-server`) is available."""
        if self._mock_engineer:
            return
        download_dir = (self.config.run_root / "bin").resolve()
        self.codex_executable = ensure_codex(download_dir=download_dir)
        print(f"[bootstrap] using codex at: {self.codex_executable}", file=sys.stderr)

    def _fetch_bootstrap_payload(self) -> None:
        """Call backend /runs/{id}/bootstrap to get the task prompt."""
        self.bootstrap_payload = fetch_bootstrap_payload(
            self.config.server_url, self.config.run_id, self.config.capability_token
        )

    def _initialize_git_repo(self) -> None:
        """Initialize git repository if code persistence is enabled.

        If the bootstrap payload contains a repo_context and github_token,
        we clone the repository and set up the experiment branch.
        """
        assert self.bootstrap_payload is not None
        assert self.workspace is not None

        repo_context = self.bootstrap_payload.repo_context
        github_token = self.bootstrap_payload.github_token

        if repo_context is None or github_token is None:
            self._log("Git: Code persistence not configured, skipping repo setup")
            return

        self._log(
            f"Git: Initializing code persistence for {repo_context.repo_owner}/{repo_context.repo_name}"
        )

        # Create git config with telemetry logging
        def log_fn(level: str, message: str) -> None:
            self._log(f"Git: {message}", level=level)

        self.git_config = GitConfig(
            workspace=self.workspace,
            repo_context=repo_context,
            github_token=github_token,
            log_fn=log_fn,
        )

        # Initialize the repository (clone, credentials, branch)
        if not initialize_repo(self.git_config):
            self._log(
                "Git: Failed to initialize repository, continuing without code persistence",
                level="warning",
            )
            self.git_config = None
            return

        self._log(
            f"Git: Repository initialized, working on branch {repo_context.branch_name}"
        )

    def _finalize_git_repo(self, exit_code: int) -> None:
        """Finalize git repository after codex completes.

        Commits any changes and pushes them to the remote.
        Only runs if code persistence was successfully initialized.
        """
        if self.git_config is None:
            return

        commit_message = (
            f"Flywheel experiment run: {self.config.run_id}"
            if exit_code == 0
            else f"[WIP] Flywheel experiment run (failed): {self.config.run_id}"
        )

        if exit_code != 0:
            self._log(
                f"Git: Codex exited with code {exit_code}, committing WIP",
                level="warning",
            )
        else:
            self._log("Git: Finalizing repository, committing and pushing changes")

        artifact_exclusions = self._artifact_paths_for_git_exclusion()
        if artifact_exclusions:
            self._log(
                f"Git: Excluding {len(artifact_exclusions)} artifact path(s) from commit"
            )

        if not commit_changes(
            self.git_config,
            commit_message,
            exclude_paths=artifact_exclusions,
        ):
            self._log("Git: Failed to commit changes", level="error")
            return

        self._refresh_github_token()

        if push_changes(self.git_config):
            self._log("Git: Changes pushed successfully")
        else:
            self._log("Git: Failed to push changes", level="error")

    def _artifact_paths_for_git_exclusion(self) -> tuple[str, ...]:
        """Return workspace-relative artifact paths that should not be committed."""
        assert self.workspace is not None

        paths: set[str] = set()
        manifest_rel = self.config.artifact_manifest
        manifest_path = self.workspace / manifest_rel
        if manifest_path.exists():
            paths.add(manifest_rel)

        manifest_result = read_manifest(manifest_path)
        for artifact in manifest_result.artifacts:
            if not isinstance(artifact, dict):
                continue
            payload = artifact.get("payload", {})
            if not isinstance(payload, dict):
                continue
            raw_path = payload.get("path") or payload.get("file")
            if not isinstance(raw_path, str) or not raw_path.strip():
                continue
            resolved = self._resolve_artifact_path(raw_path)
            if resolved is None or not resolved.exists():
                continue
            try:
                relative = resolved.relative_to(self.workspace).as_posix()
            except ValueError:
                continue
            paths.add(relative)

        return tuple(sorted(paths))

    def _refresh_github_token(self) -> None:
        """Refresh the GitHub token before pushing."""
        if self.git_config is None:
            return

        try:
            token = fetch_github_token(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
            )
        except Exception as exc:
            self._log(
                f"Git: Failed to refresh GitHub token ({exc}); using existing token",
                level="warning",
            )
            return

        if not token:
            self._log(
                "Git: Empty GitHub token response; using existing token",
                level="warning",
            )
            return

        if update_git_auth(self.git_config, token):
            self._log("Git: Refreshed GitHub token")
        else:
            self._log(
                "Git: Failed to update git credentials after token refresh",
                level="warning",
            )

    def _shutdown_app_server(self) -> None:
        app = self._app
        self._app = None
        self._thread_id = None
        self._codex_model = None
        with self._turn_state_lock:
            self._active_turn_id = None
            self._turn_done_events.clear()
            self._turn_status_by_id.clear()
            self._turn_error_by_id.clear()
            self._turns_completed.clear()
            self._token_usage_by_turn.clear()
        if app is None:
            return
        try:
            app.close()
        except Exception:  # pragma: no cover - best effort
            pass

    def _launch_engineer_and_stream(self) -> int:
        """Run Codex app-server and stream updates."""
        assert self.workspace is not None
        assert self.bootstrap_payload is not None
        assert self.user_config is not None

        prompt_text = build_prompt_text(
            server_prompt=self.bootstrap_payload.prompt,
            workspace_instructions=self.user_config.workspace_instructions,
            artifact_manifest=self.config.artifact_manifest,
        )

        if self._mock_engineer:
            # Fast-path: emit one heartbeat, a couple logs, a run id, and exit 0.
            post_heartbeat(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                summary="alive (mock)",
            )
            self._log(
                "mock: starting work",
                extra={
                    "run_event_kind": "command_output",
                    "run_event_source": "engineer",
                },
            )
            self._log(
                "mock: finished",
                extra={
                    "run_event_kind": "command_output",
                    "run_event_source": "engineer",
                },
            )
            self._write_mock_manifest()
            return 0

        codex_path = self.codex_executable or Path("codex")
        env = os.environ.copy()
        env.update(
            {
                "FLYWHEEL_RUN_ID": self.config.run_id,
                "FLYWHEEL_RUN_TOKEN": self.config.capability_token,
                "FLYWHEEL_SERVER": self.config.server_url,
                "FLYWHEEL_WORKSPACE": str(self.workspace.resolve()),
            }
        )
        if self.bootstrap_payload.wandb_api_key:
            env["WANDB_API_KEY"] = self.bootstrap_payload.wandb_api_key
        if self.bootstrap_payload.hf_token:
            env["HF_TOKEN"] = self.bootstrap_payload.hf_token
            # Backward-compatible env key still used by older tooling.
            env["HUGGING_FACE_HUB_TOKEN"] = self.bootstrap_payload.hf_token

        # Debug: show API key status
        api_key = env.get("OPENAI_API_KEY", "")
        if api_key:
            print(
                "[bootstrap] OPENAI_API_KEY is set",
                file=sys.stderr,
            )
        else:
            print(
                "[bootstrap] WARNING: OPENAI_API_KEY is NOT set in environment",
                file=sys.stderr,
            )

        # If using LM Studio, set the API base URL for Codex
        if self.user_config.oss_provider == "lmstudio":
            env["OPENAI_API_BASE"] = "http://localhost:1234/v1"
            env["OPENAI_BASE_URL"] = "http://localhost:1234/v1"
            # LM Studio doesn't validate API keys, but some tools require one to be set
            if "OPENAI_API_KEY" not in env:
                env["OPENAI_API_KEY"] = "lm-studio"
            print(
                "[bootstrap] Using LM Studio at http://localhost:1234/v1",
                file=sys.stderr,
            )

        # Ensure Codex actually sees the same config file bootstrap parsed.
        # Codex reads config from CODEX_HOME/config.toml; point it at a per-run copy.
        try:
            codex_home = self.workspace / ".codex_home"
            codex_home.mkdir(parents=True, exist_ok=True)

            # Keep bootstrap-only Codex state out of git commits in persisted repos.
            self._ensure_workspace_git_info_exclude_entry(".codex_home/")

            # Copy config but expand ~ paths so codex sees absolute paths
            config_text = self.config.config_path.read_text(encoding="utf-8")
            # Expand common tilde patterns in the config
            # Use forward slashes for cross-platform compatibility (works on Windows too)
            # and avoids TOML interpreting backslashes as escape sequences
            home_dir = str(Path.home()).replace("\\", "/")
            config_text = config_text.replace('"~/', f'"{home_dir}/')
            config_text = config_text.replace("'~/", f"'{home_dir}/")
            (codex_home / "config.toml").write_text(config_text, encoding="utf-8")

            # Also copy auth credentials from user's default codex home so the
            # spawned codex process stays authenticated.
            # TODO: more sophisticated auth
            user_codex_home = Path.home() / ".codex"
            user_auth = user_codex_home / "auth.json"
            if user_auth.exists():
                shutil.copyfile(user_auth, codex_home / "auth.json")
                print(f"[bootstrap] Copied auth from {user_auth}", file=sys.stderr)
            else:
                print(
                    f"[bootstrap] WARNING: No auth.json found at {user_auth}",
                    file=sys.stderr,
                )

            env["CODEX_HOME"] = str(codex_home)
            print(f"[bootstrap] CODEX_HOME set to: {codex_home}", file=sys.stderr)
        except Exception as exc:
            post_log(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                level="warning",
                message="failed to prepare CODEX_HOME config override; continuing",
                extra={"error": repr(exc)},
            )

        self._app = AppServerStdioClient(
            argv=[str(codex_path), "app-server"],
            env=env,
            cwd=self.workspace,
            on_notification=self._handle_app_server_notification,
            request_handler=self._handle_app_server_request,
        )
        _init = self._app.initialize(
            client_info={"name": "flywheel-bootstrap", "version": "0.1"}
        )

        thread_result = self._app.request(
            method="thread/start",
            params={"cwd": str(self.workspace), "experimentalRawEvents": False},
            timeout_seconds=60,
        )
        thread_obj = thread_result.get("thread")
        thread_id = ""
        if isinstance(thread_obj, dict):
            raw_id = thread_obj.get("id")
            if isinstance(raw_id, str):
                thread_id = raw_id
        if not thread_id:
            raise RuntimeError("codex app-server thread/start missing thread id")
        self._thread_id = thread_id

        model = thread_result.get("model")
        if isinstance(model, str) and model:
            self._codex_model = model

        self._log(
            "codex app-server thread started",
            extra={
                "run_event_kind": "command_started",
                "run_event_source": "codex-app-server",
                "thread_id": thread_id,
            },
        )

        with self._interaction_mode_lock:
            self._interaction_mode = "autonomous"

        # Start heartbeat thread
        self._stop_heartbeats.clear()
        self.heartbeat_thread = threading.Thread(
            target=self._heartbeat_loop, daemon=True
        )
        self.heartbeat_thread.start()
        self._stop_control.clear()
        self.control_thread = threading.Thread(target=self._control_loop, daemon=True)
        self.control_thread.start()

        first_autonomous_run = True

        while True:
            if self._stop_control.is_set():
                return 1
            if self._is_interactive_input_running():
                if self._stop_control.wait(CONTROL_POLL_INTERVAL_SECONDS):
                    return 1
                continue

            try:
                prompt = prompt_text
                if not first_autonomous_run:
                    prompt = _AUTONOMOUS_RESUME_PROMPT
                first_autonomous_run = False

                with self._active_prompt_lock:
                    self._active_prompt = True
                success, detail = self._run_prompt_turn(prompt)
            finally:
                with self._active_prompt_lock:
                    self._active_prompt = False

            # Only retriable interrupted turns should continue the loop.
            if self._should_retry_after_turn(success=success, detail=detail):
                self._last_stop_reason = detail
                continue
            self._last_stop_reason = detail
            if success:
                self._log(
                    "codex turn completed",
                    extra={
                        "run_event_kind": "command_finished",
                        "run_event_source": "codex-app-server",
                        "detail": detail,
                    },
                )
                return 0

            # Anything else is treated as failure (interrupted, failed, etc.).
            self._log(
                "codex turn ended",
                level="warning",
                extra={
                    "run_event_kind": "warning",
                    "run_event_source": "codex-app-server",
                    "detail": detail,
                },
            )
            return 1

    def _collect_and_post_artifacts(self, exit_code: int) -> None:
        """Read manifest (and optional resume attempts) then POST /artifacts/complete/error."""
        assert self.workspace is not None
        manifest_path = self.workspace / self.config.artifact_manifest
        manifest_result, artifacts = self._load_artifacts_with_content(manifest_path)

        # Auto-resume up to MAX_ARTIFACT_RETRIES times if artifacts are
        # missing or the manifest was malformed.
        retries = 0
        while not artifacts and retries < MAX_ARTIFACT_RETRIES:
            retries += 1
            self._attempt_artifact_retry(manifest_path, manifest_result)
            manifest_result, artifacts = self._load_artifacts_with_content(
                manifest_path
            )

        if artifacts:
            post_artifacts(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                artifacts,
            )

        if exit_code == 0:
            summary = "engineer run completed"
            post_completion(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                summary,
            )
        else:
            stop_reason = self._last_stop_reason or "unknown"
            reason = f"engineer exit code {exit_code} (stop_reason={stop_reason})"
            post_error(
                self.config.server_url,
                self.config.run_id,
                self.config.capability_token,
                reason=reason,
                summary="engineer failed",
            )

    def _load_artifacts_with_content(
        self, manifest_path: Path
    ) -> tuple[ManifestResult, list[dict[str, object]]]:
        """Load artifacts and inline file-backed payloads under threshold.

        Any artifact payload that references a local file via ``path``/``file``
        is enriched when file size is <= ``self._artifact_blob_threshold_bytes``.
        The enriched payload is later uploaded to blob storage via the existing
        ``POST /runs/{id}/artifacts`` path.
        """
        import base64
        import mimetypes

        assert self.workspace is not None
        manifest_result = read_manifest(manifest_path)
        artifacts = manifest_result.artifacts
        enriched: list[dict[str, object]] = []
        max_blob_size = self._artifact_blob_threshold_bytes

        # Checkpoint file extensions (model weights, etc.)
        checkpoint_extensions = {
            ".pt",
            ".pth",
            ".ckpt",
            ".safetensors",
            ".bin",
            ".h5",
            ".hdf5",
            ".pkl",
            ".pickle",
            ".joblib",
            ".npy",
            ".npz",
            ".onnx",
            ".pb",
        }

        for artifact in artifacts:
            try:
                if not isinstance(artifact, dict):
                    continue
                artifact = dict(artifact)
                artifact_type = str(artifact.get("artifact_type", "")).lower()
                payload_raw = artifact.get("payload", {})
                if not isinstance(payload_raw, dict):
                    enriched.append(artifact)
                    continue
                payload = dict(payload_raw)
                artifact["payload"] = payload

                path_str = payload.get("path") or payload.get("file")
                resolved: Path | None = None
                file_size: int | None = None
                if isinstance(path_str, str) and path_str:
                    path_lower = path_str.lower()
                    if any(path_lower.endswith(ext) for ext in checkpoint_extensions):
                        artifact["artifact_type"] = "checkpoint"
                        artifact_type = "checkpoint"
                    resolved = self._resolve_artifact_path(path_str)
                    if resolved and resolved.is_file():
                        file_size = resolved.stat().st_size
                        payload["size_bytes"] = file_size

                if resolved and file_size is not None:
                    if file_size > max_blob_size:
                        payload["rendering_error"] = (
                            f"File too large ({file_size} bytes, max {max_blob_size})"
                        )
                    elif artifact_type in ("text", "html", "table"):
                        try:
                            payload["content"] = resolved.read_text(encoding="utf-8")
                        except UnicodeDecodeError:
                            self._log(
                                f"failed to read {artifact_type} artifact at {resolved} (encoding)",
                                level="warning",
                            )
                    elif artifact_type == "json":
                        try:
                            text_content = resolved.read_text(encoding="utf-8")
                        except UnicodeDecodeError:
                            self._log(
                                f"failed to read json artifact at {resolved} (encoding)",
                                level="warning",
                            )
                        else:
                            try:
                                payload["content"] = json.loads(text_content)
                            except json.JSONDecodeError:
                                payload["content"] = text_content
                                self._log(
                                    f"json artifact at {resolved} is not valid JSON; sending raw text",
                                    level="warning",
                                )
                    else:
                        # For image/checkpoint/unknown file artifacts, inline as data URL.
                        try:
                            file_data = resolved.read_bytes()
                            mime_type, _ = mimetypes.guess_type(str(resolved))
                            if not mime_type:
                                mime_type = (
                                    "image/png"
                                    if artifact_type == "image"
                                    else "application/octet-stream"
                                )
                            b64 = base64.b64encode(file_data).decode("ascii")
                            payload["data_url"] = f"data:{mime_type};base64,{b64}"
                        except Exception as exc:
                            self._log(
                                f"failed to read {artifact_type} artifact at {resolved}: {exc}",
                                level="warning",
                            )

            except Exception as exc:  # pragma: no cover - defensive
                self._log(f"artifact enrichment error: {exc}", level="warning")
            enriched.append(dict(artifact))
        return manifest_result, enriched

    def _resolve_artifact_path(self, path_str: str) -> Path | None:
        """Resolve artifact path within workspace, returning None if invalid."""
        assert self.workspace is not None
        path = Path(path_str)
        resolved = (self.workspace / path).resolve()
        workspace_root = self.workspace.resolve()
        if resolved == workspace_root or workspace_root in resolved.parents:
            return resolved
        self._log(f"skipping artifact outside workspace: {resolved}", level="warning")
        return None

    def _heartbeat_loop(self) -> None:
        while not self._stop_heartbeats.is_set():
            try:
                post_heartbeat(
                    self.config.server_url,
                    self.config.run_id,
                    self.config.capability_token,
                    summary="alive",
                )
            except Exception as exc:  # pragma: no cover - best effort
                print(f"heartbeat failed: {exc}", file=sys.stderr)
            self._stop_heartbeats.wait(HEARTBEAT_INTERVAL_SECONDS)

    def _get_interaction_mode(self) -> Literal["autonomous", "interactive"]:
        with self._interaction_mode_lock:
            return self._interaction_mode

    def _set_interaction_mode(self, mode: Literal["autonomous", "interactive"]) -> None:
        with self._interaction_mode_lock:
            self._interaction_mode = mode

    def _should_retry_after_turn(self, *, success: bool, detail: str) -> bool:
        if success:
            return False
        if self._get_interaction_mode() != "interactive":
            return False
        return "status=interrupted" in detail

    def _is_interactive_input_running(self) -> bool:
        with self._interactive_input_lock:
            thread = self._interactive_input_thread
        return bool(thread and thread.is_alive())

    def _steer_active_turn(self, text: str) -> tuple[bool, str]:
        """Best-effort: steer the active Codex turn, if one is running."""
        app = self._app
        thread_id = self._thread_id
        if app is None or not thread_id:
            return False, "codex app-server not initialized"
        with self._turn_state_lock:
            turn_id = self._active_turn_id
        if not turn_id:
            return False, "no active turn to steer"
        try:
            # Codex app-server supports steering an in-progress turn. This is
            # preferable to spawning a new turn when the user wants to "take over".
            _ = app.request(
                method="turn/steer",
                params={
                    "threadId": thread_id,
                    "turnId": turn_id,
                    "input": [{"type": "text", "text": text}],
                },
                timeout_seconds=10,
            )
            return True, f"steered turn_id={turn_id}"
        except Exception as exc:  # pragma: no cover - best effort
            return False, f"turn/steer failed: {repr(exc)}"

    def _cancel_active_prompt(self) -> bool:
        app = self._app
        thread_id = self._thread_id
        if app is None or not thread_id:
            return False
        with self._turn_state_lock:
            turn_id = self._active_turn_id
        if not turn_id:
            return False
        try:
            _ = app.request(
                method="turn/interrupt",
                params={"threadId": thread_id, "turnId": turn_id},
                timeout_seconds=10,
            )
            return True
        except Exception as exc:  # pragma: no cover - best effort
            self._log(
                "failed to interrupt active codex turn",
                level="warning",
                extra={"error": repr(exc), "thread_id": thread_id, "turn_id": turn_id},
            )
            return False

    def _run_prompt_turn(self, prompt: str) -> tuple[bool, str]:
        app = self._app
        thread_id = self._thread_id
        if app is None or not thread_id:
            return False, "codex app-server not initialized"

        try:
            result = app.request(
                method="turn/start",
                params={
                    "threadId": thread_id,
                    "input": [{"type": "text", "text": prompt}],
                },
                timeout_seconds=60,
            )
        finally:
            pass

        turn_obj = result.get("turn")
        turn_id = ""
        if isinstance(turn_obj, dict):
            raw = turn_obj.get("id")
            if isinstance(raw, str):
                turn_id = raw
        if not turn_id:
            return False, "turn/start missing turn id"

        done = threading.Event()
        with self._turn_state_lock:
            self._turn_done_events[turn_id] = done
            if turn_id in self._turn_status_by_id:
                done.set()
            else:
                self._active_turn_id = turn_id

        # Poll for turn completion, checking that the app-server process is
        # still alive.  A bare ``done.wait()`` would block forever if the
        # turn/completed notification is lost or malformed.
        while not done.wait(timeout=TURN_COMPLETION_POLL_SECONDS):
            if self._stop_control.is_set():
                break
            if app._closed.is_set():
                self._log(
                    "app-server process exited before turn completed",
                    level="warning",
                )
                break
            proc = app._proc
            if proc is not None and proc.poll() is not None:
                self._log(
                    f"app-server process exited with code {proc.returncode} "
                    "before turn completed",
                    level="warning",
                )
                break

        with self._turn_state_lock:
            status = self._turn_status_by_id.get(turn_id, "unknown")
            error = self._turn_error_by_id.get(turn_id)
            self._turn_done_events.pop(turn_id, None)
            if self._active_turn_id == turn_id:
                self._active_turn_id = None

        if status == "completed":
            return True, f"turn_id={turn_id} status=completed"
        if status == "interrupted":
            return False, f"turn_id={turn_id} status=interrupted"
        if status == "failed":
            return False, f"turn_id={turn_id} status=failed error={error!r}"
        return False, f"turn_id={turn_id} status={status} error={error!r}"

    def _start_send_input_worker(self) -> None:
        with self._interactive_input_lock:
            existing = self._interactive_input_thread
            if existing and existing.is_alive():
                return

            def _worker() -> None:
                while True:
                    with self._interactive_input_lock:
                        if not self._interactive_input_queue:
                            self._interactive_input_thread = None
                            self._interactive_input_command_id = None
                            return
                        command_id, text = self._interactive_input_queue.pop(0)
                        self._interactive_input_command_id = command_id

                    ack_status = "failed"
                    detail = "send_input failed"
                    try:
                        steered, steer_detail = self._steer_active_turn(text)
                        if steered:
                            ack_status = "applied"
                            detail = f"send_input: {steer_detail}"
                        else:
                            # No active turn or steer unsupported: run a new prompt turn.
                            success, resume_detail = self._run_prompt_turn(text)
                            ack_status = "applied" if success else "failed"
                            detail = f"send_input: {resume_detail}"
                    except Exception as exc:  # pragma: no cover - defensive
                        detail = f"send_input failed unexpectedly: {repr(exc)}"
                        self._log(
                            "send_input worker failed unexpectedly",
                            level="error",
                            extra={
                                "command_id": command_id,
                                "command": "send_input",
                                "error": repr(exc),
                            },
                        )

                    level = "info" if ack_status == "applied" else "warning"
                    self._log(
                        "processed send_input",
                        level=level,
                        extra={"command_id": command_id, "command": "send_input"},
                    )
                    try:
                        ack_run_control(
                            self.config.server_url,
                            self.config.run_id,
                            self.config.capability_token,
                            command_id=command_id,
                            status=ack_status,
                            detail=detail,
                        )
                    except Exception as exc:  # pragma: no cover - best effort
                        self._log(
                            "failed to acknowledge send_input command",
                            level="warning",
                            extra={
                                "command_id": command_id,
                                "command": "send_input",
                                "error": repr(exc),
                            },
                        )

            worker = threading.Thread(target=_worker, daemon=True)
            self._interactive_input_thread = worker
            worker.start()

    def _enqueue_send_input(self, command_id: str, text: str) -> tuple[bool, str]:
        with self._interactive_input_lock:
            # Deduplicate by command id to stay safe under poll retries.
            if self._interactive_input_command_id == command_id:
                return False, "send_input already in progress"
            if any(cmd_id == command_id for cmd_id, _ in self._interactive_input_queue):
                return False, "send_input already queued"
            self._interactive_input_queue.append((command_id, text))
        self._start_send_input_worker()
        return True, "send_input queued"

    def _control_loop(self) -> None:
        while not self._stop_control.is_set():
            try:
                command_payload = poll_run_control(
                    self.config.server_url,
                    self.config.run_id,
                    self.config.capability_token,
                )
                status = command_payload.get("status")
                if (
                    isinstance(status, str)
                    and status
                    and status.lower() in {"completed", "errored", "stale"}
                ):
                    # The platform terminated the run (e.g. user cancel). Stop polling,
                    # interrupt any active turn, and let the bootstrap process exit.
                    self._log(
                        "run is terminal; shutting down bootstrap control loop",
                        level="warning",
                        extra={"run_status": status},
                    )
                    self._cancel_active_prompt()
                    self._stop_control.set()
                    break
                self._handle_control_payload(command_payload)
            except Exception as exc:  # pragma: no cover - best effort
                print(f"control poll failed: {exc}", file=sys.stderr)
            self._stop_control.wait(CONTROL_POLL_INTERVAL_SECONDS)

    def _handle_control_payload(self, payload: dict[str, object]) -> None:
        command_id = payload.get("command_id")
        command = payload.get("command")
        if not isinstance(command_id, str) or not isinstance(command, str):
            return

        ack_status = "applied"
        detail = "acknowledged by bootstrap"
        if command == "take_over":
            already_interactive = self._get_interaction_mode() == "interactive"
            if already_interactive:
                detail = "take_over acknowledged; already interactive"
            else:
                self._set_interaction_mode("interactive")
                cancelled = self._cancel_active_prompt()
                if cancelled:
                    detail = "take_over acknowledged; autonomous execution interrupted, interactive mode active"
                else:
                    detail = "take_over acknowledged; interactive mode active"
            self._log(
                "received take_over command",
                extra={"command_id": command_id, "command": command},
            )
        elif command == "resume_autonomous":
            self._set_interaction_mode("autonomous")
            cancelled = self._cancel_active_prompt()
            detail = "resume_autonomous acknowledged; autonomous execution will resume"
            if cancelled:
                detail += " after interrupting active interactive command"
            self._log(
                "received resume_autonomous command",
                extra={"command_id": command_id, "command": command},
            )
        elif command == "send_input":
            command_payload = payload.get("payload")
            text: str | None = None
            if isinstance(command_payload, dict):
                raw_text = command_payload.get("text")
                if isinstance(raw_text, str):
                    stripped = raw_text.strip()
                    if stripped:
                        text = stripped

            if text is None:
                ack_status = "ignored"
                detail = "send_input ignored: missing text payload"
                self._log(
                    "received send_input command without text payload",
                    level="warning",
                    extra={"command_id": command_id, "command": command},
                )
            else:
                queued, queue_detail = self._enqueue_send_input(command_id, text)
                if queued:
                    self._log(
                        "accepted send_input command for async execution",
                        extra={"command_id": command_id, "command": command},
                    )
                    return
                # Duplicate queueing is safe to ignore (idempotent).
                self._log(
                    "ignored duplicate send_input command",
                    level="info",
                    extra={
                        "command_id": command_id,
                        "command": command,
                        "detail": queue_detail,
                    },
                )
                return
        else:
            ack_status = "ignored"
            detail = f"unsupported command '{command}'"
            self._log(
                "received unsupported control command",
                level="warning",
                extra={"command_id": command_id, "command": command},
            )

        ack_run_control(
            self.config.server_url,
            self.config.run_id,
            self.config.capability_token,
            command_id=command_id,
            status=ack_status,
            detail=detail,
        )

    def _sanitize_terminal_text(self, text: str) -> str:
        sanitized = _ANSI_ESCAPE_PATTERN.sub("", text).replace("\r", "")
        if len(sanitized) <= MAX_TERMINAL_OUTPUT_CHARS:
            return sanitized
        suffix = " [truncated]"
        max_prefix = max(0, MAX_TERMINAL_OUTPUT_CHARS - len(suffix))
        return sanitized[:max_prefix] + suffix

    def _handle_app_server_request(self, msg: dict[str, object]) -> dict[str, object]:
        method = msg.get("method")

        if method == "item/commandExecution/requestApproval":
            return {"decision": "acceptForSession"}
        if method == "item/fileChange/requestApproval":
            return {"decision": "acceptForSession"}
        if method == "applyPatchApproval":
            return {"decision": "approved_for_session"}
        if method == "execCommandApproval":
            return {"decision": "approved_for_session"}
        if method == "item/tool/requestUserInput":
            return {"answers": {}}
        if method == "item/tool/call":
            return {"contentItems": [], "success": False}

        return {}

    def _handle_app_server_notification(self, msg: dict[str, object]) -> None:
        method = msg.get("method")
        method_name = method if isinstance(method, str) and method else "notification"
        params = msg.get("params")
        payload = params if isinstance(params, dict) else {}

        # Keep delta buffering from fragmenting on-screen output: when we see a
        # non-delta event, flush any buffered deltas first so words don't get
        # interleaved with item lifecycle logs.
        if (
            method_name not in _DELTA_METHODS
            and method_name not in _IGNORED_DELTA_METHODS
        ):
            self._flush_delta_buffers()

        thread_id = payload.get("threadId")
        turn_id = payload.get("turnId")
        # Best-effort fallback IDs to keep streaming stable when app-server omits fields.
        thread_key = (
            thread_id if isinstance(thread_id, str) else (self._thread_id or "")
        )
        with self._turn_state_lock:
            active_turn_id = self._active_turn_id
        turn_key = turn_id if isinstance(turn_id, str) else (active_turn_id or "")

        if method_name == "sessionConfigured":
            model = payload.get("model")
            if isinstance(model, str) and model:
                self._codex_model = model
            session_id = payload.get("sessionId")
            if isinstance(session_id, str) and session_id and not self._thread_id:
                self._thread_id = session_id
        elif method_name == "turn/started":
            turn = payload.get("turn")
            if isinstance(turn, dict):
                raw_turn_id = turn.get("id")
                if isinstance(raw_turn_id, str):
                    turn_id = raw_turn_id
            if isinstance(thread_id, str) and isinstance(turn_id, str) and turn_id:
                with self._turn_state_lock:
                    if self._active_turn_id is None:
                        self._active_turn_id = turn_id
        elif method_name == "turn/completed":
            turn = payload.get("turn")
            status = "unknown"
            error: object = None
            if isinstance(turn, dict):
                raw_turn_id = turn.get("id")
                if isinstance(raw_turn_id, str):
                    turn_id = raw_turn_id
                raw_status = turn.get("status")
                if isinstance(raw_status, str):
                    status = raw_status
                error = turn.get("error")
            # Fall back to active turn ID if turn_id missing from notification
            if not turn_id:
                with self._turn_state_lock:
                    turn_id = self._active_turn_id or ""
            if isinstance(turn_id, str) and turn_id:
                with self._turn_state_lock:
                    self._turn_status_by_id[turn_id] = status
                    if error is not None:
                        self._turn_error_by_id[turn_id] = error
                    self._turns_completed.add(turn_id)
                    done = self._turn_done_events.get(turn_id)
                    if done is not None:
                        done.set()
                    if self._active_turn_id == turn_id:
                        self._active_turn_id = None
                self._maybe_emit_engineer_usage_for_turn(turn_id)
        elif method_name == "thread/tokenUsage/updated":
            token_usage = payload.get("tokenUsage")
            token_usage_dict = token_usage if isinstance(token_usage, dict) else {}
            totals = token_usage_dict.get("total")
            totals_dict = totals if isinstance(totals, dict) else {}
            try:
                input_tokens = int(totals_dict.get("inputTokens", 0) or 0)
                cached_input_tokens = int(totals_dict.get("cachedInputTokens", 0) or 0)
                output_tokens = int(totals_dict.get("outputTokens", 0) or 0)
                reasoning_output_tokens = int(
                    totals_dict.get("reasoningOutputTokens", 0) or 0
                )
            except (TypeError, ValueError):
                return
            if isinstance(turn_id, str) and turn_id:
                with self._turn_state_lock:
                    self._token_usage_by_turn[turn_id] = (
                        input_tokens,
                        cached_input_tokens,
                        output_tokens,
                        reasoning_output_tokens,
                    )
                self._maybe_emit_engineer_usage_for_turn(turn_id)
            return  # Do not spam UI with usage deltas; billing log is emitted separately.
        elif method_name in _IGNORED_DELTA_METHODS:
            return
        elif method_name in _DELTA_METHODS:
            delta = payload.get("delta")
            if not isinstance(delta, str) or not delta:
                return
            item_id = payload.get("itemId")
            item_id_str = item_id if isinstance(item_id, str) else ""
            if not item_id_str:
                item_id_str = self._last_item_id_by_delta_stream.get(
                    (method_name, thread_key, turn_key), ""
                )
            else:
                self._last_item_id_by_delta_stream[
                    (method_name, thread_key, turn_key)
                ] = item_id_str

            # Some app-server delta notifications omit `itemId`. Use a composite
            # key so we don't accidentally mix multiple items/turns into one
            # stream and create duplicated output.
            stream_key = f"{method_name}:{thread_key}:{turn_key}:{item_id_str}"
            sanitized = self._sanitize_terminal_text(delta)
            previous = self._last_delta_by_stream_key.get(stream_key, "")
            incremental, assembled = _normalize_stream_delta(
                assembled=previous, incoming=sanitized
            )
            if assembled:
                self._last_delta_by_stream_key[stream_key] = assembled
            if not incremental:
                return
            delta_params: dict[str, object] = {}
            if thread_key:
                delta_params["threadId"] = thread_key
            if turn_key:
                delta_params["turnId"] = turn_key
            if item_id_str:
                delta_params["itemId"] = item_id_str
            self._buffer_delta_log(
                stream_key=stream_key,
                method=method_name,
                params=delta_params,
                message=incremental,
            )
            return

        # Item boundaries: clear per-item delta state to avoid cross-item overlap.
        if method_name in ("item/started", "item/completed"):
            item_id = payload.get("itemId")
            item_id_str = item_id if isinstance(item_id, str) else ""
            # Some item lifecycle notifications encode the item in `item`.
            item_obj = payload.get("item")
            if isinstance(item_obj, dict):
                raw_id = item_obj.get("id")
                if isinstance(raw_id, str) and raw_id:
                    item_id_str = raw_id

                raw_type = item_obj.get("type")
                item_type = raw_type if isinstance(raw_type, str) else ""
                # Update stream mapping so subsequent deltas without itemId can be associated.
                if item_id_str and item_type:
                    delta_method: str | None = None
                    if item_type == "agentMessage":
                        delta_method = "item/agentMessage/delta"
                    elif item_type == "commandExecution":
                        delta_method = "item/commandExecution/outputDelta"
                    elif item_type == "fileChange":
                        delta_method = "item/fileChange/outputDelta"
                    if delta_method:
                        self._last_item_id_by_delta_stream[
                            (delta_method, thread_key, turn_key)
                        ] = item_id_str

            if item_id_str:
                for delta_method in _DELTA_METHODS:
                    key = f"{delta_method}:{thread_key}:{turn_key}:{item_id_str}"
                    self._last_delta_by_stream_key.pop(key, None)
                    with self._delta_buffers_lock:
                        self._delta_buffers.pop(key, None)
                # Also clear the best-effort mapping when the item completes, so we don't
                # accidentally associate deltas from the next item with the old id.
                if method_name == "item/completed":
                    for delta_method in _DELTA_METHODS:
                        self._last_item_id_by_delta_stream.pop(
                            (delta_method, thread_key, turn_key), None
                        )

        # For non-delta notifications, forward the raw app-server event so the
        # platform can persist item boundaries and project a transcript.
        post_app_server_event(
            self.config.server_url,
            self.config.run_id,
            self.config.capability_token,
            method=method_name,
            params=payload,
        )

    def _attempt_artifact_retry(
        self, manifest_path: Path, manifest_result: ManifestResult
    ) -> None:
        """Retry artifact collection via Codex turn."""

        manifest_name = self.config.artifact_manifest

        if manifest_result.status == ManifestStatus.MALFORMED:
            error_detail = manifest_result.error or "unknown error"
            raw_content = ""
            if manifest_path.exists():
                try:
                    raw_content = manifest_path.read_text(encoding="utf-8")[:2000]
                except Exception:
                    raw_content = "<could not read file>"

            fix_prompt = (
                "The artifact manifest file at "
                f"$FLYWHEEL_WORKSPACE/{manifest_name} is malformed.\n\n"
                f"Error: {error_detail}\n\n"
                f"Current file contents:\n{raw_content}\n\n"
                "Please rewrite this file so it is a valid JSON list of "
                "artifact entries. Each entry must be an object with "
                '"artifact_type" and "payload" keys. The file must be a '
                "top-level JSON array, for example:\n"
                "[\n"
                '  {"artifact_type": "text", "payload": {"content": "..."}},\n'
                '  {"artifact_type": "image", "payload": {"path": "plot.png",'
                ' "format": "png"}}\n'
                "]\n\n"
                "Do NOT wrap the list in an object. The file must start with "
                "[ and end with ].\n"
                "Only fix the manifest format — do not change the actual "
                "artifact content or paths."
            )
            log_msg = "attempting codex turn to fix malformed artifact manifest"
        else:
            # MISSING — the file was never written.
            fix_prompt = (
                "The artifact manifest file was not found at "
                f"$FLYWHEEL_WORKSPACE/{manifest_name}.\n\n"
                "Your task already completed successfully, but the manifest "
                "file is missing. Please write the manifest now.\n\n"
                "The file must be a valid JSON list of artifact entries. "
                'Each entry must be an object with "artifact_type" and '
                '"payload" keys. The file must be a top-level JSON array, '
                "for example:\n"
                "[\n"
                '  {"artifact_type": "text", "payload": {"content": "..."}},\n'
                '  {"artifact_type": "image", "payload": {"path": "plot.png",'
                ' "format": "png"}}\n'
                "]\n\n"
                "Do NOT wrap the list in an object. The file must start with "
                "[ and end with ].\n"
                "Look at the files you produced in the workspace and create "
                "the manifest based on what you find."
            )
            log_msg = "attempting codex turn to write missing artifact manifest"

        self._log(
            log_msg,
            extra={
                "status": manifest_result.status.value,
                "error": manifest_result.error,
            },
        )

        try:
            success, detail = self._run_prompt_turn(fix_prompt)
            self._log(
                "artifact retry prompt finished",
                level="info" if success else "warning",
                extra={"detail": detail},
            )
        except Exception as exc:  # pragma: no cover
            self._log(
                "artifact retry prompt failed",
                level="error",
                extra={"error": repr(exc)},
            )

    def _log(
        self, message: str, level: str = "info", extra: dict[str, object] | None = None
    ) -> None:
        """Lightweight logger that routes to telemetry."""
        post_log(
            self.config.server_url,
            self.config.run_id,
            self.config.capability_token,
            level=level,
            message=message,
            extra=extra or {},
        )

    # --- mock helpers (used in tests via BOOTSTRAP_MOCK_ENGINEER=1) ---

    def _write_mock_manifest(self) -> None:
        assert self.workspace is not None
        manifest_path = self.workspace / self.config.artifact_manifest
        manifest = [{"artifact_type": "text", "payload": {"content": "mock artifact"}}]
        manifest_path.write_text(json.dumps(manifest), encoding="utf-8")


def build_config(args: Any) -> BootstrapConfig:
    """Construct BootstrapConfig from CLI args and environment."""

    server_url = args.server or os.environ.get(ENV_SERVER_URL, DEFAULT_SERVER_URL)
    config_path = Path(args.config).expanduser().resolve()
    return BootstrapConfig(
        run_id=args.run_id or _env_or_throw(ENV_RUN_ID, "run id"),
        capability_token=args.token or _env_or_throw(ENV_RUN_TOKEN, "capability token"),
        config_path=config_path,
        server_url=server_url,
    )


def _env_or_throw(var: str, label: str) -> str:
    value = os.environ.get(var)
    if not value:
        raise SystemExit(f"missing {label} (pass flag or set {var})")
    return value
