UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

1,256 lines • 55.1 kB
// Codex Desktop foregrounding driver. Ported faithfully from
// granular/tools/relay-companion/src/codex-desktop.js. Finds the running Codex
// main process, opens (or SIGUSR1-starts) its Node inspector, and runs a
// Runtime.evaluate -> webContents.executeJavaScript -> electronBridge expression
// in the selected primary BrowserWindow to refresh the recents rail and
// navigate-to-route the freshly materialized Relay thread.
//
// Only adaptation: the original `import WsWebSocket from "ws"` is made optional.
// Node 22+ exposes a global WebSocket (which the original already preferred), so
// we use that; the `ws` package is loaded lazily only as a fallback and never at
// module-load time, so this module imports cleanly without the dependency.
//
// On top of the ported refresh/open path, this module carries the
// "Open in current chat" live tier (submitTurnToCodexDesktopThread /
// relaySubmitCodexRenderer): resume the user's current thread in the primary
// window and start a real turn in it through the same inspector bridge.

import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import { setTimeout as sleep } from "node:timers/promises";
import { promisify } from "node:util";

const execFileAsync = promisify(execFile);
const INSPECTOR_HOST = "127.0.0.1";
const INSPECTOR_PORTS = Array.from({ length: 11 }, (_, index) => 9229 + index);
// The Codex desktop app was renamed to ChatGPT while retaining bundle id
// com.openai.codex. Support both the current and legacy application layouts.
const CODEX_MAIN_PATHS = [
  "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT",
  "/Applications/Codex.app/Contents/MacOS/Codex",
];
// Launch order for a cold open (see notifyCodexDesktopThreads). Matches the
// overlay's own activation candidates so both agree on which app "codex" means.
const CODEX_DESKTOP_BUNDLE_IDS = ["com.openai.codex", "com.openai.chat"];
// Consecutive empty `ps` reads before a cold-start wait concludes the app really
// is gone. Matches the pill's own host-visibility debounce.
const CODEX_ABSENT_MISS_THRESHOLD = 3;

let wsFallbackPromise = null;
let codexProjectMutationChain = Promise.resolve();

export function enqueueCodexProjectMutation(run) {
  const result = codexProjectMutationChain.then(run, run);
  codexProjectMutationChain = result.catch(() => {});
  return result;
}

async function resolveWebSocketImpl() {
  if (typeof WebSocket === "function") return WebSocket;
  if (!wsFallbackPromise) {
    wsFallbackPromise = import("ws")
      .then((mod) => mod.default || mod.WebSocket || mod)
      .catch(() => null);
  }
  const ws = await wsFallbackPromise;
  if (!ws) throw new Error("No WebSocket implementation available (global WebSocket missing and 'ws' not installed)");
  return ws;
}

export async function notifyCodexDesktopThread({ threadId, pinnedThreadIds = null, timeoutMs, open = false } = {}) {
  return notifyCodexDesktopThreads({
    threadIds: threadId ? [threadId] : [],
    pinnedThreadIds,
    timeoutMs,
    openThreadId: open ? threadId : null,
  });
}

export async function notifyCodexDesktopThreads(options = {}) {
  const mutatesProject = Boolean(String(options.ensureWorkspaceRoot || "").trim());
  return mutatesProject
    ? enqueueCodexProjectMutation(() => notifyCodexDesktopThreadsUnserialized(options))
    : notifyCodexDesktopThreadsUnserialized(options);
}

async function notifyCodexDesktopThreadsUnserialized({
  threadIds = [],
  pinnedThreadIds = null,
  openThreadId = null,
  workspaceRootsByThreadId = null,
  ensureWorkspaceRoot = null,
  assignmentOnly = false,
  primeOpen = true,
  // The open path navigates hotkey -> (prime wait) -> /local inside the renderer,
  // so the inspector eval must outlast RELAY_CODEX_OPEN_PRIME_MS; default higher.
  timeoutMs = Number(process.env.RELAY_CODEX_DESKTOP_TIMEOUT_MS || 8000),
} = {}) {
  if (process.env.RELAY_CODEX_DESKTOP_REFRESH === "0") return { attempted: false, reason: "disabled" };
  if (process.platform !== "darwin") return { attempted: false, reason: "not-darwin" };

  const openId = String(openThreadId || "").trim() || null;
  const ids = uniqueStrings(openId ? [...threadIds, openId] : threadIds);
  const pinnedIds = Array.isArray(pinnedThreadIds) ? uniqueStrings(pinnedThreadIds) : null;
  if (!ids.length && !pinnedIds && !openId) return { attempted: false, reason: "nothing-to-refresh" };

  let pids = await findCodexMainPids();
  let coldLaunched = false;
  if (!pids.length) {
    // COLD START: an open must not assume the app is already running. Returning
    // "codex-not-running" here leaves the caller's codex:// deep link as the only
    // opener, and that link is a RACE against the app's own boot — fired at a
    // launching ChatGPT it is sometimes dropped and the user lands on the Home
    // screen with the relay merely pinned (observed live: two cold opens landed
    // on the thread, a third landed on Home). Launch the app ourselves and wait
    // for it, so the deterministic bridge path below does the open instead.
    // Only a real OPEN may launch: a background pin/recents refresh must never
    // make an app appear on someone's screen.
    if (!openId) return { attempted: true, ok: false, reason: "codex-not-running", results: [] };
    const launched = await launchCodexDesktop();
    pids = launched ? await waitForCodexMainPids(Number(process.env.RELAY_CODEX_LAUNCH_TIMEOUT_MS || 30000)) : [];
    if (!pids.length) return { attempted: true, ok: false, reason: "codex-launch-failed", results: [] };
    coldLaunched = true;
  }

  const expression = buildCodexDesktopRefreshExpression({
    threadIds: ids,
    pinnedThreadIds: pinnedIds,
    openThreadId: openId,
    workspaceRootsByThreadId,
    ensureWorkspaceRoot,
    assignmentOnly,
    primeOpen,
  });
  // READINESS, not liveness: a just-launched app answers the inspector (the main
  // process is up) long before it owns a BrowserWindow to drive, and an app the
  // user left running with every window CLOSED never owns one at all. Both cases
  // used to score as success — `ok` counted the inspector evaluation, not the
  // window — which suppressed the deep-link fallback and showed the user nothing.
  // So wait for a primary window to actually RUN the code, polling rather than
  // sleeping a fixed guess, and report honestly when it never does.
  const readyDeadline = Date.now() + (coldLaunched ? Number(process.env.RELAY_CODEX_COLD_READY_MS || 45000) : 0);
  let results = [];
  let misses = 0;
  for (;;) {
    results = await evaluateAcrossCodexPids(pids, expression, { timeoutMs });
    if (results.some(primaryWindowRan)) break;
    if (Date.now() >= readyDeadline) break;
    await sleep(500);
    const next = await findCodexMainPids();
    // A booting app's process list FLICKERS — an early build measured one empty
    // `ps` read seconds after a successful launch — so a single miss must not
    // abandon the wait, or the cold open gives up while the app is still coming
    // up. Same 3-miss debounce the pill uses for host visibility.
    if (next.length) {
      misses = 0;
      pids = next;
      continue;
    }
    misses += 1;
    if (misses >= CODEX_ABSENT_MISS_THRESHOLD) break; // really gone; nothing left to wait for
  }

  const reached = results.some(primaryWindowRan);
  const rendererResult = primaryRefreshRendererResult(results);
  return {
    attempted: true,
    ok: reached,
    // Keep this distinct from the generic result for refresh-only calls. The
    // caller may suppress the codex:// fallback only after the requested task
    // is active and the selected Codex window has actually taken focus.
    openConfirmed: Boolean(openId && reached),
    projectAssignmentOk: ensureWorkspaceRoot ? rendererResult?.projectAssignmentOk === true : true,
    ...(reached ? {} : { reason: coldLaunched ? "codex-not-ready" : "codex-window-unavailable" }),
    results,
  };
}

