#!/usr/bin/env python3
"""Codex Launcher shared library — pure Python stdlib, zero GUI dependencies.

Provides cross-platform utility functions for both the GTK GUI (Linux) and
the tkinter GUI (Windows).  No pip dependencies.  No GTK/PyGObject imports.
"""

import base64
import collections
import contextlib
import hashlib
import json
import os
import re
import secrets
import shutil
import signal
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

UA = "codex-launcher/1.0"

# ═══════════════════════════════════════════════════════════════════════
# Platform detection
# ═══════════════════════════════════════════════════════════════════════

IS_WINDOWS = sys.platform == "win32"
HOME = Path.home()

if IS_WINDOWS:
    _LOCAL_APPDATA = Path(os.environ.get("LOCALAPPDATA", HOME / "AppData/Local"))
    PROXY_CONFIG_DIR = _LOCAL_APPDATA / "codex-proxy"
    CONFIG_DIR = HOME / ".codex"
    BIN_DIR = _LOCAL_APPDATA / "Programs" / "Codex-Launcher"
    LOG_DIR = _LOCAL_APPDATA / "codex-proxy"
    PID_REGISTRY = _LOCAL_APPDATA / "codex-proxy" / "pids.json"
    _USAGE_STATS_FILE = _LOCAL_APPDATA / "codex-proxy" / "usage-stats.json"
    MONITORING_FILE = _LOCAL_APPDATA / "codex-proxy" / "monitoring-config.json"
    INCIDENT_STORE_FILE = _LOCAL_APPDATA / "codex-proxy" / "incident-store.json"
    MONITORING_LOG = _LOCAL_APPDATA / "codex-proxy" / "monitoring.log"
    REQUEST_SNAP_DIR = _LOCAL_APPDATA / "codex-proxy" / "requests"
else:
    PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
    CONFIG_DIR = HOME / ".codex"
    BIN_DIR = HOME / ".local/bin"
    LOG_DIR = HOME / ".cache/codex-proxy"
    PID_REGISTRY = HOME / ".cache/codex-proxy" / "pids.json"
    _USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json"
    MONITORING_FILE = HOME / ".cache/codex-proxy/monitoring-config.json"
    INCIDENT_STORE_FILE = HOME / ".cache/codex-proxy/incident-store.json"
    MONITORING_LOG = HOME / ".cache/codex-proxy/monitoring.log"
    REQUEST_SNAP_DIR = HOME / ".cache/codex-proxy/requests"

CONFIG = CONFIG_DIR / "config.toml"
CONFIG_BAK = CONFIG_DIR / "config.toml.launcher-bak"
CONFIG_TXN = CONFIG_DIR / "config.toml.launcher-txn.json"
ENDPOINTS_FILE = CONFIG_DIR / "endpoints.json"
BGP_POOLS_FILE = CONFIG_DIR / "bgp-pools.json"
LAUNCH_LOG = LOG_DIR / "launcher.log"
OAUTH_SECRETS_PATH = HOME / ".config" / "codex-launcher" / "oauth-secrets.json"

if IS_WINDOWS:
    PROXY = BIN_DIR / "translate-proxy.py"
    CLEANUP = BIN_DIR / "cleanup-codex-stale.py"
    START_SH = None
else:
    PROXY = BIN_DIR / "translate-proxy.py"
    CLEANUP = BIN_DIR / "cleanup-codex-stale.sh"
    START_SH = Path("/opt/codex-desktop/start.sh")

DEFAULT_CONFIG = """model = ""
model_provider = ""
model_catalog_json = ""
"""

