"""Windows 兼容的 Cursor SDK bridge 管理。""" from __future__ import annotations import codecs import json import logging import os import subprocess import threading import time from pathlib import Path from typing import Any, Mapping from daily.config import ROOT, env logger = logging.getLogger(__name__) READY_LINE_PREFIX = "cursor-sdk-bridge ready " _bridge_lock = threading.Lock() _bridge_process: subprocess.Popen[bytes] | None = None def _cursor_cwd() -> str: return env("DAILY_CURSOR_CWD") or env("CURSOR_CWD") or str(ROOT) def _parse_discovery_line(line: str) -> Mapping[str, Any] | None: if not line.startswith(READY_LINE_PREFIX): return None payload = line[len(READY_LINE_PREFIX) :].strip() loaded = json.loads(payload) if not isinstance(loaded, dict): raise RuntimeError("Bridge discovery payload must be an object") return loaded def _read_discovery_polling(process: subprocess.Popen[bytes], timeout: float = 60) -> Mapping[str, Any]: """不用 selectors,避免 Windows 上 WinError 10038。""" if process.stderr is None: raise RuntimeError("Bridge stderr unavailable") fd = process.stderr.fileno() was_blocking = os.get_blocking(fd) os.set_blocking(fd, False) decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") pending = "" stderr_lines: list[str] = [] deadline = time.monotonic() + timeout try: while time.monotonic() < deadline: try: chunk = os.read(fd, 8192) except BlockingIOError: chunk = b"" if chunk: pending += decoder.decode(chunk) while "\n" in pending: line, pending = pending.split("\n", 1) stderr_lines.append(line) discovery = _parse_discovery_line(line) if discovery is not None: return discovery else: code = process.poll() if code is not None: pending += decoder.decode(b"", final=True) if pending.strip(): stderr_lines.append(pending.strip()) joined = "\n".join(stderr_lines)[-2000:] raise RuntimeError( f"Bridge 启动失败 exit={code}: {joined or '无 stderr 输出'}" ) time.sleep(0.05) finally: os.set_blocking(fd, was_blocking) raise RuntimeError("等待 Cursor bridge 就绪超时") def _auth_token_from_discovery(discovery: Mapping[str, Any]) -> str: token = str(discovery.get("authToken") or "").strip() if token: return token token_file = discovery.get("authTokenFile") if token_file: return Path(str(token_file)).read_text(encoding="utf-8").strip() raise RuntimeError("Bridge discovery 缺少 auth token") def warm_cursor_bridge(force: bool = False) -> None: """启动 cursor-sdk-bridge 并写入 CURSOR_SDK_BRIDGE_* 环境变量。""" global _bridge_process with _bridge_lock: if ( not force and _bridge_process is not None and _bridge_process.poll() is None and os.environ.get("CURSOR_SDK_BRIDGE_URL") and os.environ.get("CURSOR_SDK_BRIDGE_TOKEN") ): return if _bridge_process is not None and _bridge_process.poll() is None: _bridge_process.terminate() try: _bridge_process.wait(timeout=5) except subprocess.TimeoutExpired: _bridge_process.kill() from cursor_sdk._vendor import resolve_bridge_path cwd = _cursor_cwd() argv = [resolve_bridge_path(), "--workspace", cwd] logger.info("启动 Cursor bridge workspace=%s", cwd) process = subprocess.Popen( argv, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) try: discovery = _read_discovery_polling(process) except (RuntimeError, OSError, ValueError) as exc: logger.warning("Cursor bridge discovery 失败,终止子进程:%s", exc) process.kill() process.wait(timeout=5) raise url = str(discovery.get("url") or "").strip() if not url: host = str(discovery.get("host") or "127.0.0.1") port = discovery.get("port") url = f"http://{host}:{port}" token = _auth_token_from_discovery(discovery) os.environ["CURSOR_SDK_BRIDGE_URL"] = url os.environ["CURSOR_SDK_BRIDGE_TOKEN"] = token _bridge_process = process logger.info("Cursor bridge 就绪: %s", url) def shutdown_cursor_bridge() -> None: global _bridge_process with _bridge_lock: if _bridge_process is None: return if _bridge_process.poll() is None: _bridge_process.terminate() try: _bridge_process.wait(timeout=5) except subprocess.TimeoutExpired: _bridge_process.kill() _bridge_process = None