async function evaluateAcrossCodexPids(pids, expression, { timeoutMs }) {
  const results = [];
  for (const pid of pids) {
    try {
      const target = await findOrStartInspectorForPid(pid, { timeoutMs });
      if (!target) {
        results.push({ pid, ok: false, reason: "inspector-unavailable" });
        continue;
      }
      const value = await evaluateInspectorExpression(target.webSocketDebuggerUrl, expression, { timeoutMs });
      results.push({ pid, ok: true, value });
    } catch (error) {
      results.push({ pid, ok: false, error: errorMessage(error) });
    }
  }
  return results;
}

// Did a primary window actually EXECUTE the renderer code? `ok` on the per-pid
// envelope only says the inspector evaluation itself succeeded, which is equally
// true when BrowserWindow.getAllWindows() came back empty — so it cannot stand in
// for readiness. Exported for the unit tests.
export function primaryWindowRan(entry) {
  if (!entry || !entry.ok || !Array.isArray(entry.value)) return false;
  return entry.value.some(
    (win) =>
      win &&
      win.kind === "primary" &&
      !win.skipped &&
      win.result?.ok === true &&
      (!win.focusRequested || win.focused === true),
  );
}

export function primaryRefreshRendererResult(results) {
  for (const entry of Array.isArray(results) ? results : []) {
    if (!entry?.ok || !Array.isArray(entry.value)) continue;
    const selected = entry.value.find((win) => win?.kind === "primary" && !win.skipped && win.result && typeof win.result === "object");
    if (selected) return selected.result;
  }
  return null;
}

// Bring up ChatGPT/Codex Desktop. Bundle ids in the same order the activation
// path uses, so a machine carrying only the legacy app still launches.
async function launchCodexDesktop() {
  for (const bundle of CODEX_DESKTOP_BUNDLE_IDS) {
    try {
      await execFileAsync("/usr/bin/open", ["-b", bundle], { timeout: 8000 });
      return true;
    } catch {
      // Try the next bundle id.
    }
  }
  return false;
}

// Poll for the launched process instead of sleeping a guess: LaunchServices
// returns as soon as it has handed the launch off, well before the app exists.
async function waitForCodexMainPids(timeoutMs) {
  const deadline = Date.now() + Math.max(1000, timeoutMs);
  for (;;) {
    const pids = await findCodexMainPids();
    if (pids.length) return pids;
    if (Date.now() >= deadline) return [];
    await sleep(250);
  }
}