CHANGELOG = [
    ("10.13.6", "2026-05-27", [
        "Anti-loop: cross-session tracker, tool-call budget (150), file read-loop detection",
        "Auto 401 token refresh with retry",
        "Model-aware idle timeout: flash 120s, pro 300s",
        "Smart compaction: directive summary on read-loops",
        "Default provider policy for unrecognized backends",
        "Anti-stall fix: no longer kills own parent/process group",
        "Codex Desktop Updater: check/install/rollback/manual rebuild",
        "Fix Codex CLI 0.134.0 profiles: separate .config.toml files",
        "Fix compaction: max_input_items 60->200 for 1M-token models",
        "E2E test suite: bash test-antigravity.sh [--task]",
        "Merge cobra91 PR #17: MSIX Desktop launch, button state",
    ]),
    ("3.12.1", "2026-05-27", [
        "Fix Antigravity adapter (PR #15): simplify model resolution",
        "Removed broken schema sanitization, restored correct headers",
        "Expanded model alias map for all Antigravity variants",
        "Re-enabled gRPC fallback by default",
    ]),
    ("3.12.0", "2026-05-27", [
        "gRPC auto-fallback for Antigravity provider (PR #13)",
        "New antigravity_grpc module with protobuf client",
        "REST 404 triggers gRPC fallback using display names",
        "gRPC supports streaming and unary generate",
        "Dynamic version fetch with probe validation",
        "Antigravity v2 handler rewrite (anti-api approach)",
        "Safety settings, stopSequences, sessionId, requestType: agent",
    ]),
    ("3.11.11", "2026-05-26", [
        "Final trimming only removes plain messages, never function_call_output",
    ]),
    ("3.11.10", "2026-05-26", [
        "Fix Antigravity: interleave function_call/output pairs in correct sequence (PR #11)",
        "Fix Gemini sanitizer: trim leading/trailing non-user turns for Google API compliance",
        "Stricter function call/response isolation — no merging across role boundaries",
    ]),
    ("3.11.9", "2026-05-26", [
        "Fix Antigravity: preserve functionCall/functionResponse in Gemini sanitizer (PR #10)",
        "Prevents tool responses from being merged/dropped in multi-turn Antigravity sessions",
    ]),
    ("3.11.8", "2026-05-26", [
        "Vision description cache persisted across requests (no redundant API calls for same image)",
        "Merge PR #8: fix vision cache persistence across requests",
    ]),
    ("3.11.7", "2026-05-26", [
        "Vision auto-detect: uses provider's own vision model (e.g. 0G-Qwen-VL) as fallback for image description",
        "Vision preprocessing replaces image stripping: images described via API instead of just removed",
        "Fix AttributeError in image_url handling when value is string not dict",
        "Merge PR #6: vision/OCR preprocessing for text-only models",
        "Merge PR #7: 177 unit tests for translate-proxy.py",
        "Auth os error 2 fix: GUI shows config-missing message instead of raw error",
    ]),
    ("3.11.6", "2026-05-26", [
        "Antigravity loop breakers: per-session tracking, edit-intent nudge (first turn only)",
        "Loop breaker: same tool+args repeated 5+ times triggers force finalization",
        "Latest user instruction appended exactly once per request",
        "Detailed [antigravity-loop] logging for all tracking fields",
        "has_content fix: function_call now counts as valid output (no more infinite loops)",
        "Antigravity-only changes, no touch to other providers",
    ]),
    ("3.11.5", "2026-05-26", [
        "Token-aware compaction: fixes context_length_exceeded on small-context models (25 items x 1600 tokens)",
        "Proactive compaction triggers on token count (>80% model limit), not just item count",
        "Universal adaptive compaction: removed crof.ai-only gates, all providers get compaction",
        "Vision model detection: strips images for non-vision models, keeps for vision-capable ones",
        "Per-model token limit learning from context_length_exceeded error messages",
        "Compaction aggression levels: normal vs extreme when tokens > 1.5x model limit",
        "Smart-continue text-tool detection: triggers on tool-call text patterns, not just function_call_output",
        "Active endpoint sync: GUI auto-removes stale endpoint references on startup",
    ]),
    ("3.11.0", "2026-05-26", [
        "Merge cobra PR: concurrency semaphore (max 3), auto-continue for truncated text",
        "SO_REUSEADDR on sticky port, proxy-stderr.log, stream diagnostics logging",
        "Timeout/OSError handler sends response.failed SSE instead of silent drop",
        "Restart Proxy button: only restarts proxy without killing Codex Desktop",
        "Tool call argument normalizer: fixes Arguments->arguments, strips markdown wrapping",
        "Smart-continue loop (2x retries): escalating nudges when model stops text-only mid-task",
        "XML tool call extraction: parses patterns from text, injects as real calls",
        "Auto-continue + smart-continue ordered with skip guard to avoid double-firing",
        "API key hot-reload with mtime tracking + /admin/reload + /admin/verify-key endpoints",
        "GUI hot-reload: auto-refreshes proxy key on endpoint edit, verifies with upstream",
        "Synthetic tool-results disabled: was causing deepseek-v4-pro truncation on opencode.ai",
    ]),
    ("3.10.12", "2026-05-26", [
        "Sticky endpoint: caches last working endpoint, sequential fallback on failure",
        "Endpoint order: cloudcode-pa first (matches agy CLI), daily-cloudcode-pa fallback",
        "Anti-stall engine: kills stale proxy processes + clears pycache on startup",
        "Smart error classification: quota vs capacity vs banned vs validation vs auth",
        "Rate limit reset parsing: extracts cooldown from error body for accuracy",
        "Missing headers: X-Client-Name, X-Client-Version, x-goog-api-client, sessionId",
        "Guardrail skip: simple messages (hi) skip agent guardrail, no more tool-call loops",
        "Claude fixes: preserve all tools, skip compaction/normalizer/sanitization for Claude",
        "Normalizer model param: distinguishes Claude vs Gemini for correct behavior",
    ]),
    ("3.10.11", "2026-05-26", [
        "Hybrid endpoint fallback: cloudcode-pa then daily-cloudcode-pa on 429",
        "daily-cloudcode-pa.googleapis.com (same endpoint agy-core uses)",
        "429 errors log full response body for debugging",
        "Rate-limit marking only after ALL endpoints fail",
        "Restored SERVICE_DISABLED (403) fallthrough",
    ]),
    ("3.10.10", "2026-05-25", [
        "Fix normalizer stripping ALL context after compaction on resumed sessions",
        "No auto-reset when compaction summary present (preserves 1925+ turn history)",
        "Always preserve compaction summaries in normalizer output",
        "Deduplicate consecutive identical goal_context messages",
        "Emergency reset preserves compaction summaries",
        "Fix hashlib NameError in _antigravity_normalize_context (string comparison instead)",
    ]),
    ("3.10.9", "2026-05-25", [
        "Antigravity: production-only endpoints (cloudcode-pa.googleapis.com), sandbox blocked unless ALLOW_ANTIGRAVITY_STAGING=1",
        "Antigravity: 403 SERVICE_DISABLED falls through, 429 returns to client (no sandbox fallback)",
        "AntigravityContextNormalizer: bounded context — simple messages send minimal payload",
        "Simple message detector: 'hi' etc sends only user message, no tool history",
        "Auto-reset polluted context: 200+ items with simple message resets to minimal",
        "Duplicate user message removal, tool output budget (max 2 verbatim, rest summarized)",
        "Hard limits: 20 contents, 120K/250K/500K char budgets",
        "Claude thinking fix: maxOutputTokens=64000, snake_case thinking config, VALIDATED toolConfig",
        "Claude budgets: low=8192, medium=16384, high=32768",
        "All fixes scoped to OAUTH_PROVIDER==google-antigravity only",
        "Project discovery uses production endpoint (not staging)",
        "z.ai: full OpenClaw attribution headers (cobra91 PR #4)",
        "OpenRouter: X-OpenRouter-Cache header (cobra91 PR #4)",
        "Fix Linux Re-OAuth: load_oauth_secrets() was undefined",
        "Fix GLib.idle_add lambda returning truthy tuple",
    ]),
    ("3.10.7", "2026-05-25", [
        "Prompt Enhancer: per-provider toggle to improve prompt clarity after compaction",
        "Two modes: offline (template injection) and ai-powered (external LLM rewrites)",
        "Offline mode: injects structured instructions to keep model focused post-compaction",
        "AI-powered mode: uses configurable model/URL/key to rewrite prompts for clarity",
        "Linux/Windows GUI: Prompt Enhancer switch + mode selector + model/URL/key fields",
        "Prevents lost context issues in long sessions with aggressive compaction",
    ]),
    ("3.10.6", "2026-05-25", [
        "Freebuff integration: free DeepSeek/Kimi via codebuff.com API",
        "Fixed Freebuff User-Agent to match official SDK (ai-sdk/openai-compatible/1.0.25/codebuff)",
        "Fixed Freebuff metadata: freebuff_instance_id + client_id (base36) + cost_mode: free",
        "Fixed Codebuff OAuth: use www.codebuff.com (307 redirect on bare domain)",
        "GUI preset aliases: Freebuff, FreeBuff, Codebuff all map to same backend",
        "Windows GUI consolidated into src/ (merged by cobra91)",
        "CROF adaptive logic gated to crof.ai only — no log pollution for other providers",
        "Data dir consolidation: all data in codex-proxy/",
        "Sticky proxy port: persists in .last-proxy-port for restart persistence",
        "Adaptive compact budget raised 60% to 80% for large-context models",
        "Config cleanup fix: stale proxy-*.json moved after _init_runtime()",
        "Windows GUI: Clear Log, Restart Proxy, View Log buttons (cobra91 PR #3)",
        "OAuth Secrets dialog shows all providers: Google + Freebuff/Codebuff",
        "Re-OAuth buttons for each provider: re-authenticate Google or GitHub/Codebuff",
        "Token status indicators (valid/missing) for each Google provider",
        "Shows logged-in email and auth status for Freebuff/Codebuff",
        "Linux/Windows feature parity: both GUIs have identical features",
        "Windows: OAuth Secrets all-providers + Codebuff OAuth + Sync from Preset",
        "Linux: Clear Log + Restart Proxy buttons added",
    ]),
    ("3.10.5", "2026-05-25", [
        "Context compaction for Antigravity/Gemini OAuth — prevents token limit errors",
        "Aggressive compaction policies at 60% of model context limit",
        "Compaction for cloudcode-pa and googleapis provider policies",
        "REST model IDs added to context size map (gemini-3-flash, etc.)",
        "OAuth Secrets editor in GUI — update client ID/secret without editing files",
        "Secrets stored in ~/.config/codex-launcher/oauth-secrets.json (not in repo)",
        "Import JSON button — import client_secret_*.json from Google Cloud Console",
        "All hardcoded OAuth secrets removed from source code",
        "Antigravity model IDs fixed: display names → slug model IDs for REST API",
        "Git history scrubbed of leaked credentials; pre-push hook installed",
        "Antigravity REST API model IDs verified with live API testing",
        "Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B all working",
    ]),
    ("3.9.9", "2026-05-25", [
        "Refresh Antigravity preset: Gemini 3.5 Flash, Gemini 3.1 Pro, Claude 4.6, GPT-OSS",
        "Fix Antigravity alias map for tiered model IDs (high/medium/low/thinking)",
        "Model context sizes for Gemini 3.5 Flash, 3.1 Pro, Claude 4.6, GPT-OSS 120B",
    ]),
    ("3.9.8", "2026-05-25", [
        "Fix Desktop model leak — remap gpt-5.4-mini to user-selected model",
        "send_json() catches BrokenPipeError globally — no crashes on disconnect",
        "Proxy remaps Desktop forced models via CODEX_LAUNCHER_MODEL env",
    ]),
    ("3.9.7", "2026-05-25", [
        "Forward real Codebuff error messages instead of generic 429",
        "Return HTTP 200 with Responses API format for rate limits",
        "Extract retryAfterMs from Codebuff 429 responses for cooldown",
        "RateLimitError carries upstream message through all paths",
        "BrokenPipeError fix on 'all accounts exhausted' response",
        "Fix 3 SyntaxWarnings for invalid escape sequences",
        "_codebuff_start_run returns actual error body",
    ]),
    ("3.9.6", "2026-05-25", [
        "Fix Gemini follow-up turns: enforce latest user instruction as final turn",
        "Edit-intent detection with tool-use nudge for file modifications",
        "Thought signature preservation for Gemini 3 tool-call continuity",
        "Smart tool output compaction: old=3000, recent=20000 chars",
        "Multi-account rotation for codebuff, Google OAuth, API keys",
        "/v1/accounts endpoint for account pool status",
    ]),
    ("3.9.0", "2026-05-24", [
        "Multi-account rotation for OAuth providers (codebuff, Google, API keys)",
        "Automatic failover on rate limit — next account used",
        "Codebuff: accounts[] array in credentials.json",
        "Google OAuth: multiple token files (google-*-oauth-token-N.json)",
        "API keys: comma-separated keys rotate on 429 errors",
        "/v1/accounts endpoint shows account pool status",
        "x-codebuff-model and x-codebuff-instance-id headers",
    ]),
    ("3.8.4", "2026-05-24", [
        "Codebuff streaming — SSE events reach Codex client",
        "stream_buffered_events now called for codebuff",
        "Codebuff OAuth built-in login flow (no external CLI)",
        "Codebuff API: reverse-engineered www.codebuff.com endpoints",
        "Codebuff session management with instance ID",
        "Codebuff agent run lifecycle (start/finish) with model routing",
        "Free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
        "Reasoning mode works with codebuff (thinking tokens supported)",
        "GUI: Sandbox mode selector (Read-only / Workspace / Full Access)",
        "GUI: Approval mode selector (Untrusted / On Request / Full Auto)",
        "GUI: Codebuff Login button in endpoint editor",
    ]),
    ("3.8.1", "2026-05-24", [
        "Freebuff integration — free DeepSeek V4 Pro, V4 Flash, Kimi K2.6, MiniMax M2.7",
        "Freebuff backend: auto agent-run lifecycle, credential detection, model routing",
        "Restored all provider presets (Command Code, Crof, OpenAdapter, OpenRouter, etc.)",
        "AI Monitoring — self-healing watchdog with 3-tier response system",
        "HealthWatcher: monitors proxy health every 5s, auto-restarts on crash",
        "LogAnalyzer: tails debug logs for 18 failure signal patterns",
        "Tier 1: 14 rule-based auto-recovery rules (< 1 s response)",
        "Tier 2: Incident pattern store with success rate tracking",
        "Tier 3: AI diagnostic agent — configurable provider/model for novel failures",
        "30 fault types catalogued across 5 categories (A-E)",
        "GUI: AI Monitor panel with ON/OFF, provider selector, incident log",
        "Enhanced /health endpoint with memory and uptime metrics",
    ]),
    ("3.7.0", "2026-05-22", [
        "Intelligence Routing — self-healing parser system for Command Code",
        "Layer 1: Deep URL extraction from nested JSON in explore_agent blocks",
        "Layer 2: Auto-proceed on require_escalation / request_escalation_permission blocks",
        "Layer 3: Intent-based command synthesis when all parsers fail (5 heuristics)",
        "Module-level _build_explore_cmd() — reuses URL extraction across parser + stream",
        "54 self-test patterns covering all three Intelligence Routing layers",
    ]),
    ("3.6.0", "2026-05-22", [
        "Connection pooling — persistent HTTPS connections per host",
        "Stream idle timeout (300s) — kills silent streams instead of hanging",
        "Retry-After header support on all retry paths",
        "Bounded stream buffers (8MB) — prevents OOM",
        "Dual logging to proxy.log + stderr",
    ]),
    ("3.5.0", "2026-05-22", [
        "Command Code adapter overhaul — 17 patches for multi-format tool-call parsing",
        "DSML, XML, explore_agent, bash blocks, raw JSON parser chain",
        "Self-revive watchdog — auto-restarts proxy on crash",
        "Debug-to-file logging in cc-debug.log",
        "Inline self-test (19 patterns)",
    ]),
    ("3.3.0", "2026-05-20", [
        "Antigravity + Gemini CLI OAuth — full Codex agent loop working",
        "Auto-continue on MAX_TOKENS for Gemini/Antigravity",
        "BGP++ route scoring and provider policy layer",
    ]),
    ("3.0.0", "2026-05-20", [
        "Major overhaul — ThreadingHTTPServer, thread-safe state, graceful shutdown",
        "Dynamic port allocation, proxy health gating, atomic config",
        "Usage Dashboard v2 with dark theme",
    ]),
    ("2.7.0", "2026-05-20", [
        "Usage Dashboard redesigned (OpenUsage-inspired dark theme)",
        "TCP_NODELAY streaming, Anthropic prompt caching",
    ]),
    ("2.6.1", "2026-05-20", [
        "Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed",
        "Uses Google's public OAuth client_id (same as gemini-cli)",
        "PKCE + CSRF state protection for secure auth",
        "Just click OAuth Login — browser opens — authorize — done",
        "Includes cloud-platform scope for Gemini Code Assist compatibility",
    ]),
    ("2.6.0", "2026-05-20", [
        "Usage Dashboard — per-provider request/token/latency tracking",
        "Visual cards with success rate bars, model breakdown, error tracking",
        "Google OAuth: browse for client_secret.json instead of fixed path",
    ]),
    ("2.5.1", "2026-05-20", [
        "Adaptive retry for 429/502/503 errors with exponential backoff",
        "BGP routes also retry transient errors before failing over",
        "Proxy socket reuse — no more 'Address already in use' crashes",
        "BGP route count shown at proxy startup",
    ]),
    ("2.5.0", "2026-05-20", [
        "AI BGP — multi-provider routing with automatic failover",
        "Create BGP pools with ordered routes from any configured endpoint",
        "Each route uses its own endpoint URL, API key, and model",
        "Failover strategy: tries primary, falls back on error/timeout",
        "BGP pools appear in endpoint dropdown with shuffle icon",
        "Up/down reordering for route priority in pool editor",
        "Fixed TOML config breakage from multi-line paste in fields",
    ]),
    ("2.4.0", "2026-05-20", [
        "Added OpenAdapter provider preset (api.openadapter.in)",
        "One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini",
        "Fixed Add/Edit dialog crash (missing _on_reasoning_toggled method)",
        "Redesigned Google OAuth flow with live status dialog",
    ]),
    ("2.3.2", "2026-05-20", [
        "Added Google Gemini provider with OAuth support",
        "Two presets: 'Google Gemini (API Key)' and 'Google Gemini (OAuth)'",
        "OAuth Login button in endpoint editor — full Google OAuth2 flow with auto-refresh",
        "Auto-refreshes OAuth access tokens when expired (no manual re-login needed)",
        "Supports gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, and more",
        "Uses Gemini's OpenAI-compatible endpoint — works with existing proxy",
    ]),
    ("2.3.0", "2026-05-20", [
        "Adaptive Crof self-healing system — auto-adjusts to Crof model limits",
        "Tracks per-model success/failure history, learns item count limits dynamically",
        "Proactively compacts input when above learned limit before sending to Crof",
        "Auto-retries on finish_reason=length — aggressively compacts and resends",
        "Prevents 'stream disconnected' and 'incomplete' errors on long conversations",
    ]),
    ("2.2.1", "2026-05-20", [
        "Fixed compaction orphaning function_call_output items — root cause of Crof incomplete responses",
        "Compaction now respects function_call/function_call_output pairs — no more dangling tool results",
        "Fixed reasoning control: reasoning_effort=none now always sends enable_thinking=false too",
    ]),
    ("2.2.0", "2026-05-20", [
        "Added per-provider Reasoning On/Off toggle in endpoint editor",
        "Added Reasoning Effort level per provider: None, Minimal, Low, Medium, High, Max",
        "When reasoning is OFF: sends enable_thinking=false + reasoning_effort=none to upstream API",
        "When reasoning is ON: sends user-selected effort level (default: Medium)",
        "Fixes Crof mimo-v2.5-pro and similar reasoning models exhausting output tokens",
        "Strip reasoning_content from proxy output — Codex doesn't use it",
        "Force max_tokens=64000 minimum for openai-compat providers",
    ]),
    ("2.1.3", "2026-05-19", [
        "Fixed Crof mimo-v2.5-pro stopping: reasoning_content exhausted all output tokens",
        "Strip reasoning_content from proxy output — Codex doesn't use it, avoids token waste",
        "Force max_tokens=64000 minimum for openai-compat providers",
    ]),
    ("2.1.2", "2026-05-19", [
        "Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)",
        "Codex sends function_call items with id=None — proxy now matches tool results to calls by position",
        "Fixed orphan message output item when response has only tool calls (no text)",
        "Auto-trims long conversations (>30 items) to prevent context overflow on providers like Crof",
        "Added request/response logging to ~/.cache/codex-proxy/requests.log",
    ]),
    ("2.1.1", "2026-05-19", [
        "Fixed proxy: map 'developer' role to 'system' for Chat Completions providers",
        "Fixed proxy: map 'developer' role to 'user' for Anthropic providers",
        "Forward 'instructions' field from Responses API as system message/param",
        "Fixes DeepSeek and other providers rejecting unknown 'developer' role",
    ]),
    ("2.1.0", "2026-05-19", [
        "Added Codex auth status detection (codex login status)",
        "Added Re-login button to re-authenticate via codex login",
        "Auto-checks auth before launching Codex Default mode",
        "Warns if OAuth token expired or missing before launch",
    ]),
    ("2.0.1", "2026-05-19", [
        "Added Codex CLI/Desktop installation verifier to main page",
        "Disables Desktop/CLI launch buttons when corresponding tool is missing",
        "Shows install instructions in status area on startup",
    ]),
    ("2.0.0", "2026-05-19", [
        "Initial release: multi-provider Codex Launcher",
        "Translation proxy: Responses API to Chat Completions + Anthropic Messages",
        "GTK endpoint manager with 10+ provider presets",
        "Codex Default mode (built-in OAuth, zero config)",
        "Browser UA injection for Cloudflare-protected providers (OpenCode)",
        "Streaming SSE, tool calls, reasoning content support",
        "Profile backup/import, model auto-fetch, bulk import",
        "Refresh Models in background thread",
        "URL normalization to prevent double-path bugs",
        "Config backup/restore around sessions",
        ".deb installer package",
    ]),
]