// "Open in current chat" live tier: start a real turn in an existing thread in
// the running ChatGPT desktop app via the inspector bridge. Returns
//   { attempted, submitted, reason?, results }
// submitted=true ONLY when the selected primary window's renderer reported
// ok:true from relaySubmitCodexRenderer. Every other outcome (codex not
// running, inspector unavailable, resume/start-turn failure, busy thread) is a
// clean signal for the caller to fall back to the automation tier.
export async function submitTurnToCodexDesktopThread({
  threadId,
  text,
  model,
  effort,
  cwd,
  approvalPolicy,
  approvalsReviewer,
  sandboxPolicy,
  // Ownership of a just-resumed thread settles ~7s after the resume, and a
  // turn/start fired before then no-ops with no error to read (the bridge is
  // fire-and-forget). So the caller re-fires until the ROLLOUT GROWS — the
  // only honest signal that the model actually took the turn.
  rolloutPath = "",
  confirmAttempts = Number(process.env.RELAY_CODEX_TURN_ATTEMPTS || 4),
  confirmIntervalMs = Number(process.env.RELAY_CODEX_TURN_INTERVAL_MS || 5000),
  // Must outlast the renderer's worst-case step budget (history hydrate 4s +
  // resume 6s + start-turn 8s), or the inspector eval would give up while the
  // renderer is still working and mis-report a live submit as failed.
  timeoutMs = Number(process.env.RELAY_CODEX_SUBMIT_TIMEOUT_MS || 20000),
  // Keep the platform gate injectable so lifecycle tests exercise the macOS
  // bridge deterministically on GitHub's Linux runners.
  platform = process.platform,
  // Test seam for the lifecycle state machine. Production callers never pass
  // this; keeping the process/inspector clock injectable lets regression tests
  // prove a PID swap and a dead inspector without killing the user's app.
  runtime = null,
} = {}) {
  if (process.env.RELAY_CODEX_DESKTOP_REFRESH === "0") return { attempted: false, submitted: false, reason: "disabled" };
  if (platform !== "darwin") return { attempted: false, submitted: false, reason: "not-darwin" };
  const cleanThreadId = String(threadId || "").trim();
  const cleanText = String(text || "").trim();
  if (!cleanThreadId || !cleanText) return { attempted: false, submitted: false, reason: "missing-thread-or-text" };

  const io = runtime || {};
  const findPids = io.findCodexMainPids || findCodexMainPids;
  const evaluate = io.evaluateAcrossCodexPids || evaluateAcrossCodexPids;
  const pause = io.sleep || sleep;
  const launch = io.launchCodexDesktop || launchCodexDesktop;
  const waitPids = io.waitForCodexMainPids || waitForCodexMainPids;
  // One logical press owns one identity for its entire lifetime. The old retry
  // expression generated fresh UUIDs every time, so an inspector timeout after
  // the app-server had accepted a turn could create the same user message two
  // or three times. Codex deduplicates clientUserMessageId; preserve it across
  // every renderer/PID retry, and preserve the JSON-RPC id as well.
  const clientUserMessageId = randomUUID();
  const requestId = randomUUID();
  const expression = buildCodexDesktopSubmitExpression({
    threadId: cleanThreadId,
    text: cleanText,
    model,
    effort,
    cwd,
    approvalPolicy,
    approvalsReviewer,
    sandboxPolicy,
    clientUserMessageId,
    requestId,
  });

  const maxAttempts = Math.max(1, Number.isFinite(confirmAttempts) ? confirmAttempts : 1);
  const pollMs = Math.max(10, Math.min(700, Math.floor(Math.max(10, confirmIntervalMs) / 4)));
  const allResults = [];
  let renderer = null;
  let delivered = false;
  let ran = rolloutPath ? codexRolloutHasClientMessage(rolloutPath, clientUserMessageId) : null;
  let attempts = 0;
  let coldLaunchTried = false;

  for (let attempt = 1; attempt <= maxAttempts && ran !== true; attempt += 1) {
    attempts = attempt;
    // Resolve the process again on EVERY pass. A ChatGPT update/restart leaves
    // the prior inspector WebSocket and PID stale while a new renderer is
    // already alive; retrying the captured PID was the intermittent failure.
    let pids = await findPids();
    if (!pids.length && !coldLaunchTried) {
      coldLaunchTried = true;
      if (await launch()) pids = await waitPids(Number(process.env.RELAY_CODEX_LAUNCH_TIMEOUT_MS || 30000));
    }

    if (pids.length) {
      const results = await evaluate(pids, expression, { timeoutMs });
      allResults.push(...results.map((entry) => ({ ...entry, attempt })));
      const current = primarySubmitRendererResult(results);
      if (current) renderer = current;
      if (current && current.ok === true) delivered = true;
      // A retry can discover the active turn created by the previous attempt.
      // Do not fire over it: the exact rollout identity below decides whether
      // it is ours. If it is unrelated, report busy honestly.
      if (current?.reason === "turn-in-progress" && !delivered) {
        const deadline = Date.now() + Math.max(10, confirmIntervalMs);
        while (rolloutPath && Date.now() < deadline) {
          if (codexRolloutHasClientMessage(rolloutPath, clientUserMessageId)) {
            ran = true;
            break;
          }
          await pause(pollMs);
        }
        if (ran !== true) break;
      }
    }

    if (!rolloutPath) {
      ran = null;
      if (delivered) break;
    } else {
      const deadline = Date.now() + Math.max(10, confirmIntervalMs);
      while (Date.now() < deadline) {
        if (codexRolloutHasClientMessage(rolloutPath, clientUserMessageId)) {
          ran = true;
          break;
        }
        await pause(pollMs);
      }
      if (ran !== true) ran = false;
    }
  }

  if (!allResults.length && !coldLaunchTried) {
    return { attempted: true, submitted: false, ran, reason: "codex-not-running", results: [], clientUserMessageId };
  }
  return {
    attempted: true,
    submitted: delivered,
    ran,
    turnAttempts: attempts,
    reason: renderer ? renderer.reason || null : "no-primary-window-result",
    rendererResult: renderer,
    results: allResults,
    clientUserMessageId,
  };
}

/**
 * The app-server writes the composer identity verbatim on the canonical
 * event_msg/user_message row. Matching that identity is the only honest start
 * acknowledgment: line-count growth can be a token count, tool update, or a
 * different native turn in the same thread.
 */
export function codexRolloutHasClientMessage(rolloutPath, clientUserMessageId) {
  const wanted = String(clientUserMessageId || "").trim();
  if (!wanted) return false;
  let body;
  try {
    body = fs.readFileSync(rolloutPath, "utf8");
  } catch {
    return false;
  }
  for (const line of body.split("\n")) {
    if (!line || !line.includes(wanted)) continue;
    try {
      const row = JSON.parse(line);
      if (row?.type === "event_msg" && row?.payload?.type === "user_message" && row.payload.client_id === wanted) return true;
    } catch {
      // A writer may be halfway through the final line; the next poll retries.
    }
  }
  return false;
}

// Dig the renderer's relaySubmitCodexRenderer return value out of the
// per-pid/per-window envelope. Exported for the fallback-ordering tests.
export function primarySubmitRendererResult(results) {
  for (const entry of Array.isArray(results) ? results : []) {
    if (!entry || !entry.ok || !Array.isArray(entry.value)) continue;
    for (const win of entry.value) {
      if (win && win.kind === "primary" && !win.skipped && win.result && typeof win.result === "object") {
        return win.result;
      }
    }
  }
  return null;
}

// Runs INSIDE the selected primary Codex renderer: it is serialized to a string
// via toString() and evaluated in the window's page context, so it must be
// self-contained — no imports, only renderer globals (window, location).
// Exported at module scope so the thread-routing decision can be unit-tested.
export async function relayRefreshCodexRenderer(payload) {
  const hostId = "local";
  const bridge = window.electronBridge;
  if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };

  const sent = [];
  // Bound every bridge send. Some host messages (notably maybe-resume-conversation)
  // can hang indefinitely; the original serial awaits then block the navigation
  // that follows, leaving the thread unopened. Cap each send so the open proceeds.
  async function send(message, timeoutMs = 2000) {
    try {
      await Promise.race([
        bridge.sendMessageFromView(message),
        new Promise((_, reject) => setTimeout(() => reject(new Error("send-timeout")), timeoutMs)),
      ]);
      sent.push({ type: message.type, ok: true });
    } catch (error) {
      sent.push({ type: message.type, ok: false, error: String(error?.message || error) });
    }
  }

  // Use the same vscode://codex request bridge as Codex Desktop's own state
  // store. `sendMessageFromView` is one-way, while the matching fetch response
  // arrives as a window message, so correlate it explicitly by request id.
  // This keeps project membership writes inside Codex's main process: no
  // brittle direct edits of .codex-global-state.json and no lost broadcasts.
  async function hostRequest(name, params, timeoutMs = 700) {
    const requestId = globalThis.crypto?.randomUUID?.() || `relay-${Date.now()}-${Math.random()}`;
    return new Promise((resolve, reject) => {
      let settled = false;
      const timer = setTimeout(() => {
        finish(() => reject(new Error(`${name}-timeout`)));
      }, timeoutMs);
      function finish(callback) {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        window.removeEventListener?.("message", onMessage);
        callback();
      }
      function onMessage(event) {
        const message = event?.data;
        if (message?.type !== "fetch-response" || message.requestId !== requestId) return;
        if (message.responseType !== "success" || !(message.status >= 200 && message.status < 300)) {
          finish(() => reject(new Error(String(message.error || message.bodyJsonString || `${name}-failed`))));
          return;
        }
        finish(() => {
          try {
            resolve(JSON.parse(message.bodyJsonString || "null"));
          } catch (error) {
            reject(error);
          }
        });
      }
      window.addEventListener?.("message", onMessage);
      try {
        Promise.resolve(
          bridge.sendMessageFromView({
            type: "fetch",
            requestId,
            method: "POST",
            url: `vscode://codex/${name}`,
            body: JSON.stringify(params),
          }),
        ).catch((error) => finish(() => reject(error)));
      } catch (error) {
        finish(() => reject(error));
      }
    });
  }

  const canonicalRoot = (value) => String(value || "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
  const findProjectForRoot = (projects, root) => {
    const wanted = canonicalRoot(root);
    if (!wanted || !projects || typeof projects !== "object") return null;
    return (
      Object.values(projects).find((project) =>
        Array.isArray(project?.rootPaths) && project.rootPaths.some((candidate) => canonicalRoot(candidate) === wanted),
      ) || null
    );
  };

  if (Array.isArray(payload.pinnedThreadIds)) {
    await send({ type: "set-pinned-thread-ids-for-host", hostId, threadIds: payload.pinnedThreadIds });
  }
  const projectRoot = String(payload.ensureWorkspaceRoot || "").trim();
  let projectAssignmentOk = !projectRoot;
  let relayProject = null;
  let projectLookupOk = false;
  if (projectRoot) {
    const mutateProjectState = async () => {
    try {
      relayProject = findProjectForRoot((await hostRequest("get-global-state", { key: "local-projects" }))?.value, projectRoot);
      projectLookupOk = true;
    } catch (error) {
      sent.push({ type: "relay-project-lookup", ok: false, error: String(error?.message || error) });
    }
    // Create through Codex's native project manager on a foreground open. The
    // update-roots command owns the project-map transaction and selects the
    // resulting project, but (unlike add-new-workspace-root-option) does not
    // navigate the renderer to Home. Background repair never creates/selects a
    // project or disturbs the active window.
    if (projectLookupOk && !relayProject) {
      if (payload.openThreadId) {
        try {
          await send({ type: "electron-update-workspace-root-options", roots: [projectRoot] });
          for (let attempt = 0; attempt < 5 && !relayProject; attempt += 1) {
            await new Promise((resolve) => setTimeout(resolve, 80));
            const saved = (await hostRequest("get-global-state", { key: "local-projects" }))?.value;
            relayProject = findProjectForRoot(saved, projectRoot);
          }
        } catch (error) {
          sent.push({ type: "relay-project-registration", ok: false, error: String(error?.message || error) });
        }
      }
    }

    if (relayProject?.id) {
      const concurrentAssignments = {};
      const onAssignmentUpdate = (event) => {
        const message = event?.data;
        if (message?.type !== "thread-project-assignments-updated" || !message.assignments || typeof message.assignments !== "object") return;
        Object.assign(concurrentAssignments, message.assignments);
      };
      window.addEventListener?.("message", onAssignmentUpdate);
      try {
        const assignment = { projectKind: "local", projectId: relayProject.id };
        const relayThreadIds = payload.threadIds.filter((threadId) => {
          const roots = Array.isArray(payload.workspaceRootsByThreadId?.[threadId])
            ? payload.workspaceRootsByThreadId[threadId]
            : [];
          return roots.some((root) => canonicalRoot(root) === canonicalRoot(projectRoot));
        });
        let verified = false;
        const applyConcurrent = (assignments) => {
          for (const [threadId, concurrent] of Object.entries(concurrentAssignments)) {
            if (concurrent == null) delete assignments[threadId];
            else assignments[threadId] = concurrent;
          }
        };
        const sameAssignment = (left, right) =>
          left?.projectKind === right?.projectKind &&
          left?.projectId === right?.projectId &&
          (left?.hostId || null) === (right?.hostId || null);
        for (let attempt = 0; attempt < 2 && !verified; attempt += 1) {
          const current = (await hostRequest("get-global-state", { key: "thread-project-assignments" }))?.value;
          const assignments = current && typeof current === "object" ? { ...current } : {};
          applyConcurrent(assignments);
          for (const threadId of relayThreadIds) assignments[threadId] = assignment;
          await hostRequest("set-global-state", { key: "thread-project-assignments", value: assignments });
          const saved = (await hostRequest("get-global-state", { key: "thread-project-assignments" }))?.value;
          const required = { ...assignments };
          applyConcurrent(required);
          for (const threadId of relayThreadIds) required[threadId] = assignment;
          verified = Object.entries(required).every(([threadId, expected]) =>
            expected == null ? saved?.[threadId] == null : sameAssignment(saved?.[threadId], expected),
          );
        }
        if (!verified) throw new Error("assignment-not-persisted");

        // Do not rewrite Codex's projectless-thread-ids array. Codex derives
        // Recents from the assignment map and excludes every assigned thread;
        // its native projectless bookkeeping may change concurrently, and a
        // generic whole-array write here could erase an unrelated new task.
        projectAssignmentOk = true;
        sent.push({ type: "relay-project-assignment", ok: true, projectId: relayProject.id });
      } catch (error) {
        sent.push({ type: "relay-project-assignment", ok: false, error: String(error?.message || error) });
      } finally {
        window.removeEventListener?.("message", onAssignmentUpdate);
      }
    } else {
      sent.push({ type: "relay-project-assignment", ok: false, error: "project-not-found" });
    }
    };
    const locks = globalThis.navigator?.locks;
    if (locks?.request) {
      let acquired = false;
      // An inspector evaluation cannot cancel executeJavaScript after its
      // websocket timeout. Never leave an expression queued on a browser lock:
      // if another Relay mutation owns it, fail fast and let the bounded quiet
      // assignment retry heal the task without a late surprise navigation.
      for (let attempt = 0; attempt < 3 && !acquired; attempt += 1) {
        acquired = await locks.request(
          "relay-codex-project-state",
          { ifAvailable: true },
          async (lock) => {
            if (!lock) return false;
            await mutateProjectState();
            return true;
          },
        );
        if (!acquired && attempt < 2) await new Promise((resolve) => setTimeout(resolve, 40 * (attempt + 1)));
      }
      if (!acquired) sent.push({ type: "relay-project-assignment", ok: false, error: "project-state-lock-busy" });
    } else {
      await mutateProjectState();
    }
  }
  if (payload.assignmentOnly) {
    return {
      ok: projectAssignmentOk,
      projectAssignmentOk,
      openConfirmed: false,
      href: location.href,
      openThreadId: null,
      sent,
    };
  }
  if (payload.threadIds.length) {
    await send({ type: "hydrate-pinned-threads", hostId, threadIds: payload.threadIds });
  }
  await send({ type: "refresh-recent-conversations-for-host", hostId });
  for (const threadId of payload.threadIds) {
    const workspaceRoots = Array.isArray(payload.workspaceRootsByThreadId?.[threadId])
      ? payload.workspaceRootsByThreadId[threadId].map((root) => String(root || "").trim()).filter(Boolean)
      : [];
    // Codex Desktop (>=0.142) serves thread views from its in-memory core and
    // never cold-loads externally created threads — the view spins forever
    // until an app restart. Adopt the thread the way the app itself opens one:
    // drop any stale cache entry, ask the tail-hydration path to load history
    // (feature-gated; harmless when off — dependentConversationIds is REQUIRED
    // or the handler throws), then maybe-resume-conversation, which activates
    // the thread summary and resumes the conversation from the rollout on disk.
    await send({ type: "discard-conversation-from-cache", conversationId: threadId });
    await send({ type: "ensure-conversation-history-loaded", conversationId: threadId, dependentConversationIds: [] });
    await send({
      type: "maybe-resume-conversation",
      hostId,
      conversationId: threadId,
      model: null,
      serviceTier: null,
      reasoningEffort: null,
      workspaceRoots,
      collaborationMode: null,
    });
    await send({ type: "broadcast-conversation-snapshot-for-host", hostId, conversationId: threadId });
  }
  let openConfirmed = false;
  if (payload.openThreadId) {
    const encoded = encodeURIComponent(String(payload.openThreadId));
    const navTo = (path) => {
      try {
        window.postMessage({ type: "navigate-to-route", path }, location.origin);
        const attempt = { type: "navigate-to-route", attempted: true, ok: false, path };
        sent.push(attempt);
        return attempt;
      } catch (error) {
        const attempt = { type: "navigate-to-route", attempted: true, ok: false, path, error: String(error?.message || error) };
        sent.push(attempt);
        return attempt;
      }
    };
    const targetThreadId = String(payload.openThreadId);
    const targetIsActive = () => {
      const nodes = window.document?.querySelectorAll?.("[data-app-action-sidebar-thread-id]") || [];
      for (const node of nodes) {
        if (
          node.getAttribute?.("data-app-action-sidebar-thread-id") === targetThreadId &&
          node.getAttribute?.("data-app-action-sidebar-thread-active") === "true"
        ) {
          return true;
        }
      }
      return false;
    };
    const confirmActiveThread = async () => {
      const timeoutMs = Number.isFinite(payload.confirmMs) ? payload.confirmMs : 2500;
      const deadline = Date.now() + Math.max(0, timeoutMs);
      do {
        if (targetIsActive()) return true;
        if (Date.now() >= deadline) return false;
        await new Promise((resolve) => setTimeout(resolve, 50));
      } while (true);
    };
    // Codex renders a thread through two router surfaces: the main window
    // (sidebar + recents rail) at /local/<id>, and the borderless hotkey
    // "quick chat" view at /hotkey-window/thread/<id>. Two facts drive the logic:
    //   1. The app uses an in-memory router, so location.pathname is ALWAYS
    //      /index.html — it never reveals the current route or window kind.
    //   2. The /local/<id> route CANNOT cold-load an externally-created relay
    //      thread: it renders the empty "New chat" state and never recovers.
    //      Only /hotkey-window/thread/<id> loads such a thread from disk into the
    //      renderer store. Once loaded, /local/<id> renders it fine.
    // So to land the user in the main window WITH content, prime the load via the
    // hotkey route, then switch to /local/<id> once it is warm. The hotkey view
    // shows the thread during the prime, so the transition is content -> content,
    // never a blank flash. The second materializer pass runs with primeOpen=false
    // and just re-asserts /local (the thread is warm by then).
    const inHotkeyWindow = /hotkey-window/.test(location.href) || /hotkey-window/.test(location.search || "");
    const primeMs = Number.isFinite(payload.primeMs) ? payload.primeMs : 1200;
    let finalNavigation;
    if (inHotkeyWindow) {
      finalNavigation = navTo(`/hotkey-window/thread/${encoded}`);
    } else if (payload.primeOpen === false) {
      finalNavigation = navTo(`/local/${encoded}`);
    } else {
      navTo(`/hotkey-window/thread/${encoded}`);
      await new Promise((resolve) => setTimeout(resolve, primeMs));
      finalNavigation = navTo(`/local/${encoded}`);
    }
    openConfirmed = await confirmActiveThread();
    if (finalNavigation) {
      finalNavigation.confirmed = openConfirmed;
      finalNavigation.ok = openConfirmed;
    }
  }

  return {
    // Opening and assignment have separate recovery paths. If navigation
    // succeeded, report that visual fact even when the project write needs a
    // quiet retry; otherwise the caller fires a second codex:// open and the
    // user sees a duplicate/flicker despite already being in the task.
    ok: payload.openThreadId ? openConfirmed : sent.some((item) => item.ok),
    projectAssignmentOk,
    openConfirmed,
    href: location.href,
    openThreadId: payload.openThreadId || null,
    sent,
  };
}