# ═══════════════════════════════════════════════════════════════════════
# Provider presets (17 providers)
# ═══════════════════════════════════════════════════════════════════════

PROVIDER_PRESETS = {
    "Custom": {
        "backend_type": "openai-compat",
        "base_url": "",
        "models": [],
    },
    "OpenAI": {
        "backend_type": "native",
        "base_url": "https://api.openai.com/v1",
        "models": ["gpt-4o", "gpt-4o-mini"],
    },
    "Anthropic": {
        "backend_type": "anthropic",
        "base_url": "https://api.anthropic.com/v1",
        "models": ["claude-sonnet-4-5", "claude-3-5-haiku-latest"],
    },
    "OpenCode Zen (OpenAI-compatible)": {
        "backend_type": "openai-compat",
        "base_url": "https://opencode.ai/zen/v1",
        "models": [
            "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
            "minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free",
            "deepseek-v4-flash-free", "nemotron-3-super-free",
            "qwen3.6-plus", "qwen3.5-plus", "big-pickle",
        ],
    },
    "OpenCode Zen (Anthropic)": {
        "backend_type": "anthropic",
        "base_url": "https://opencode.ai/zen/v1",
        "models": [
            "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5",
            "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5",
            "claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku",
        ],
    },
    "OpenCode Go (OpenAI-compatible)": {
        "backend_type": "openai-compat",
        "base_url": "https://opencode.ai/zen/go/v1",
        "models": [
            "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
            "mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5",
            "qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash",
        ],
    },
    "OpenCode Go (Anthropic)": {
        "backend_type": "anthropic",
        "base_url": "https://opencode.ai/zen/go/v1",
        "models": ["minimax-m2.7", "minimax-m2.5"],
    },
    "Crof.ai": {
        "backend_type": "openai-compat",
        "base_url": "https://crof.ai/v1",
        "models": [],
    },
    "NVIDIA NIM": {
        "backend_type": "openai-compat",
        "base_url": "https://integrate.api.nvidia.com/v1",
        "models": [],
    },
    "Kilo.ai Gateway": {
        "backend_type": "openai-compat",
        "base_url": "https://api.kilo.ai/api/gateway",
        "models": [],
    },
    "Command Code": {
        "backend_type": "command-code",
        "base_url": "https://api.commandcode.ai",
        "cc_version": "0.26.8",
        "models": [
            "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro",
            "anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4-5-20251001",
            "anthropic:claude-opus-4-7", "anthropic:claude-opus-4-6",
            "openai:gpt-5.5", "openai:gpt-5.4", "openai:gpt-5.4-mini", "openai:gpt-5.3-codex",
            "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5",
            "zai-org/GLM-5.1", "zai-org/GLM-5",
            "MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.5",
            "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus",
            "stepfun/Step-3.5-Flash", "google/gemini-3.1-flash-lite",
        ],
    },
    "OpenRouter": {
        "backend_type": "openai-compat",
        "base_url": "https://openrouter.ai/api/v1",
        "models": [],
    },
    "Google Gemini (API Key)": {
        "backend_type": "openai-compat",
        "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
        "models": [
            "gemini-2.5-flash", "gemini-2.5-pro",
            "gemini-2.0-flash", "gemini-2.0-flash-lite",
            "gemini-2.5-flash-preview-native-audio-dialog",
        ],
    },
    "Google Gemini (OAuth)": {
        "backend_type": "gemini-oauth-cli",
        "base_url": "https://cloudcode-pa.googleapis.com",
        "oauth_provider": "google-cli",
        "models": [
            "gemini-2.5-flash", "gemini-2.5-pro",
        ],
    },
    "Google Antigravity (OAuth)": {
        "backend_type": "gemini-oauth-antigravity",
        "base_url": "https://cloudcode-pa.googleapis.com",
        "oauth_provider": "google-antigravity",
        "models": [
            "antigravity-gemini-3-flash",
            "antigravity-gemini-3-pro",
            "antigravity-gemini-3.1-pro",
            "antigravity-claude-sonnet-4-6",
            "antigravity-claude-opus-4-6-thinking",
            "gemini-2.5-flash", "gemini-2.5-pro",
            "gemini-3-flash-preview", "gemini-3-pro-preview", "gemini-3.1-pro-preview",
        ],
    },
    "OpenAdapter": {
        "backend_type": "openai-compat",
        "base_url": "https://api.openadapter.in/v1",
        "models": [
            "0G-DeepSeek-V3",
            "0G-DeepSeek-v4-Pro",
            "0G-GLM-5",
            "0G-GLM-5.1",
            "0G-Qwen3.6",
            "0G-Qwen-VL",
        ],
    },
    "Z.ai Coding": {
        "backend_type": "openai-compat",
        "base_url": "https://api.z.ai/api/coding/paas/v4",
        "models": [
            "glm-5.1", "glm-4.7", "GLM-4-Plus", "GLM-4-Long",
            "GLM-4-Flash", "GLM-4-FlashX", "GLM-Z1-Flash",
        ],
    },
    "Freebuff (Free DeepSeek/Kimi)": {
        "backend_type": "freebuff",
        "base_url": "https://freebuff.com",
        "models": [
            "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash",
            "moonshotai/kimi-k2.6", "minimax/minimax-m2.7",
        ],
    },
    "Ollama (local)": {
        "backend_type": "openai-compat",
        "base_url": "http://localhost:11434/v1",
        "models": [],
    },
}

# ═══════════════════════════════════════════════════════════════════════
# Cross-platform process management
# ═══════════════════════════════════════════════════════════════════════

def _subprocess_new_group_flag():
    if IS_WINDOWS:
        return subprocess.CREATE_NEW_PROCESS_GROUP
    return None


def _subprocess_preexec_fn():
    if IS_WINDOWS:
        return None
    return os.setsid


def _kill_process_group(pid):
    if IS_WINDOWS:
        try:
            subprocess.run(
                ["taskkill", "/F", "/T", "/PID", str(pid)],
                capture_output=True, timeout=10,
            )
        except Exception:
            pass
    else:
        try:
            pgid = os.getpgid(pid)
            os.killpg(pgid, signal.SIGTERM)
            time.sleep(0.5)
            try:
                os.killpg(pgid, signal.SIGKILL)
            except (ProcessLookupError, PermissionError):
                pass
        except (ProcessLookupError, PermissionError):
            pass


def _kill_process_group_soft(pid):
    if IS_WINDOWS:
        try:
            subprocess.run(
                ["taskkill", "/T", "/PID", str(pid)],
                capture_output=True, timeout=10,
            )
        except Exception:
            pass
    else:
        try:
            pgid = os.getpgid(pid)
            os.killpg(pgid, signal.SIGTERM)
        except (ProcessLookupError, PermissionError):
            pass


def _register_pgid_entry(kind, pid):
    data = _load_pid_registry()
    if IS_WINDOWS:
        data[kind] = {"pid": pid, "pgid": pid, "ts": time.time()}
    else:
        try:
            pgid = os.getpgid(pid)
        except ProcessLookupError:
            return
        data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()}
    _save_pid_registry(data)

# ═══════════════════════════════════════════════════════════════════════
# Cross-platform terminal detection
# ═══════════════════════════════════════════════════════════════════════

def detect_terminal():
    if IS_WINDOWS:
        for term in ["wt.exe", "cmd.exe", "powershell.exe"]:
            path = shutil.which(term)
            if path:
                return (term, [], path)
        return None
    terms = [
        ("x-terminal-emulator", ["-e"]),
        ("kgx", ["--"]),
        ("gnome-terminal", ["--"]),
        ("konsole", ["-e"]),
        ("xterm", ["-e"]),
    ]
    for t in terms:
        if shutil.which(t[0]):
            return (t[0], t[1], shutil.which(t[0]))
    return None

# ═══════════════════════════════════════════════════════════════════════
# Cross-platform URL/file opening
# ═══════════════════════════════════════════════════════════════════════

def open_url(url):
    if IS_WINDOWS:
        os.startfile(url)
    elif shutil.which("xdg-open"):
        subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    elif sys.platform == "darwin":
        subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def open_file(path):
    open_url(str(path))

# ═══════════════════════════════════════════════════════════════════════
# String / utility helpers
# ═══════════════════════════════════════════════════════════════════════

def safe_name(name):
    base = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name).strip("._-") or "endpoint"
    digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
    return f"{base}-{digest}"

def _profile_slug(name):
    return "".join(ch if ch.isalnum() else "-" for ch in name).strip("-") or "default"


def label_for_backend(backend_type):
    return {
        "openai-compat": "OpenAI-compatible",
        "anthropic": "Anthropic",
        "command-code": "Command Code",
        "freebuff": "Freebuff (Free AI)",
        "native": "Native",
    }.get(backend_type, backend_type)


def normalize_model_id(text):
    value = text.strip().lower()
    if not value:
        return ""
    value = value.replace("/", "-")
    value = value.replace("+", "plus")
    value = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in value)
    while "--" in value:
        value = value.replace("--", "-")
    return value.strip("-.")


def normalize_base_url(url):
    base = (url or "").strip().rstrip("/")
    for suffix in ("/chat/completions", "/responses", "/messages"):
        if base.endswith(suffix):
            base = base[: -len(suffix)]
            break
    return base.rstrip("/")


def parse_model_list(text):
    out = []
    seen = set()
    for raw in text.replace(",", "\n").splitlines():
        mid = normalize_model_id(raw)
        if mid and mid not in seen:
            seen.add(mid)
            out.append(mid)
    return out


def now_utc_iso():
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def _fmt_tok(n):
    if n >= 1_000_000:
        return f"{n/1_000_000:.1f}M"
    if n >= 1_000:
        return f"{n/1_000:.1f}K"
    return str(n)


def _fmt_dur(s):
    if s >= 3600:
        return f"{s/3600:.1f}h"
    if s >= 60:
        return f"{s/60:.1f}m"
    return f"{s:.1f}s"


def _status_pill(success_rate, fail_pct):
    _U = _usage_theme()
    if fail_pct > 0.15:
        return ("ERR", _U["red"])
    if fail_pct > 0.05:
        return ("WARN", _U["yellow"])
    return ("OK", _U["green"])


def _usage_theme():
    return {
        "base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235",
        "surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8",
        "dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8",
        "sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E",
        "red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1",
        "lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C",
        "flamingo": "#E878B0", "rosewater": "#F0D0C0",
        "model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0",
                          "#F0C75E", "#A899F0", "#70C8E8", "#E878B0",
                          "#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"],
    }

# ═══════════════════════════════════════════════════════════════════════
# Provider preset helpers
# ═══════════════════════════════════════════════════════════════════════

def apply_provider_preset(endpoint, preset_name):
    preset = PROVIDER_PRESETS.get(preset_name)
    if not preset:
        return endpoint
    updated = dict(endpoint)
    updated["provider_preset"] = preset_name
    updated["backend_type"] = preset["backend_type"]
    updated["base_url"] = normalize_base_url(preset["base_url"])
    if preset.get("cc_version") and not updated.get("cc_version"):
        updated["cc_version"] = preset["cc_version"]
    if not updated.get("models") or (preset.get("backend_type") or "").startswith("gemini-oauth"):
        updated["models"] = list(preset.get("models", []))
    if preset.get("oauth_provider"):
        updated["oauth_provider"] = preset["oauth_provider"]
    if not updated.get("default_model") and updated.get("models"):
        updated["default_model"] = updated["models"][0]
    return updated

# ═══════════════════════════════════════════════════════════════════════
# Endpoint CRUD
# ═══════════════════════════════════════════════════════════════════════

def load_endpoints():
    if ENDPOINTS_FILE.exists():
        try:
            return json.loads(ENDPOINTS_FILE.read_text())
        except Exception:
            pass
    return {"default": None, "endpoints": []}


def save_endpoints(data):
    ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
    ENDPOINTS_FILE.write_text(json.dumps(data, indent=2))


def load_bgp_pools():
    if BGP_POOLS_FILE.exists():
        try:
            return json.loads(BGP_POOLS_FILE.read_text())
        except Exception:
            pass
    return {"pools": []}


def save_bgp_pools(data):
    BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True)
    BGP_POOLS_FILE.write_text(json.dumps(data, indent=2))


def get_endpoint(name):
    for e in load_endpoints()["endpoints"]:
        if e["name"] == name:
            return e
    return None

# ═══════════════════════════════════════════════════════════════════════
# Profile bundle import/export
# ═══════════════════════════════════════════════════════════════════════

def build_profile_bundle():
    return {
        "version": 1,
        "exported_at": now_utc_iso(),
        "endpoints": load_endpoints(),
        "codex_config_toml": CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "",
    }


def save_profile_bundle(path):
    bundle = build_profile_bundle()
    Path(path).write_text(json.dumps(bundle, indent=2), encoding="utf-8")


def import_profile_bundle(path):
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("Invalid profile bundle")

    endpoints = data.get("endpoints")
    if not isinstance(endpoints, dict) or "endpoints" not in endpoints:
        raise ValueError("Profile bundle missing endpoints")

    if CONFIG.exists():
        shutil.copy2(str(CONFIG), str(CONFIG_BAK))
    if ENDPOINTS_FILE.exists():
        shutil.copy2(str(ENDPOINTS_FILE), str(ENDPOINTS_FILE.with_suffix(".json.import-bak")))

    save_endpoints(endpoints)

    cfg = data.get("codex_config_toml", "")
    if isinstance(cfg, str) and cfg.strip():
        CONFIG.parent.mkdir(parents=True, exist_ok=True)
        CONFIG.write_text(cfg, encoding="utf-8")
    return endpoints

# ═══════════════════════════════════════════════════════════════════════
# Secure file write
# ═══════════════════════════════════════════════════════════════════════