// Runs INSIDE the selected primary Codex renderer (same serialization contract
// as relayRefreshCodexRenderer: self-contained, renderer globals only). Starts
// a REAL turn in an existing thread — the "Open in current chat" live tier:
//   1. ensure-conversation-history-loaded (best-effort tail hydration),
//   2. maybe-resume-conversation — activates + resumes the thread in this
//      window and returns { activeTurnId }: non-null means a turn is RUNNING,
//      in which case we bail out rather than colliding with the user's work
//      (the caller queues behind idle or falls back to the automation tier),
//   3. start-turn-for-host with app-server TurnStartParams
//      ({ input: [{ type: "text", text, text_elements: [] }] }; thread settings
//      are inherited from the conversation) — verified against the desktop
//      bundle: this is the plain submit handler with NO thread-follower-owner
//      assertion. thread-follower-submit-user-input is NOT a submit — it
//      answers an item/tool/requestUserInput prompt.
//   4. navigate to the thread so the injected turn is visible. No hotkey
//      prime: the thread was just resumed in this window, and the current
//      chat is often already on screen — a hotkey bounce would visibly yank it.
// The turn/start envelope the app sends for its own composer. SECURITY: the
// sandbox and approval policy MUST ride the turn params. A thread created with
// sandbox "workspace-write" does not keep it across a resume — the app falls
// back to ~/.codex/config.toml, which on a developer's machine is routinely
// approval_policy = "never" / sandbox_mode = "danger-full-access". A relay
// carries text written by SOMEONE ELSE, so an unsandboxed, unapproved run on
// that text is exactly the thing we must never ship.
export function codexTurnStartMessage({ threadId, text, payload = {} }) {
  const uuid = () =>
    (globalThis.crypto && globalThis.crypto.randomUUID && globalThis.crypto.randomUUID()) ||
    `relay-${Date.now()}-${Math.random().toString(16).slice(2)}`;
  const params = {
    threadId,
    input: [{ type: "text", text, text_elements: [] }],
    // Bypass, for the same reason as the Claude lane: the human read the request
    // and pressed Start — that IS the consent gate. on-request made the agent
    // stop at a prompt raised inside the Codex app, invisible to the reader
    // watching the run in the pill, and the run simply hung (David, live).
    approvalPolicy: payload.approvalPolicy || "never",
    // The app-server's OWN shape, read from its logs: {"type":"workspaceWrite",
    // "networkAccess":false,...}. Sending {mode:"workspace-write"} is rejected
    // outright — `Invalid request: missing field \`type\`` (-32600) — and the
    // bridge is fire-and-forget, so the rejection is INVISIBLE to us: the turn
    // simply never happens and the page says "working" forever. This one wrong
    // key is why every Codex request hung (David, live).
    // Matches what the user's OWN sessions get from ~/.codex/config.toml, which
    // is the stated bar: a Relay-started run should behave exactly like one they
    // started themselves in the app.
    sandboxPolicy: payload.sandboxPolicy || { type: "dangerFullAccess" },
    clientUserMessageId: payload.clientUserMessageId || uuid(),
  };
  if (payload.approvalsReviewer) params.approvalsReviewer = payload.approvalsReviewer;
  if (payload.cwd) params.cwd = payload.cwd;
  // The model is not optional in practice: the account's default-model bucket
  // can be exhausted while another model's is untouched, and an omitted model
  // fails the whole turn with usageLimitExceeded.
  if (payload.model) params.model = payload.model;
  if (payload.effort) params.effort = payload.effort;
  return {
    type: "mcp-request",
    hostId: "local",
    priority: "interactive",
    source: "relay",
    timeoutMs: 600000,
    request: { id: payload.requestId || uuid(), method: "turn/start", params },
  };
}