def write_secure_text(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(text, encoding="utf-8")
    if not IS_WINDOWS:
        os.chmod(str(tmp), 0o600)
    os.replace(str(tmp), str(path))

# ═══════════════════════════════════════════════════════════════════════
# Config management
# ═══════════════════════════════════════════════════════════════════════

def backup_config():
    if CONFIG.exists():
        tmp = CONFIG_BAK.with_suffix(".tmp")
        shutil.copy2(str(CONFIG), str(tmp))
        os.replace(str(tmp), str(CONFIG_BAK))


def restore_config():
    if CONFIG_BAK.exists():
        tmp = CONFIG.with_suffix(".tmp")
        shutil.copy2(str(CONFIG_BAK), str(tmp))
        os.replace(str(tmp), str(CONFIG))


def begin_config_transaction(reason):
    txn = {"started_at": time.time(), "reason": reason,
           "config_existed": CONFIG.exists(), "backup_path": str(CONFIG_BAK)}
    if CONFIG.exists():
        backup_config()
    CONFIG_TXN.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_TXN.write_text(json.dumps(txn, indent=2))


def end_config_transaction():
    CONFIG_TXN.unlink(missing_ok=True)


def recover_config_if_needed(logfn=None):
    if not CONFIG_TXN.exists():
        return
    try:
        txn = json.loads(CONFIG_TXN.read_text())
        if txn.get("config_existed") and CONFIG_BAK.exists():
            restore_config()
            if logfn:
                logfn("Recovered Codex config from interrupted session.")
        elif CONFIG.exists():
            CONFIG.unlink()
            if logfn:
                logfn("Removed generated config from interrupted session.")
    finally:
        CONFIG_TXN.unlink(missing_ok=True)


def _toml_safe(val):
    val = str(val).replace("\\", "/").replace('"', '\\"')
    return val.split('\n', 1)[0].strip()


def _resolve_secret(value):
    value = (value or "").strip()
    m = re.fullmatch(r"\$\{ENV:([A-Z0-9_]+)\}", value)
    if m:
        return os.environ.get(m.group(1), "")
    return value


def _merge_toml(existing_text, new_sections_text):
    """Merge launcher-generated TOML sections into an existing config.toml.

    Preserves all existing sections/keys that are not overwritten by the
    launcher.  This is a simple line-based merge — good enough for the flat
    TOML structure Codex uses.
    """
    if not existing_text:
        return new_sections_text

    new_lines = new_sections_text.rstrip().splitlines()

    root_keys = []
    new_section_blocks = {}
    current_section = None
    current_block_lines = []

    for line in new_lines:
        stripped = line.strip()
        if not stripped or stripped.startswith("#"):
            continue
        if stripped.startswith("[") and not stripped.startswith("[["):
            if current_section is not None:
                new_section_blocks[current_section] = current_block_lines
            current_section = stripped
            current_block_lines = []
        elif current_section is None:
            root_keys.append(line)
        else:
            current_block_lines.append(line)
    if current_section is not None:
        new_section_blocks[current_section] = current_block_lines

    existing_lines = existing_text.splitlines()
    existing_sections = {}
    existing_root_lines = []
    existing_section_order = []
    cur_sec = None

    for line in existing_lines:
        stripped = line.strip()
        if stripped.startswith("[") and not stripped.startswith("[["):
            if cur_sec is not None:
                pass
            cur_sec = stripped
            existing_section_order.append(cur_sec)
            existing_sections[cur_sec] = [line]
        elif cur_sec is not None:
            existing_sections[cur_sec].append(line)
        else:
            existing_root_lines.append(line)

    merged_root = []
    root_key_names = set()
    for rk in root_keys:
        key_name = rk.strip().split("=")[0].strip() if "=" in rk else ""
        if key_name:
            root_key_names.add(key_name)

    for line in existing_root_lines:
        stripped = line.strip()
        if stripped.startswith("#") or not stripped:
            merged_root.append(line)
            continue
        if "=" in stripped:
            key_name = stripped.split("=")[0].strip()
            if key_name in root_key_names:
                continue
        merged_root.append(line)

    merged_root.extend(root_keys)

    all_sections = list(existing_section_order)
    for sec in new_section_blocks:
        if sec not in all_sections:
            all_sections.append(sec)

    merged = list(merged_root)
    if merged and merged[-1] != "":
        merged.append("")
    for sec in all_sections:
        if sec in new_section_blocks:
            merged.append(sec)
            merged.extend(new_section_blocks[sec])
        else:
            merged.extend(existing_sections.get(sec, []))
        merged.append("")

    return "\n".join(merged).strip() + "\n"


def _gen_model_catalog(endpoint, selected_model=None):
    default_model = selected_model or endpoint.get("default_model")
    models = []
    for mid in endpoint.get("models", []):
        models.append({
            "slug": mid, "model": mid, "display_name": mid,
            "description": f"{endpoint['name']} {mid}",
            "hidden": False, "isDefault": mid == default_model,
            "shell_type": "shell_command", "visibility": "list",
            "default_reasoning_level": "medium",
            "supported_reasoning_levels": [
                {"effort": "low", "description": "Fast"},
                {"effort": "medium", "description": "Balanced"},
                {"effort": "high", "description": "Deep"},
                {"effort": "xhigh", "description": "Extra deep"},
            ],
            "supportedReasoningEfforts": [
                {"reasoningEffort": "low", "description": "Fast"},
                {"reasoningEffort": "medium", "description": "Balanced"},
                {"reasoningEffort": "high", "description": "Deep"},
                {"reasoningEffort": "xhigh", "description": "Extra deep"},
            ],
            "priority": 30, "context_size": 128000,
            "additional_speed_tiers": [], "service_tiers": [],
            "supports_reasoning_summaries": True, "support_verbosity": True,
            "reasoning": True, "tool_call": True,
            "supports_parallel_tool_calls": True,
            "experimental_supported_tools": [], "supported_in_api": True,
            "truncation_policy": {"mode": "tokens", "limit": 128000},
            "base_instructions": "You are Codex, a coding agent.",
        })
    return {"models": models}


def write_config_for_native(endpoint, selected_model):
    backup_config()
    model_catalog = _gen_model_catalog(endpoint, selected_model)
    mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
    mc_path.parent.mkdir(parents=True, exist_ok=True)
    mc_path.write_text(json.dumps(model_catalog, indent=2))
    mc_str = str(mc_path).replace("\\", "/")

    main_config = [
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model_catalog_json = "{mc_str}"\n',
        f'\n[model_providers."{endpoint["name"]}"]\n',
        f'name = "{_toml_safe(endpoint["name"])}"\n',
        f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
        f'experimental_bearer_token = "{_toml_safe(_resolve_secret(endpoint["api_key"]))}"\n',
    ]
    existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
    merged = _merge_toml(existing, "".join(main_config))
    write_secure_text(CONFIG, merged)

    profile_slug = _profile_slug(endpoint["name"])
    profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
    profile_lines = [
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model_catalog_json = "{mc_str}"\n',
        f'service_tier = "default"\n',
        f'approvals_reviewer = "user"\n',
    ]
    write_secure_text(profile_path, "".join(profile_lines))


def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
    backup_config()
    model_catalog = _gen_model_catalog(endpoint, selected_model)
    mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
    mc_path.parent.mkdir(parents=True, exist_ok=True)
    mc_path.write_text(json.dumps(model_catalog, indent=2))
    mc_str = str(mc_path).replace("\\", "/")

    main_config = [
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model_catalog_json = "{mc_str}"\n',
        f'\n[model_providers."{endpoint["name"]}"]\n',
        f'name = "{_toml_safe(endpoint["name"])}"\n',
        f'base_url = "http://127.0.0.1:{proxy_port}"\n',
        f'experimental_bearer_token = "codex-launcher-local"\n',
    ]
    existing = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
    merged = _merge_toml(existing, "".join(main_config))
    write_secure_text(CONFIG, merged)

    profile_slug = _profile_slug(endpoint["name"])
    profile_path = CONFIG.parent / f"{profile_slug}.config.toml"
    profile_lines = [
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model_catalog_json = "{mc_str}"\n',
        f'service_tier = "fast"\n',
        f'approvals_reviewer = "user"\n',
    ]
    write_secure_text(profile_path, "".join(profile_lines))

# ═══════════════════════════════════════════════════════════════════════
# Model fetching
# ═══════════════════════════════════════════════════════════════════════

def endpoint_models_url(endpoint):
    base = normalize_base_url(endpoint.get("base_url") or "")
    if not base:
        return ""
    return f"{base}/models"


def endpoint_model_headers(endpoint):
    key = (endpoint.get("api_key") or "").strip()
    backend = endpoint.get("backend_type", "openai-compat")
    headers = {"User-Agent": UA}
    if backend == "anthropic":
        if key:
            headers["x-api-key"] = key
        headers["anthropic-version"] = "2023-06-01"
    elif key:
        headers["Authorization"] = f"Bearer {key}"
    return headers


def fetch_models_for_endpoint(endpoint, timeout=10):
    bt = endpoint.get("backend_type", "")
    if bt == "gemini-oauth-antigravity":
        return list(ANTIGRAVITY_MODELS), None
    url = endpoint_models_url(endpoint)
    if not url:
        return None, "Base URL is empty"
    try:
        req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint))
        raw = urllib.request.urlopen(req, timeout=timeout).read()
        payload = json.loads(raw)
        items = payload.get("data") or payload.get("models") or []
        ids = []
        seen = set()
        for item in items:
            mid = item.get("id") if isinstance(item, dict) else None
            if mid and mid not in seen:
                seen.add(mid)
                ids.append(mid)
        if not ids:
            return None, "No models returned"
        return ids, None
    except Exception as e:
        return None, str(e)


def refresh_endpoint_models(endpoint):
    ids, err = fetch_models_for_endpoint(endpoint)
    if not ids:
        return None, err
    updated = dict(endpoint)
    updated["models"] = ids
    if updated.get("default_model") not in ids:
        updated["default_model"] = ids[0]
    return updated, None


# ═══════════════════════════════════════════════════════════════════════
# Antigravity model list (static — no /v1/models REST endpoint)
# ═══════════════════════════════════════════════════════════════════════

ANTIGRAVITY_MODELS = [
    "Gemini 3.5 Flash (High)", "Gemini 3.5 Flash (Medium)", "Gemini 3.5 Flash (Low)",
    "Gemini 3.1 Pro (High)", "Gemini 3.1 Pro (Low)",
    "Claude Sonnet 4.6 (Thinking)",
    "Claude Opus 4.6 (Thinking)",
    "GPT-OSS 120B (Medium)",
]


# ═══════════════════════════════════════════════════════════════════════
# OAuth secrets (local, never in repo)
# ═══════════════════════════════════════════════════════════════════════

def load_oauth_secrets():
    try:
        with open(OAUTH_SECRETS_PATH, encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}


def save_oauth_secrets(data):
    os.makedirs(os.path.dirname(OAUTH_SECRETS_PATH), exist_ok=True)
    tmp = str(OAUTH_SECRETS_PATH) + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
    os.replace(tmp, OAUTH_SECRETS_PATH)


# ═══════════════════════════════════════════════════════════════════════
# Doctor checks
# ═══════════════════════════════════════════════════════════════════════

def _doctor_check_streaming(base_url, key, bt, model, add):
    if bt == "anthropic":
        test_url = f"{base_url}/v1/messages"
        headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
        body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1, "stream": True,
                           "messages": [{"role": "user", "content": "hi"}]}).encode()
    else:
        test_url = f"{base_url}/chat/completions"
        headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
        body = json.dumps({"model": model, "max_tokens": 1, "stream": True,
                           "messages": [{"role": "user", "content": "hi"}]}).encode()
    try:
        req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
        t0 = time.time()
        resp = urllib.request.urlopen(req, timeout=20)
        content_type = resp.headers.get("content-type", "")
        first_chunk = resp.read(512)
        lat = (time.time() - t0) * 1000
        is_sse = "text/event-stream" in content_type or first_chunk.startswith(b"data:")
        if is_sse:
            add("Streaming support", True, f"SSE OK in {lat:.0f}ms")
        else:
            add("Streaming support", False, f"Expected SSE, got {content_type[:60]}")
    except urllib.error.HTTPError as e:
        body_text = ""
        try:
            body_text = e.read(200).decode(errors="replace")
        except Exception:
            pass
        if e.code == 429:
            add("Streaming support", None, "Rate limited (skipped)")
        elif e.code in (400, 404, 422):
            add("Streaming support", False, f"HTTP {e.code}: {body_text[:80]}")
        else:
            add("Streaming support", False, f"HTTP {e.code}")
    except Exception as e:
        add("Streaming support", False, str(e)[:100])


def _doctor_check_toolcall(base_url, key, bt, model, add):
    tool = {"type": "function", "function": {"name": "test_tool", "parameters": {"type": "object", "properties": {"x": {"type": "string"}}}}}
    if bt == "anthropic":
        test_url = f"{base_url}/v1/messages"
        headers = {"User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json"}
        body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 50, "stream": False,
                           "tools": [tool], "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode()
    else:
        test_url = f"{base_url}/chat/completions"
        headers = {"User-Agent": UA, "Authorization": f"Bearer {key}", "content-type": "application/json"}
        body = json.dumps({"model": model, "max_tokens": 50, "stream": False, "tools": [tool],
                           "messages": [{"role": "user", "content": "Use the test_tool with x=hello"}]}).encode()
    try:
        req = urllib.request.Request(test_url, data=body, headers=headers, method="POST")
        t0 = time.time()
        resp = urllib.request.urlopen(req, timeout=30)
        raw = resp.read()
        lat = (time.time() - t0) * 1000
        payload = json.loads(raw)
        has_tools = False
        if bt == "anthropic":
            for block in (payload.get("content") or []):
                if block.get("type") == "tool_use":
                    has_tools = True
                    break
        else:
            choices = payload.get("choices") or []
            for ch in choices:
                if (ch.get("message", {}).get("tool_calls")):
                    has_tools = True
                    break
        if has_tools:
            add("Tool-call support", True, f"Tool call received in {lat:.0f}ms")
        else:
            add("Tool-call support", None, f"Responded but no tool_call ({lat:.0f}ms)")
    except urllib.error.HTTPError as e:
        if e.code == 429:
            add("Tool-call support", None, "Rate limited (skipped)")
        elif e.code in (400, 404, 422):
            err_body = ""
            try:
                err_body = e.read(200).decode(errors="replace")
            except Exception:
                pass
            add("Tool-call support", False, f"HTTP {e.code}: {err_body[:80]}")
        else:
            add("Tool-call support", False, f"HTTP {e.code}")
    except Exception as e:
        add("Tool-call support", False, str(e)[:100])


def run_endpoint_doctor(endpoint):
    """Comprehensive health checks for an endpoint. Returns [(name, ok, detail), ...].
    ok: True=pass, False=fail, None=warn/skip."""
    checks = []
    def add(name, ok, detail=""):
        checks.append((name, ok, detail))

    url = normalize_base_url(endpoint.get("base_url") or "")
    key = (endpoint.get("api_key") or "").strip()
    bt = endpoint.get("backend_type", "openai-compat")
    model = endpoint.get("default_model") or (endpoint.get("models", [""])[0] if endpoint.get("models") else "")

    parsed = urllib.parse.urlparse(url)
    has_url = bool(parsed.scheme and parsed.netloc)
    add("URL format", has_url, url if has_url else "Missing scheme or host")
    if not has_url:
        return checks

    host = parsed.hostname
    port = parsed.port or (443 if parsed.scheme == "https" else 80)

    try:
        t0 = time.time()
        addrs = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
        dns_ms = (time.time() - t0) * 1000
        add("DNS resolution", True, f"{addrs[0][4][0]} ({dns_ms:.0f}ms)")
    except socket.gaierror as e:
        add("DNS resolution", False, str(e))
        return checks

    try:
        t0 = time.time()
        sock = socket.create_connection((host, port), timeout=10)
        tcp_ms = (time.time() - t0) * 1000
        if parsed.scheme == "https":
            ctx = ssl.create_default_context()
            try:
                ssock = ctx.wrap_socket(sock, server_hostname=host)
                tls_ms = (time.time() - t0) * 1000
                add("TLS connection", True, f"TCP {tcp_ms:.0f}ms + handshake {tls_ms:.0f}ms")
                ssock.close()
            except ssl.SSLError as e:
                add("TLS certificate", False, str(e)[:120])
                sock.close()
                return checks
        else:
            add("TCP connection", True, f"{tcp_ms:.0f}ms")
            sock.close()
    except (socket.timeout, ConnectionRefusedError, OSError) as e:
        add("TCP connection", False, str(e)[:100])
        return checks

    if bt == "anthropic":
        add("/models endpoint", None, "Anthropic has no /models endpoint — testing via /messages")
        try:
            t0 = time.time()
            msg_url = f"{url}/v1/messages"
            body = json.dumps({"model": model or "claude-3-5-haiku-20241022", "max_tokens": 1,
                               "messages": [{"role": "user", "content": "hi"}]}).encode()
            req = urllib.request.Request(msg_url, data=body, headers={
                "User-Agent": UA, "x-api-key": key, "anthropic-version": "2023-06-01", "content-type": "application/json",
            }, method="POST")
            urllib.request.urlopen(req, timeout=15)
            lat = (time.time() - t0) * 1000
            add("Auth valid", True, f"Responded in {lat:.0f}ms")
        except urllib.error.HTTPError as e:
            if e.code in (401, 403):
                add("Auth valid", False, f"HTTP {e.code} — check API key")
            elif e.code == 400:
                add("Auth valid", True, "Authenticated (model or param error)")
            else:
                add("Auth valid", False, f"HTTP {e.code}")
        except Exception as e:
            add("Auth valid", False, str(e)[:100])
    elif bt.startswith("gemini-oauth"):
        token_name = "google-antigravity-oauth-token.json" if "antigravity" in bt else "google-cli-oauth-token.json"
        token_path = PROXY_CONFIG_DIR / token_name
        if token_path.exists():
            try:
                td = json.loads(token_path.read_text())
                exp = td.get("expires_at", 0)
                if exp > time.time():
                    remaining = exp - time.time()
                    add("OAuth token", True, f"Valid ({remaining / 60:.0f} min remaining)")
                else:
                    add("OAuth token", False, "Token expired — re-login required")
            except Exception as e:
                add("OAuth token", False, str(e)[:80])
        else:
            add("OAuth token", False, f"No token file ({token_name})")
        try:
            t0 = time.time()
            ids, err = fetch_models_for_endpoint(endpoint)
            lat = (time.time() - t0) * 1000
            if ids:
                add("Network reachable", True, f"{lat:.0f}ms")
                add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)")
                if model:
                    add("Selected model exists", model in ids,
                        model if model in ids else f"'{model}' not in {ids[:5]}...")
            elif err and ("401" in str(err) or "403" in str(err)):
                add("Network reachable", True, f"{lat:.0f}ms")
                add("Auth valid", False, str(err)[:100])
            else:
                add("Network reachable", False, str(err or "no response")[:100])
        except Exception as e:
            add("Network", False, str(e)[:100])
    else:
        try:
            t0 = time.time()
            ids, err = fetch_models_for_endpoint(endpoint)
            lat = (time.time() - t0) * 1000
            if ids:
                add("Network reachable", True, f"{lat:.0f}ms")
                add("Auth valid", True)
                add("/models endpoint", True, f"{len(ids)} models ({lat:.0f}ms)")
                if model:
                    add("Selected model exists", model in ids,
                        model if model in ids else f"'{model}' not found in {len(ids)} models")
                else:
                    add("Selected model", False, "No model selected")
            elif err and ("401" in str(err) or "403" in str(err)):
                add("Network reachable", True, f"{lat:.0f}ms")
                add("Auth valid", False, "HTTP 401/403 — check API key")
            elif err and "429" in str(err):
                add("Network reachable", True, f"{lat:.0f}ms")
                add("Auth valid", True, "Authenticated but rate-limited")
                add("/models endpoint", None, "Rate limited — skipped")
            else:
                add("Network reachable", False, str(err or "no response")[:100])
        except Exception as e:
            add("Network", False, str(e)[:100])

    if bt not in ("native", "command-code"):
        _doctor_check_streaming(url, key, bt, model, add)

    if bt not in ("native", "command-code"):
        _doctor_check_toolcall(url, key, bt, model, add)

    return checks

# ═══════════════════════════════════════════════════════════════════════
# PID registry
# ═══════════════════════════════════════════════════════════════════════

def _load_pid_registry():
    if PID_REGISTRY.exists():
        try:
            return json.loads(PID_REGISTRY.read_text())
        except Exception:
            pass
    return {}


def _save_pid_registry(data):
    PID_REGISTRY.parent.mkdir(parents=True, exist_ok=True)
    tmp = PID_REGISTRY.with_suffix(".tmp")
    tmp.write_text(json.dumps(data, indent=2))
    os.replace(str(tmp), str(PID_REGISTRY))


def safe_cleanup_owned(logfn=None):
    data = _load_pid_registry()
    changed = False
    for kind, meta in list(data.items()):
        pid = meta.get("pid") or meta.get("pgid")
        if not pid:
            continue
        try:
            _kill_process_group(pid)
            if logfn:
                logfn(f"Stopped {kind} (pid {pid})")
            changed = True
        except ProcessLookupError:
            changed = True
        except Exception as e:
            if logfn:
                logfn(f"Could not stop {kind}: {e}")
    if changed:
        _save_pid_registry({})

# ═══════════════════════════════════════════════════════════════════════
# Proxy lifecycle
# ═══════════════════════════════════════════════════════════════════════

_proxy_proc = None
_proxy_port = None
_PROXY_PORT_FILE = PROXY_CONFIG_DIR / ".last-proxy-port"


def _pick_free_port():
    saved = None
    try:
        saved = int(_PROXY_PORT_FILE.read_text().strip())
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.bind(("127.0.0.1", saved))
            return saved
    except (ValueError, OSError, FileNotFoundError):
        pass
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def get_proxy_state():
    return _proxy_proc, _proxy_port


def set_proxy_state(proc, port):
    global _proxy_proc, _proxy_port
    _proxy_proc = proc
    _proxy_port = port


def stop_proxy():
    global _proxy_proc
    if _proxy_proc and _proxy_proc.poll() is None:
        _kill_process_group(_proxy_proc.pid)
        _proxy_proc = None


def start_proxy_for(endpoint, logfn):
    """Start the translation proxy for an endpoint. Returns the port.
    logfn(msg) is used for status messages (may be called from any thread).
    """
    global _proxy_proc, _proxy_port
    stop_proxy()
    port = _pick_free_port()
    _proxy_port = port
    _PROXY_PORT_FILE.parent.mkdir(parents=True, exist_ok=True)
    _PROXY_PORT_FILE.write_text(str(port))

    model_list = endpoint.get("models", [])
    if (endpoint.get("backend_type") or "").startswith("gemini-oauth") and (endpoint.get("oauth_provider") or "").startswith("google"):
        token_name = "google-antigravity-oauth-token.json" if endpoint.get("oauth_provider") == "google-antigravity" else "google-cli-oauth-token.json"
        token_path = PROXY_CONFIG_DIR / token_name
        try:
            with open(token_path) as tf:
                td = json.load(tf)
            discovered = [] if endpoint.get("oauth_provider") == "google-antigravity" else td.get("available_models", [])
            if discovered:
                model_list = discovered
        except Exception:
            pass

    pcfg = {
        "port": port,
        "backend_type": endpoint["backend_type"],
        "target_url": normalize_base_url(endpoint["base_url"]),
        "api_key": endpoint["api_key"],
        "cc_version": endpoint.get("cc_version", ""),
        "oauth_provider": endpoint.get("oauth_provider", ""),
        "reasoning_enabled": endpoint.get("reasoning_enabled", True),
        "reasoning_effort": endpoint.get("reasoning_effort", "medium"),
        "force_model": endpoint.get("default_model") or "",
        "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
                   for m in model_list],
    }
    pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}-{port}.json"
    pcfg_path.parent.mkdir(parents=True, exist_ok=True)
    pcfg_path.write_text(json.dumps(pcfg, indent=2))
    _start_proxy_with_config(pcfg_path, port, logfn)
    return port


def _start_proxy_with_config(pcfg_path, port, logfn):
    global _proxy_proc
    python_bin = sys.executable
    proxy_script = str(PROXY)

    popen_kwargs = {
        "stdout": subprocess.DEVNULL,
        "stderr": subprocess.PIPE,
        "text": True,
    }
    if IS_WINDOWS:
        popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
    else:
        popen_kwargs["preexec_fn"] = os.setsid

    _proxy_proc = subprocess.Popen(
        [python_bin, proxy_script, "--config", str(pcfg_path)],
        **popen_kwargs,
    )
    _register_pgid_entry("proxy", _proxy_proc.pid)

    _proxy_log_path = PROXY_CONFIG_DIR / "proxy-stderr.log"
    _proxy_log_file = open(_proxy_log_path, "a", encoding="utf-8")

    def _pipe_stderr():
        if not _proxy_proc.stderr:
            return
        for line in _proxy_proc.stderr:
            logfn(f"[proxy] {line.rstrip()}")
            try:
                _proxy_log_file.write(line)
                _proxy_log_file.flush()
            except Exception:
                pass

    threading.Thread(target=_pipe_stderr, daemon=True).start()

    deadline = time.time() + 15
    last_err = None
    while time.time() < deadline:
        if _proxy_proc.poll() is not None:
            raise RuntimeError(f"Proxy exited early with code {_proxy_proc.returncode}")
        try:
            urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=2)
            logfn(f"Proxy ready on port {port}")
            return
        except Exception as e:
            last_err = e
            time.sleep(0.3)

    _kill_process_group(_proxy_proc.pid)
    raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}")