// Ownership of a just-resumed thread takes ~7s to settle, and a turn/start
// fired before then silently no-ops. The bridge is fire-and-forget so there is
// no error to read — the caller re-fires this until the rollout actually grows.
export async function relayFireCodexTurnRenderer(payload) {
  const bridge = window.electronBridge;
  if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };
  const threadId = String(payload.threadId || "").trim();
  const text = String(payload.text || "");
  if (!threadId || !text.trim()) return { ok: false, reason: "missing-thread-or-text" };
  try {
    await bridge.sendMessageFromView(codexTurnStartMessage({ threadId, text, payload }));
    return { ok: true };
  } catch (error) {
    return { ok: false, reason: String(error?.message || error) };
  }
}

export async function relaySubmitCodexRenderer(payload) {
  const hostId = "local";
  const bridge = window.electronBridge;
  if (!bridge?.sendMessageFromView) return { ok: false, reason: "missing-electron-bridge" };

  const steps = [];
  // Unlike the refresh path (fire-and-forget with per-send timeouts), a submit
  // must know whether each step LANDED: a hung or thrown send fails the tier so
  // the caller can fall back to the automation tier instead of losing the open.
  async function send(message, timeoutMs = 4000) {
    const value = await Promise.race([
      bridge.sendMessageFromView(message),
      new Promise((_, reject) => setTimeout(() => reject(new Error("send-timeout")), timeoutMs)),
    ]);
    steps.push({ type: message.type, ok: true });
    return value;
  }
  const fail = (type, error) => steps.push({ type, ok: false, error: String(error?.message || error) });

  const threadId = String(payload.threadId || "").trim();
  const text = String(payload.text || "");
  if (!threadId || !text.trim()) return { ok: false, reason: "missing-thread-or-text", steps };

  // The app serves a STALE in-memory view of a thread another writer touched
  // (the forge writes the rollout from outside), so drop its cache before
  // asking it to resume — otherwise it resumes the version it already had.
  try {
    await send({ type: "discard-conversation-from-cache", conversationId: threadId });
  } catch (error) {
    fail("discard-conversation-from-cache", error); // best-effort
  }
  try {
    await send({ type: "ensure-conversation-history-loaded", conversationId: threadId, dependentConversationIds: [] });
  } catch (error) {
    fail("ensure-conversation-history-loaded", error); // feature-gated in-app; a miss is not fatal
  }

  let resume;
  try {
    resume = await send(
      {
        type: "maybe-resume-conversation",
        hostId,
        conversationId: threadId,
        model: null,
        serviceTier: null,
        reasoningEffort: null,
        workspaceRoots: [],
        collaborationMode: null,
      },
      6000,
    );
  } catch (error) {
    fail("maybe-resume-conversation", error);
    return { ok: false, reason: "resume-failed", steps };
  }
  const activeTurnId = resume && typeof resume === "object" ? (resume.activeTurnId ?? null) : null;
  if (activeTurnId) return { ok: false, reason: "turn-in-progress", activeTurnId, steps };

  // Observed live: bridge sends ACK immediately (fire-and-forget) while the
  // resume completes asynchronously against the app's own app-server. An
  // immediate start-turn races that resume ("Conversation is not being
  // streamed") and silently no-ops. Give the resume a beat, mirroring the
  // refresh path's hotkey prime wait.
  const encodedEarly = encodeURIComponent(threadId);
  const inHotkeyEarly = /hotkey-window/.test(location.href) || /hotkey-window/.test(location.search || "");
  try {
    window.postMessage({ type: "navigate-to-route", path: `/hotkey-window/thread/${encodedEarly}` }, location.origin);
    steps.push({ type: "navigate-to-route", ok: true, path: "hotkey-prime" });
  } catch (error) {
    fail("navigate-to-route", error);
  }
  await new Promise((resolve) => setTimeout(resolve, 600));
  if (!inHotkeyEarly) {
    try {
      window.postMessage({ type: "navigate-to-route", path: `/local/${encodedEarly}` }, location.origin);
      steps.push({ type: "navigate-to-route", ok: true, path: `/local/${encodedEarly}` });
    } catch (error) {
      fail("navigate-to-route", error);
    }
  }
  const settleMs = Number.isFinite(payload.settleMs) ? payload.settleMs : 1200;
  if (settleMs > 0) await new Promise((resolve) => setTimeout(resolve, settleMs));

  // start-turn-for-host is DEAD in this build (ChatGPT 151.0.7922.76 / codex
  // 0.145.0): it acks, and then nothing happens — no rollout growth, no DOM
  // change, not a line in the app's own logs, even with this window confirmed
  // as stream owner. Tasks "started" on Codex for weeks and never ran.
  //
  // What the app itself does when a human presses send is a generic RPC
  // envelope to its OWN embedded app-server (a stdio child with no socket, so
  // this bridge is the only way in). turn/start is the real method, and it is
  // asynchronous: it returns inProgress and completes later.
  try {
    await send(codexTurnStartMessage({ threadId, text, payload }), 8000);
  } catch (error) {
    fail("mcp-request:turn/start", error);
    return { ok: false, reason: "start-turn-failed", steps };
  }

  const encoded = encodeURIComponent(threadId);
  const inHotkeyWindow = /hotkey-window/.test(location.href) || /hotkey-window/.test(location.search || "");
  const navPath = inHotkeyWindow ? `/hotkey-window/thread/${encoded}` : `/local/${encoded}`;
  try {
    // Already navigated before the turn; re-assert so the run is on screen.
    window.postMessage({ type: "navigate-to-route", path: navPath }, location.origin);
    steps.push({ type: "navigate-to-route", ok: true, path: navPath });
  } catch (error) {
    fail("navigate-to-route", error); // the turn landed; visibility is best-effort
  }
  return { ok: true, threadId, steps };
}