def start_bgp_proxy(pool, model, logfn):
    """Start a BGP proxy for a pool. Returns (port, bgp_endpoint, pcfg_path)."""
    global _proxy_proc, _proxy_port
    stop_proxy()
    port = _pick_free_port()
    _proxy_port = port
    _PROXY_PORT_FILE.parent.mkdir(parents=True, exist_ok=True)
    _PROXY_PORT_FILE.write_text(str(port))

    bgp_ep = {
        "name": pool["name"],
        "backend_type": "openai-compat",
        "base_url": "http://bgp.placeholder",
        "api_key": "",
        "default_model": model,
        "models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))),
    }
    pcfg = {
        "port": port,
        "backend_type": "openai-compat",
        "target_url": "http://bgp.placeholder",
        "api_key": "",
        "bgp_routes": pool.get("routes", []),
        "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]],
    }
    pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json"
    pcfg_path.parent.mkdir(parents=True, exist_ok=True)
    pcfg_path.write_text(json.dumps(pcfg, indent=2))
    _start_proxy_with_config(pcfg_path, port, logfn)
    return port, bgp_ep

# ═══════════════════════════════════════════════════════════════════════
# Codex detection
# ═══════════════════════════════════════════════════════════════════════

def detect_codex_cli():
    try:
        path = shutil.which("codex")
        if not path:
            return None
        out = subprocess.run(["codex", "--version"], capture_output=True, text=True, timeout=5)
        ver = (out.stdout or "").strip() or (out.stderr or "").strip() or "unknown"
        return (path, ver)
    except Exception:
        return None


def detect_codex_desktop():
    """Detect Codex Desktop installation.

    Returns (path_or_aumid, is_msix) tuple on Windows, path string on Linux.
    For MSIX installs, returns the AppUserModelId since the exe cannot be
    launched directly via subprocess from WindowsApps.
    """
    if IS_WINDOWS:
        la = os.environ.get("LOCALAPPDATA", "")
        pf = os.environ.get("PROGRAMFILES", "")
        pf86 = os.environ.get("PROGRAMFILES(X86)", "")
        desktop_paths = [
            Path(la) / "Programs" / "Codex Desktop" / "Codex Desktop.exe",
            Path(pf) / "Codex Desktop" / "Codex Desktop.exe",
            Path(pf86) / "Codex Desktop" / "Codex Desktop.exe",
            Path(la) / "OpenAI" / "Codex Desktop" / "Codex Desktop.exe",
        ]
        for p in desktop_paths:
            if p.exists():
                return str(p), False
        # MSIX / Microsoft Store install
        try:
            r = subprocess.run(
                ["powershell", "-NoProfile", "-Command",
                 "(Get-AppxPackage *OpenAI.Codex*).InstallLocation"],
                capture_output=True, text=True, timeout=10,
            )
            loc = r.stdout.strip() if r.returncode == 0 else ""
            if loc:
                msix_exe = Path(loc) / "app" / "Codex.exe"
                if msix_exe.exists():
                    r2 = subprocess.run(
                        ["powershell", "-NoProfile", "-Command",
                         "(Get-AppxPackage *OpenAI.Codex*).PackageFamilyName"],
                        capture_output=True, text=True, timeout=10,
                    )
                    family = r2.stdout.strip() if r2.returncode == 0 else ""
                    if family:
                        return f"{family}!App", True
        except Exception:
            pass
        return None, False
    if START_SH and START_SH.exists():
        return str(START_SH), False
    return None, False


def launch_codex_desktop(desktop_info):
    """Launch Codex Desktop process.

    Args:
        desktop_info: (path_or_aumid, is_msix) tuple from detect_codex_desktop()

    Returns:
        subprocess.Popen object or None
    """
    path, is_msix = desktop_info
    if IS_WINDOWS:
        if is_msix:
            return subprocess.Popen(
                ["cmd", "/c", "start", "", f"shell:AppsFolder\\{path}"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
        return subprocess.Popen(
            [path],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)
    else:
        return subprocess.Popen(
            [path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            preexec_fn=os.setsid)


def is_codex_desktop_running():
    """Check if Codex Desktop (or MSIX Codex) is currently running."""
    if IS_WINDOWS:
        try:
            for name in ("Codex Desktop.exe", "Codex.exe"):
                out = subprocess.run(
                    ["tasklist", "/FI", f"IMAGENAME eq {name}", "/FO", "CSV", "/NH"],
                    capture_output=True, text=True, timeout=5,
                )
                for line in out.stdout.strip().splitlines():
                    parts = line.split(",")
                    if len(parts) >= 2 and parts[1].strip('"').isdigit():
                        return True
        except Exception:
            pass
        return False
    else:
        try:
            out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
            return bool(out.stdout.strip())
        except Exception:
            return False


def check_codex_auth():
    try:
        out = subprocess.run(
            ["codex", "login", "status"],
            capture_output=True, text=True, timeout=10,
        )
        text = (out.stdout or "").strip()
        if not text:
            text = (out.stderr or "").strip()
        if out.returncode == 0 and text:
            return ("logged_in", text)
        if text:
            return ("error", text)
        return ("unknown", "No output from codex login status")
    except FileNotFoundError:
        return ("not_installed", "codex not found")
    except OSError as e:
        if e.errno == 2:
            return ("not_configured", "Config not found — launch Codex once to create it")
        return ("error", str(e))
    except Exception as e:
        return ("error", str(e))

# ═══════════════════════════════════════════════════════════════════════
# Log helpers
# ═══════════════════════════════════════════════════════════════════════

def last_log_lines(n=15):
    try:
        t = LAUNCH_LOG.read_text()
        return "\n".join(t.splitlines()[-n:])
    except Exception:
        return "(no log file)"

# ═══════════════════════════════════════════════════════════════════════
# Process helpers (desktop kill etc.)
# ═══════════════════════════════════════════════════════════════════════

def kill_existing_desktop(logfn=None):
    if IS_WINDOWS:
        for img in ("Codex Desktop.exe", "Codex.exe"):
            try:
                out = subprocess.run(
                    ["tasklist", "/FI", f"IMAGENAME eq {img}", "/FO", "CSV", "/NH"],
                    capture_output=True, text=True, timeout=5,
                )
                for line in out.stdout.strip().splitlines():
                    parts = line.split(",")
                    if len(parts) >= 2:
                        pid_str = parts[1].strip('"')
                        if pid_str.isdigit():
                            pid = int(pid_str)
                            _kill_process_group(pid)
                            if logfn:
                                logfn(f"Killed existing Codex Desktop (pid {pid})")
                            time.sleep(2)
            except Exception as e:
                if logfn:
                    logfn(f"Note: could not kill existing Desktop: {e}")
    else:
        try:
            out = subprocess.run(["pgrep", "-f", "/opt/codex-desktop/electron"], capture_output=True, text=True, timeout=5)
            pids = [p for p in out.stdout.strip().splitlines() if p.strip().isdigit()]
            if not pids:
                return
            main_pid = int(pids[0])
            pgid = os.getpgid(main_pid)
            if pgid > 0:
                os.killpg(pgid, signal.SIGTERM)
                if logfn:
                    logfn(f"Killed existing Codex Desktop (pid {main_pid}, pgid {pgid})")
                time.sleep(2)
                try:
                    os.killpg(pgid, signal.SIGKILL)
                except (ProcessLookupError, PermissionError):
                    pass
        except Exception as e:
            if logfn:
                logfn(f"Note: could not kill existing Desktop: {e}")

# ═══════════════════════════════════════════════════════════════════════
# AI Monitoring — Self-Healing Watchdog
# ═══════════════════════════════════════════════════════════════════════

_TIER1_RULES = [
    ("proxy_health_fail",      "restart_proxy",         30),
    ("proxy_port_conflict",    "kill_stale_restart",    60),
    ("upstream_429",           "wait_retry",             0),
    ("upstream_502_503",       "retry_backoff",         30),
    ("upstream_500_repeat",    "switch_provider",       60),
    ("upstream_timeout",       "retry_increase_timeout",30),
    ("upstream_401_403",       "alert_bad_key",          0),
    ("stream_broken_pipe",     "restart_proxy",         30),
    ("stream_reset",           "restart_proxy",         30),
    ("parsed_tool_calls_0_x3", "clear_schema_cache",   300),
    ("sanitizer_suspicious_5x","alert_model_issue",      0),
    ("stuck_recovery_x5",      "suggest_switch_model",   0),
    ("codex_process_dead",     "alert_restart",           0),
    ("schema_corrupt",         "delete_provider_caps",    0),
]

_FAILURE_SIGNALS = {
    "parsed_tool_calls=0":      ("C1", "parser_empty"),
    "[STUCK-RECOVERY]":         ("C3", "stuck_recovery"),
    "suspicious cmd":           ("C4", "sanitizer_flag"),
    "empty cmd recovered":      ("C6", "empty_cmd"),
    "HTTP 429":                 ("B1", "rate_limited"),
    "HTTP 500":                 ("B2", "server_error"),
    "HTTP 502":                 ("B2", "server_error"),
    "HTTP 503":                 ("B2", "server_error"),
    "HTTP 401":                 ("B3", "auth_failure"),
    "HTTP 403":                 ("B4", "forbidden"),
    "Connection refused":       ("A1", "proxy_dead"),
    "Address already in use":   ("A2", "port_conflict"),
    "Broken pipe":              ("B7", "broken_pipe"),
    "Connection reset":         ("B6", "connection_reset"),
    "timed out":                ("B5", "timeout"),
    "SELF-REVIVE CRASH":        ("A5", "proxy_crash"),
    "stream error":             ("B6", "stream_error"),
    "content_type.*array":      ("E1", "schema_corrupt"),
}

_DIAGNOSTIC_SYSTEM_PROMPT = (
    'You are a diagnostic agent for "Codex Launcher" — a desktop app that runs a local '
    'translation proxy between OpenAI Codex CLI/Desktop and AI providers.\n\n'
    'Analyze the incident and respond with ONLY a JSON object:\n'
    '{"action": "...", "reason": "...", "confidence": 0.0-1.0}\n\n'
    'Available actions: restart_proxy, kill_stale_processes, clear_schema_cache, '
    'switch_provider, increase_timeout, regenerate_config, cleanup_stale, '
    'alert_user, ignore, retry_now\n\n'
    'Rules:\n'
    '- upstream 401/403 with auth error -> alert_user\n'
    '- proxy dead -> restart_proxy\n'
    '- same error 5+ times -> switch_provider or alert_user\n'
    '- schema/content_type error -> clear_schema_cache\n'
    '- "Address already in use" -> kill_stale_processes then restart_proxy\n'
    '- timeout on slow upstream -> increase_timeout\n'
    '- single transient 429/502/503 -> ignore\n'
    '- "stream disconnected" + proxy healthy -> ignore\n'
    '- no extra text, no markdown, just the JSON object'
)


def load_monitoring_config():
    if MONITORING_FILE.exists():
        try:
            return json.loads(MONITORING_FILE.read_text())
        except Exception:
            pass
    return {
        "enabled": False,
        "provider_url": "",
        "model": "",
        "api_key": "",
        "health_check_interval_s": 5,
        "auto_restart_proxy": True,
        "auto_switch_provider": False,
    }


def save_monitoring_config(cfg):
    MONITORING_FILE.parent.mkdir(parents=True, exist_ok=True)
    MONITORING_FILE.write_text(json.dumps(cfg, indent=2))


def load_incident_store():
    if INCIDENT_STORE_FILE.exists():
        try:
            return json.loads(INCIDENT_STORE_FILE.read_text())
        except Exception:
            pass
    return {"version": 1, "incidents": {}, "stats": {"ai_calls": 0, "tokens_used": 0}}


def save_incident_store(store):
    INCIDENT_STORE_FILE.parent.mkdir(parents=True, exist_ok=True)
    INCIDENT_STORE_FILE.write_text(json.dumps(store, indent=2))


def monitoring_log(msg):
    try:
        with open(str(MONITORING_LOG), "a") as f:
            f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
    except Exception:
        pass


class IncidentStore:
    def __init__(self):
        self._store = load_incident_store()
        self._dirty = False

    def lookup(self, pattern):
        inc = self._store.get("incidents", {}).get(pattern)
        if inc and inc.get("success_count", 0) > 0:
            rate = inc["success_count"] / max(inc["success_count"] + inc.get("fail_count", 0), 1)
            if rate > 0.5:
                return inc
        return None

    def record(self, pattern, fix, success=True):
        incs = self._store.setdefault("incidents", {})
        inc = incs.setdefault(pattern, {
            "fix": fix, "success_count": 0, "fail_count": 0,
            "last_seen": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "occurrences": 0,
        })
        inc["last_seen"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        inc["occurrences"] = inc.get("occurrences", 0) + 1
        if success:
            inc["success_count"] = inc.get("success_count", 0) + 1
        else:
            inc["fail_count"] = inc.get("fail_count", 0) + 1
        self._dirty = True

    def record_ai_call(self, tokens=0):
        stats = self._store.setdefault("stats", {"ai_calls": 0, "tokens_used": 0})
        stats["ai_calls"] = stats.get("ai_calls", 0) + 1
        stats["tokens_used"] = stats.get("tokens_used", 0) + tokens
        self._dirty = True

    def flush(self):
        if self._dirty:
            save_incident_store(self._store)
            self._dirty = False

    @property
    def stats(self):
        return self._store.get("stats", {"ai_calls": 0, "tokens_used": 0})


class AIDiagnosticAgent:
    def __init__(self, provider_url, model, api_key):
        self.provider_url = provider_url
        self.model = model
        self.api_key = api_key
        self.incident_store = IncidentStore()

    def diagnose(self, context):
        pattern = self._extract_pattern(context)
        known = self.incident_store.lookup(pattern)
        if known:
            monitoring_log(f"Tier 2 HIT: pattern={pattern} fix={known['fix']}")
            return {"action": known["fix"], "reason": "known_pattern", "confidence": 0.9, "tier": 2}
        action = self._call_model(context)
        if action:
            self.incident_store.record(pattern, action.get("action", "unknown"))
            self.incident_store.flush()
        return action

    def _extract_pattern(self, context):
        parts = []
        for k in sorted(context.get("signals", [])):
            parts.append(k)
        if context.get("http_code"):
            parts.append(f"http_{context['http_code']}")
        return "+".join(parts[:3]) or "unknown"

    def _call_model(self, context):
        prompt = (
            f"INCIDENT REPORT:\n"
            f"Time: {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n"
            f"Proxy health: {context.get('proxy_alive', 'unknown')}\n"
            f"Upstream: {context.get('upstream_url', 'unknown')}\n"
            f"Model: {context.get('model', 'unknown')}\n"
            f"Last HTTP code: {context.get('http_code', 'n/a')}\n"
            f"Recent signals: {context.get('signals', [])}\n"
            f"Recent log tail:\n{context.get('log_tail', '')[:1500]}\n"
        )
        body = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": _DIAGNOSTIC_SYSTEM_PROMPT},
                {"role": "user", "content": prompt},
            ],
            "max_tokens": 200,
            "temperature": 0.1,
        }
        try:
            req = urllib.request.Request(
                self.provider_url,
                data=json.dumps(body).encode(),
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {self.api_key}",
                },
            )
            resp = urllib.request.urlopen(req, timeout=15)
            result = json.loads(resp.read())
            text = result["choices"][0]["message"]["content"].strip()
            self.incident_store.record_ai_call(tokens=800)
            action = json.loads(text)
            action["tier"] = 3
            monitoring_log(f"Tier 3 AI: action={action.get('action')} reason={action.get('reason')}")
            return action
        except Exception as e:
            monitoring_log(f"Tier 3 AI FAILED: {e}")
            return {"action": "alert_user", "reason": f"ai_diag_failed: {e}", "confidence": 0.0, "tier": 3}


class HealthWatcher(threading.Thread):
    def __init__(self, on_failure, on_recovery, on_signal, on_action):
        super().__init__(daemon=True)
        self.cfg = load_monitoring_config()
        self.on_failure = on_failure
        self.on_recovery = on_recovery
        self.on_signal = on_signal
        self.on_action = on_action
        self.failures = 0
        self.running = False
        self._signal_counts = collections.defaultdict(int)
        self._last_actions = {}
        self._restart_count = 0
        self._last_restart_time = 0

    def run(self):
        self.running = True
        self.incident_store = IncidentStore()
        self._log_analyzer = _LogAnalyzerThread(self._on_log_signal)
        self._log_analyzer.start()
        while self.running:
            self.cfg = load_monitoring_config()
            if not self.cfg.get("enabled"):
                time.sleep(5)
                continue
            port = self._get_proxy_port()
            if port:
                healthy = self._check_health(port)
                if healthy:
                    if self.failures > 0:
                        self.failures = 0
                        self.on_recovery()
                else:
                    self.failures += 1
                    if self.failures >= 3:
                        self._handle_failure("proxy_health_fail")
            self.incident_store.flush()
            interval = self.cfg.get("health_check_interval_s", 5)
            time.sleep(interval)

    def stop(self):
        self.running = False
        if hasattr(self, '_log_analyzer'):
            self._log_analyzer.running = False

    def _get_proxy_port(self):
        try:
            cfg_path = PROXY_CONFIG_DIR / "proxy-config.json"
            if cfg_path.exists():
                d = json.loads(cfg_path.read_text())
                return d.get("port")
        except Exception:
            pass
        return None

    def _check_health(self, port):
        try:
            req = urllib.request.Request(f"http://localhost:{port}/health")
            resp = urllib.request.urlopen(req, timeout=5)
            return resp.status == 200
        except Exception:
            return False

    def _on_log_signal(self, fault_id, category, line):
        self._signal_counts[category] += 1
        self.on_signal(fault_id, category, line[:200])
        count = self._signal_counts[category]
        if category in ("proxy_dead", "port_conflict") and count >= 2:
            self._handle_failure(category)
        elif category in ("server_error", "timeout") and count >= 3:
            self._handle_failure(category + "_repeat")
        elif category in ("sanitizer_flag",) and count >= 5:
            self._handle_failure("sanitizer_suspicious_5x")
        elif category in ("stuck_recovery",) and count >= 5:
            self._handle_failure("stuck_recovery_x5")
        elif category in ("parser_empty",) and count >= 3:
            self._handle_failure("parsed_tool_calls_0_x3")
        elif category in ("schema_corrupt",):
            self._handle_failure("schema_corrupt")

    def _handle_failure(self, trigger):
        now = time.time()
        for rule_trigger, action, cooldown in _TIER1_RULES:
            if rule_trigger == trigger:
                last_t = self._last_actions.get(action, 0)
                if now - last_t < cooldown:
                    return
                self._last_actions[action] = now
                monitoring_log(f"Tier 1: trigger={trigger} action={action}")
                self.on_action(action, trigger)
                self.incident_store.record(trigger, action, success=True)
                return
        self._try_tier2_3(trigger)

    def _try_tier2_3(self, trigger):
        cfg = self.cfg
        if not cfg.get("provider_url") or not cfg.get("model") or not cfg.get("api_key"):
            monitoring_log(f"No AI configured for Tier 2/3 — alerting user for trigger={trigger}")
            self.on_action("alert_user", trigger)
            return
        agent = AIDiagnosticAgent(cfg["provider_url"], cfg["model"], cfg["api_key"])
        context = {
            "signals": [trigger],
            "proxy_alive": self.failures == 0,
            "log_tail": self._get_recent_log(),
        }
        result = agent.diagnose(context)
        if result:
            action = result.get("action", "alert_user")
            monitoring_log(f"Tier {result.get('tier', '?')}: action={action}")
            self.on_action(action, trigger)

    def _get_recent_log(self):
        lines = []
        for log_name in ["cc-debug.log", "proxy.log"]:
            log_path = PROXY_CONFIG_DIR / log_name
            try:
                text = log_path.read_text()
                lines.extend(text.splitlines()[-20:])
            except Exception:
                pass
        return "\n".join(lines[-30:])


class _LogAnalyzerThread(threading.Thread):
    def __init__(self, on_signal):
        super().__init__(daemon=True)
        self.on_signal = on_signal
        self.running = False

    def run(self):
        self.running = True
        log_paths = [
            str(PROXY_CONFIG_DIR / "cc-debug.log"),
            str(PROXY_CONFIG_DIR / "proxy.log"),
        ]
        fhs = {}
        for p in log_paths:
            try:
                f = open(p, "r")
                f.seek(0, 2)
                fhs[p] = f
            except Exception:
                pass
        while self.running:
            activity = False
            for p, fh in list(fhs.items()):
                try:
                    line = fh.readline()
                    if line:
                        activity = True
                        for pattern, (fault_id, category) in _FAILURE_SIGNALS.items():
                            if re.search(pattern, line):
                                self.on_signal(fault_id, category, line.strip())
                                break
                except Exception:
                    pass
            if not activity:
                time.sleep(0.5)

# ═══════════════════════════════════════════════════════════════════════
# Usage stats
# ═══════════════════════════════════════════════════════════════════════

def load_usage_stats():
    try:
        if _USAGE_STATS_FILE.exists():
            return json.loads(_USAGE_STATS_FILE.read_text())
    except Exception:
        pass
    return {"providers": {}, "updated": None}

# ═══════════════════════════════════════════════════════════════════════
# Default endpoints creation
# ═══════════════════════════════════════════════════════════════════════

def create_default_endpoints():
    if not ENDPOINTS_FILE.exists():
        save_endpoints({
            "default": "OpenAI",
            "endpoints": [
                {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1",
                 "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"],
                 "provider_preset": "OpenAI"},
                {"name": "Z.AI", "backend_type": "openai-compat",
                 "base_url": "https://api.z.ai/api/coding/paas/v4",
                 "api_key": "", "default_model": "glm-5.1",
                 "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"],
                 "provider_preset": "Custom"},
            ],
        })


def ensure_dirs():
    for d in [LOG_DIR, PROXY_CONFIG_DIR]:
        d.mkdir(parents=True, exist_ok=True)