// ChatGPT runs several renderer surfaces in separate BrowserWindows. Their
// URLs all point at index.html, but auxiliary surfaces declare their router
// entry point through an encoded `initialRoute` query parameter, for example:
//
//   app://-/index.html?initialRoute=%2Favatar-overlay
//   app://-/index.html?initialRoute=%2Fhotkey-window
//   app://-/index.html?initialRoute=%2Fglobal-dictation
//   app://-/index.html?initialRoute=%2Fchatgpt%2Fquick-chat
//
// Sending Relay's navigation expression to either window mirrors the opened
// thread in the compact overlay. Keep this function self-contained because its
// source is embedded into the Electron main-process inspector expression below.
export function classifyCodexDesktopWindowUrl(url) {
  const raw = String(url || "").trim();
  const candidates = [];
  const decode = (value) => {
    let decoded = String(value || "");
    // URLSearchParams decodes once. A bounded second pass also handles app
    // builds which hand an already-encoded route to the window manager.
    for (let index = 0; index < 3; index += 1) {
      try {
        const next = decodeURIComponent(decoded);
        if (next === decoded) break;
        decoded = next;
      } catch {
        break;
      }
    }
    return decoded;
  };

  try {
    const parsed = new URL(raw);
    candidates.push(parsed.pathname, parsed.hash, parsed.searchParams.get("initialRoute"));
  } catch {
    candidates.push(raw);
  }

  for (const candidate of candidates) {
    const route = decode(candidate).trim().replace(/^#/, "");
    // Keep this list aligned with ChatGPT's non-primary window appearances. In
    // addition to the two user-visible overlays, current builds create separate
    // renderer windows for dictation, quick chat/prewarming, and internal debug
    // tooling. Running Relay's route expression in any of them either mirrors the
    // conversation into a compact surface or corrupts that surface's own route.
    if (
      /^\/(?:avatar-overlay|hotkey-window|global-dictation|debug)(?:[/?#-]|$)/i.test(route) ||
      /^\/chatgpt\/quick-chat(?:-prewarm)?(?:[/?#-]|$)/i.test(route)
    ) {
      return "auxiliary";
    }
  }
  return "primary";
}

export function buildCodexDesktopRefreshExpression({
  threadIds = [],
  pinnedThreadIds = null,
  openThreadId = null,
  workspaceRootsByThreadId = null,
  ensureWorkspaceRoot = null,
  assignmentOnly = false,
  primeOpen = true,
} = {}) {
  const rendererCode = `(${relayRefreshCodexRenderer.toString()})(${JSON.stringify({
    threadIds: uniqueStrings(threadIds),
    pinnedThreadIds,
    openThreadId: String(openThreadId || "").trim() || null,
    workspaceRootsByThreadId:
      workspaceRootsByThreadId && typeof workspaceRootsByThreadId === "object" ? workspaceRootsByThreadId : {},
    ensureWorkspaceRoot: String(ensureWorkspaceRoot || "").trim() || null,
    assignmentOnly: assignmentOnly === true,
    primeOpen: primeOpen !== false,
    primeMs: Number(process.env.RELAY_CODEX_OPEN_PRIME_MS) || 1200,
    confirmMs: Number(process.env.RELAY_CODEX_OPEN_CONFIRM_MS) || 2500,
  })})`;
  // Only bring a window forward when we're actually opening a thread.
  const wantsFocus = Boolean(String(openThreadId || "").trim());
  return buildPrimaryWindowExpression({ rendererCode, wantsFocus });
}

// The live-injection expression: run relaySubmitCodexRenderer in the selected
// primary window, then focus it so the user watches the instruction land.
export function buildCodexDesktopSubmitExpression({ threadId, text, model, effort, cwd, approvalPolicy, approvalsReviewer, sandboxPolicy, clientUserMessageId, requestId, wantsFocus = true } = {}) {
  const payload = {
    threadId: String(threadId || "").trim(),
    text: String(text || ""),
    settleMs: Number(process.env.RELAY_CODEX_SUBMIT_SETTLE_MS) || 1200,
    ...(model ? { model } : {}),
    ...(effort ? { effort } : {}),
    ...(cwd ? { cwd } : {}),
    ...(approvalPolicy ? { approvalPolicy } : {}),
    ...(approvalsReviewer ? { approvalsReviewer } : {}),
    ...(sandboxPolicy ? { sandboxPolicy } : {}),
    ...(clientUserMessageId ? { clientUserMessageId } : {}),
    ...(requestId ? { requestId } : {}),
  };
  // These functions are shipped by STRINGIFYING them into the renderer, so a
  // module-scope helper is not in scope there — it has to travel with them.
  const rendererCode = `(() => { const codexTurnStartMessage = ${codexTurnStartMessage.toString()};
    return (${relaySubmitCodexRenderer.toString()})(${JSON.stringify(payload)}); })()`;
  return buildPrimaryWindowExpression({ rendererCode, wantsFocus: wantsFocus !== false });
}

/**
 * A retry fire, with no resume or navigation: the thread is already open and
 * the only question is whether ownership has settled yet. Cheap enough to
 * repeat every few seconds until the rollout grows.
 */
export function buildCodexDesktopRetryExpression(options = {}) {
  return buildCodexDesktopSubmitExpression({ ...options, wantsFocus: false });
}

// Shared main-process shell: pick exactly one primary ChatGPT window (focused,
// else visible, else first live), run `rendererCode` in it, optionally raise
// it. Serialized into the Electron main process via the inspector.
function buildPrimaryWindowExpression({ rendererCode, wantsFocus }) {
  return `(async () => {
    const req = process.mainModule?.require || process.getBuiltinModule("module").createRequire(process.cwd() + "/");
    const { BrowserWindow } = req("electron");
    const code = ${JSON.stringify(rendererCode)};
    const wantsFocus = ${JSON.stringify(wantsFocus)};
    const classifyWindowUrl = ${classifyCodexDesktopWindowUrl.toString()};
    const windows = [];
    const primaryCandidates = [];
    for (const win of BrowserWindow.getAllWindows()) {
      if (win.isDestroyed()) continue;
      try {
        const url = win.webContents.getURL();
        const kind = classifyWindowUrl(url);
        if (kind === "auxiliary") {
          windows.push({ id: win.id, url, kind, skipped: true, reason: "auxiliary-window" });
          continue;
        }
        primaryCandidates.push({ win, url });
      } catch (error) {
        windows.push({ id: win.id, ok: false, error: String(error?.message || error) });
      }
    }

    // ChatGPT can retain more than one primary-capable window. Route exactly one:
    // prefer the focused window, then a visible window, then the first live one.
    // This prevents a Relay open from being mirrored into any secondary surface.
    const primary = primaryCandidates.find(({ win }) => {
      try { return win.isFocused(); } catch { return false; }
    }) || primaryCandidates.find(({ win }) => {
      try { return win.isVisible(); } catch { return false; }
    }) || primaryCandidates[0] || null;

    for (const candidate of primaryCandidates) {
      if (candidate === primary) continue;
      windows.push({
        id: candidate.win.id,
        url: candidate.url,
        kind: "primary",
        skipped: true,
        reason: "not-selected-primary-window",
      });
    }

    if (primary) {
      const { win, url } = primary;
      try {
        const result = await win.webContents.executeJavaScript(code, true);
        // Bring the selected primary window forward after navigating it to the
        // thread, the way ChatGPT's own restoreShowAndFocusWindow does.
        if (wantsFocus) {
          try {
            if (win.isMinimized()) win.restore();
            win.show();
            win.focus();
            await new Promise((resolve) => setTimeout(resolve, 80));
          } catch {}
        }
        windows.push({ id: win.id, visible: win.isVisible(), url, kind: "primary", focusRequested: wantsFocus, focused: win.isFocused(), result });
      } catch (error) {
        windows.push({ id: win.id, url, kind: "primary", ok: false, error: String(error?.message || error) });
      }
    }
    return windows;
  })()`;
}

async function findCodexMainPids() {
  try {
    const { stdout } = await execFileAsync("ps", ["-Ao", "pid=,command="], { timeout: 1500, maxBuffer: 1024 * 1024 });
    return stdout
      .split("\n")
      .map((line) => line.trim().match(/^(\d+)\s+(.+)$/))
      .filter(Boolean)
      .filter((match) => isCodexMainCommand(match[2]))
      .map((match) => Number(match[1]))
      .filter(Number.isFinite);
  } catch {
    return [];
  }
}

export function isCodexMainCommand(command) {
  const value = String(command || "");
  return CODEX_MAIN_PATHS.some((mainPath) => value === mainPath || value.startsWith(`${mainPath} `));
}

async function findOrStartInspectorForPid(pid, { timeoutMs }) {
  let target = await findInspectorTargetForPid(pid);
  if (target) return target;

  try {
    process.kill(pid, "SIGUSR1");
  } catch {
    return null;
  }

  const deadline = Date.now() + Math.max(500, timeoutMs);
  do {
    await sleep(100);
    target = await findInspectorTargetForPid(pid);
    if (target) return target;
  } while (Date.now() < deadline);
  return null;
}

async function findInspectorTargetForPid(pid) {
  for (const port of INSPECTOR_PORTS) {
    const targets = await listInspectorTargets(port);
    for (const target of targets) {
      if (!target.webSocketDebuggerUrl) continue;
      try {
        const value = await evaluateInspectorExpression(target.webSocketDebuggerUrl, "process.pid", { timeoutMs: 800 });
        if (value === pid) return target;
      } catch {
        // Try the next inspector target.
      }
    }
  }
  return null;
}

function listInspectorTargets(port) {
  return new Promise((resolve) => {
    const req = http.get({ host: INSPECTOR_HOST, port, path: "/json/list", timeout: 300 }, (res) => {
      let body = "";
      res.setEncoding("utf8");
      res.on("data", (chunk) => {
        body += chunk;
      });
      res.on("end", () => {
        try {
          const parsed = JSON.parse(body);
          resolve(Array.isArray(parsed) ? parsed : []);
        } catch {
          resolve([]);
        }
      });
    });
    req.on("timeout", () => {
      req.destroy();
      resolve([]);
    });
    req.on("error", () => resolve([]));
  });
}

async function evaluateInspectorExpression(webSocketDebuggerUrl, expression, { timeoutMs = 4000 } = {}) {
  const WebSocketImpl = await resolveWebSocketImpl();
  return new Promise((resolve, reject) => {
    const ws = new WebSocketImpl(webSocketDebuggerUrl);
    const timer = setTimeout(() => {
      try {
        ws.close();
      } catch {}
      reject(new Error("Timed out talking to Codex inspector"));
    }, timeoutMs);
    const pending = new Map();
    let nextId = 1;

    ws.onopen = () => {
      const id = nextId++;
      pending.set(id, (message) => {
        if (message.exceptionDetails) {
          reject(new Error(message.exceptionDetails.text || message.result?.result?.description || "Inspector evaluation failed"));
          return;
        }
        resolve(message.result?.result?.value);
      });
      ws.send(JSON.stringify({ id, method: "Runtime.evaluate", params: { expression, awaitPromise: true, returnByValue: true } }));
    };
    ws.onerror = () => reject(new Error("Failed to connect to Codex inspector"));
    ws.onmessage = (event) => {
      let message;
      try {
        message = JSON.parse(event.data);
      } catch {
        return;
      }
      const handler = pending.get(message.id);
      if (!handler) return;
      pending.delete(message.id);
      clearTimeout(timer);
      try {
        ws.close();
      } catch {}
      handler(message);
    };
  });
}

function uniqueStrings(values) {
  return Array.from(new Set(values.map((value) => String(value || "").trim()).filter(Boolean)));
}

function errorMessage(error) {
  return error instanceof Error ? error.message : String(error);
}