declare const EdgeRuntime: string | undefined;
/**
 * CursorExecutor — talks to Cursor's agent.v1.AgentService/Run endpoint.
 *
 * cursor-agent (CLI) and the cursor IDE both use this RPC for every model id
 * (auto, composer-*, claude-*, gpt-*, gemini-*). The legacy
 * aiserver.v1.ChatService/StreamUnifiedChatWithTools rejects "auto" and
 * "composer-*" with errors, so we migrated this executor over.
 *
 * Wire format & schema details live in ../utils/cursorAgentProtobuf.ts.
 */

import { BaseExecutor, mergeUpstreamExtraHeaders } from "./base.ts";
import { PROVIDERS, HTTP_STATUS } from "../config/constants.ts";
import {
  buildAgentRequestBody,
  decodeAgentServerMessage,
  decodeExecServerEvent,
  decodeKvServerEvent,
  encodeRequestContextResponse,
  encodeKvGetBlobResult,
  encodeKvSetBlobResult,
  encodeExecReadRejected,
  encodeExecWriteRejected,
  encodeExecDeleteRejected,
  encodeExecLsRejected,
  encodeExecShellRejected,
  encodeExecBackgroundShellSpawnRejected,
  encodeExecGrepError,
  encodeExecFetchError,
  encodeExecWriteShellStdinError,
  encodeExecDiagnosticsResult,
  flattenMessages,
  openAIToolsToMcpDefs,
  type ChatMessage,
  type EncodedImage,
  type ExecServerEvent,
  type McpToolDefinition,
  type OpenAITool,
} from "../utils/cursorAgentProtobuf.ts";
import {
  resolveCursorImages,
  extractImageUrls,
  CursorImageError,
} from "../utils/cursorImages.ts";
import {
  estimateInputTokens,
  estimateOutputTokens,
  addBufferToUsage,
} from "../utils/usageTracking.ts";
import { getCursorVersion } from "../utils/cursorVersionDetector.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";
import { generateToolCallId } from "../translator/helpers/toolCallHelper.ts";
import { cursorSessionManager, type CursorSession } from "../services/cursorSessionManager.ts";
import crypto from "crypto";
import * as fs from "node:fs";
import * as zlib from "node:zlib";
import { promisify } from "node:util";

// Reject reason text aligned with kaitranntt/CLIProxyAPIPlus — proven to
// keep cursor's model from retrying the same built-in tool indefinitely.
// The model adapts and either answers from context or uses declared MCP tools.
const BUILTIN_TOOL_REJECT_REASON =
  "Tool not available in this environment. Use the MCP tools provided instead.";
const gunzipAsync = promisify(zlib.gunzip);

// Tool-commit directive — adapted from composer-api's TOOL_SYSTEM_DIRECTIVE.
// composer-2.5 otherwise narrates intent ("Checking the weather...") and ends
// the turn ~20% of the time instead of actually invoking a declared tool. This
// directive, prepended to the user text only when the request declares tools,
// tells the model to commit to the tool call rather than describe it as prose.
const TOOL_COMMIT_DIRECTIVE = [
  "You are serving an OpenAI-compatible API request and the client has provided executable tools.",
  "When a tool is needed to answer (real-time data, web/search lookups, file or project operations), you MUST issue the actual tool call. Do NOT describe what you are about to do as prose and then stop — call the tool.",
  "Answer directly only when no tool is needed.",
  "Do not emit duplicate tool calls: call each operation once, then continue after the tool result is returned.",
  "Never claim that tools are unavailable.",
].join("\n");

// NOTE: composer-api primes the model into "agent mode" with a fabricated
// prior switch_mode exchange (AGENT_MODE_PRIMER). On OmniRoute's native-tool
// agent endpoint that primer is counterproductive — it references a
// non-existent switch_mode tool and measurably LOWERED the tool-call rate in
// live A/B (56% vs 69%), so it is intentionally not ported.

function isRecordLike(v: unknown): v is Record<string, unknown> {
  return typeof v === "object" && v !== null;
}

/**
 * Translate OpenAI `tool_choice` into an extra directive line — cursor's agent
 * endpoint has no native equivalent. `"required"` forces some tool; a specific
 * `{type:"function", function:{name}}` forces that tool. `"auto"`/`"none"`/
 * absent add nothing here ("none" is handled by dropping tools entirely).
 * Ported from composer-api (directToolChoiceHint / tool_choice === "required").
 */
function toolChoiceDirectiveLine(toolChoice: unknown): string {
  if (toolChoice === "required") {
    return "\nYou MUST call at least one of the available tools now; do not answer without calling a tool.";
  }
  if (
    isRecordLike(toolChoice) &&
    toolChoice.type === "function" &&
    isRecordLike(toolChoice.function) &&
    typeof toolChoice.function.name === "string" &&
    toolChoice.function.name
  ) {
    return `\nYou MUST call the \`${toolChoice.function.name}\` tool now and not any other tool.`;
  }
  return "";
}

/**
 * Build an OUTPUT CONSTRAINTS block from OpenAI request params that cursor's
 * agent endpoint silently ignores (response_format / max_tokens / stop), so
 * they're surfaced to the model as prompt instructions instead. Ported from
 * composer-api (appendChatOptions / appendJsonConstraint / appendStopConstraint).
 * Returns "" when no constraints apply.
 */
function buildCursorOutputConstraints(body: {
  max_tokens?: unknown;
  max_completion_tokens?: unknown;
  stop?: unknown;
  response_format?: unknown;
}): string {
  const constraints: string[] = [];

  const rawMax = body.max_completion_tokens ?? body.max_tokens;
  const maxTokens = typeof rawMax === "number" && Number.isFinite(rawMax) ? Math.floor(rawMax) : 0;
  if (maxTokens > 0) {
    constraints.push(`Keep the answer within about ${maxTokens} output tokens.`);
  }

  const stop = body.stop;
  if (typeof stop === "string" && stop) {
    constraints.push(`Do not include any text at or after this stop sequence: ${stop}`);
  } else if (Array.isArray(stop) && stop.length) {
    constraints.push(`Stop before any of these sequences: ${stop.filter(Boolean).join(", ")}`);
  }

  const fmt = body.response_format;
  if (isRecordLike(fmt)) {
    if (fmt.type === "json_object") {
      constraints.push("Return a single valid JSON object and no surrounding prose or code fences.");
    } else if (fmt.type === "json_schema") {
      const js = isRecordLike(fmt.json_schema) ? fmt.json_schema.schema : fmt.schema;
      constraints.push(
        `Return only valid JSON (no prose or code fences) matching this schema: ${JSON.stringify(js ?? fmt)}`
      );
    }
  }

  return constraints.length
    ? `\n\nOUTPUT CONSTRAINTS:\n${constraints.map((c) => `- ${c}`).join("\n")}`
    : "";
}

/**
 * Build the ExecClientMessage frame that responds to a built-in tool request.
 * Returns null for the request_context handshake (caller handles separately
 * to inject MCP tools in Phase 3) and for exec_mcp (model is invoking a
 * declared MCP tool — Phase 5 surfaces this as an OpenAI tool_calls delta).
 */
function buildExecRejection(event: ExecServerEvent): Buffer | null {
  switch (event.kind) {
    case "exec_request_context":
    case "exec_mcp":
      return null;
    case "exec_read":
      return encodeExecReadRejected(
        event.execMsgId,
        event.execId,
        event.path,
        BUILTIN_TOOL_REJECT_REASON
      );
    case "exec_write":
      return encodeExecWriteRejected(
        event.execMsgId,
        event.execId,
        event.path,
        BUILTIN_TOOL_REJECT_REASON
      );
    case "exec_delete":
      return encodeExecDeleteRejected(
        event.execMsgId,
        event.execId,
        event.path,
        BUILTIN_TOOL_REJECT_REASON
      );
    case "exec_ls":
      return encodeExecLsRejected(
        event.execMsgId,
        event.execId,
        event.path,
        BUILTIN_TOOL_REJECT_REASON
      );
    case "exec_grep":
      return encodeExecGrepError(event.execMsgId, event.execId, BUILTIN_TOOL_REJECT_REASON);
    case "exec_diagnostics":
      // Diagnostics has no rejection variant — return an empty success.
      return encodeExecDiagnosticsResult(event.execMsgId, event.execId);
    case "exec_shell":
    case "exec_shell_stream":
      return encodeExecShellRejected(
        event.execMsgId,
        event.execId,
        event.command,
        event.workingDir,
        BUILTIN_TOOL_REJECT_REASON
      );
    case "exec_bg_shell":
      return encodeExecBackgroundShellSpawnRejected(
        event.execMsgId,
        event.execId,
        event.command,
        event.workingDir,
        BUILTIN_TOOL_REJECT_REASON
      );
    case "exec_fetch":
      return encodeExecFetchError(
        event.execMsgId,
        event.execId,
        event.url,
        BUILTIN_TOOL_REJECT_REASON
      );
    case "exec_write_shell_stdin":
      return encodeExecWriteShellStdinError(
        event.execMsgId,
        event.execId,
        BUILTIN_TOOL_REJECT_REASON
      );
  }
}

const CURSOR_AGENT_HOST = "agentn.global.api5.cursor.sh";
const CURSOR_AGENT_PATH = "/agent.v1.AgentService/Run";
const CURSOR_AGENT_URL = `https://${CURSOR_AGENT_HOST}${CURSOR_AGENT_PATH}`;

// Detect cloud environment (Edge runtime, Cloudflare Workers, etc.)
const isCloudEnv = () => {
  if (typeof caches !== "undefined" && typeof caches === "object") return true;
  if (typeof EdgeRuntime !== "undefined") return true;
  return false;
};

// Lazy import http2 (only in Node.js environment)
let http2: typeof import("http2") | null = null;
if (!isCloudEnv()) {
  try {
    http2 = await import("http2");
  } catch {
    http2 = null;
  }
}

// Phase 10: CURSOR_DEBUG=1 enables verbose streaming debug logs (decoded
// frame summaries, exec router dispatches, session lifecycle events).
// CURSOR_STREAM_DEBUG is kept as a backward-compatible alias.
const CURSOR_DEBUG = process.env.CURSOR_DEBUG === "1" || process.env.CURSOR_STREAM_DEBUG === "1";
const debugLog = (...args: unknown[]) => {
  if (CURSOR_DEBUG) console.log(...args);
};

// Phase 8: max wall-clock time before we give up on the upstream and abort
// the stream. Cursor's longest-observed plain chat takes ~90s; tool-using
// turns can be longer. Five minutes is generous but bounded. A malformed env
// value (NaN / non-positive) falls back to the default rather than breaking
// setTimeout.
const CURSOR_STREAM_TIMEOUT_MS = (() => {
  const parsed = parseInt(process.env.CURSOR_STREAM_TIMEOUT_MS || "300000", 10);
  return Number.isInteger(parsed) && parsed > 0 ? parsed : 300000;
})();

// Upper bound on a single Connect-RPC frame. The 4-byte length prefix can
// declare up to 4 GiB; a corrupt or hostile upstream could send a huge length
// that forces driveH2's rolling buffer to grow unbounded (OOM) while it waits
// for bytes that never arrive. Real cursor frames are well under 1 MiB
// (largest observed: a ~13 KB KV blob), so 16 MiB is a generous ceiling that
// turns the failure into a clean stream error instead of memory exhaustion.
const CURSOR_MAX_FRAME_BYTES = 16 * 1024 * 1024;

type CursorHttpResponse = {
  status: number;
  headers: Record<string, unknown>;
  body: Buffer;
};

function tryParseJsonError(payload: Buffer): { message: string; status: number } | null {
  if (payload.length < 2 || payload[0] !== 0x7b) return null;
  try {
    const text = payload.toString("utf8");
    if (!text.includes('"error"')) return null;
    const parsed = JSON.parse(text);
    const err = parsed?.error || {};
    const message =
      err?.details?.[0]?.debug?.details?.title ||
      err?.details?.[0]?.debug?.details?.detail ||
      err?.message ||
      text;
    const status =
      err?.code === "resource_exhausted" ? HTTP_STATUS.RATE_LIMITED : HTTP_STATUS.BAD_REQUEST;
    return { message, status };
  } catch {
    return null;
  }
}

// ─── Phase 4: streaming dispatch context ───────────────────────────────────
//
// One StreamCtx flows through a single execute() call. It owns the live
// SSE emission state (responseId, created timestamp, model id, role-chunk
// flag) plus aggregate state (totalText, tokenDelta) needed for the final
// usage chunk and JSON-mode aggregation. Phases 5 (tool calls) and 8
// (end-signal hardening) extend it.

export type StreamCtx = {
  responseId: string;
  created: number;
  model: string;
  emit: (chunk: string) => void;
  emittedRoleChunk: boolean;
  totalText: string;
  thinkingText: string;
  tokenDelta: number;
  // End-signal tracking (Phase 8 hardens this further).
  receivedText: boolean;
  kvAfterTextSeen: boolean;
  endReason: "turn_ended" | "kv_after_text" | "tool_calls" | "server_end" | null;
  // Mid-stream JSON error (rare; emitted once with the error code).
  midStreamError: { message: string; status: number } | null;
  // Phase 5: tool-call indexing for parallel calls. Each McpArgs gets a
  // monotonically-increasing index in the OpenAI delta. emittedToolCalls
  // tracks how many were emitted so finalizeSseStream picks the right
  // finish_reason ("tool_calls" vs "stop").
  emittedToolCallIndex: number;
  // Captured tool calls (for JSON-mode aggregation). Each entry maps to
  // one OpenAI tool_calls[] item.
  toolCalls: Array<{
    id: string;
    name: string;
    argumentsJson: string;
  }>;
  // Phase 6: maps OpenAI tool_call_id → cursor exec info, so a follow-up
  // role:"tool" message can be answered on the open h2 stream via
  // encodeExecMcpResult.
  pendingToolCalls: Map<string, { execMsgId: number; execId: string; toolName: string }>;
};

export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx {
  return {
    responseId: `chatcmpl-cursor-${Date.now()}`,
    created: Math.floor(Date.now() / 1000),
    model,
    emit,
    emittedRoleChunk: false,
    totalText: "",
    thinkingText: "",
    tokenDelta: 0,
    receivedText: false,
    kvAfterTextSeen: false,
    endReason: null,
    midStreamError: null,
    emittedToolCallIndex: 0,
    toolCalls: [],
    pendingToolCalls: new Map(),
  };
}

function emitChunk(ctx: StreamCtx, delta: object, finishReason: string | null = null) {
  const payload = {
    id: ctx.responseId,
    object: "chat.completion.chunk",
    created: ctx.created,
    model: ctx.model,
    choices: [{ index: 0, delta, finish_reason: finishReason }],
  };
  ctx.emit(`data: ${JSON.stringify(payload)}\n\n`);
}

export function buildCursorUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) {
  const promptTokens = estimateInputTokens(body);
  const completionTokens =
    ctx.tokenDelta > 0
      ? ctx.tokenDelta
      : estimateOutputTokens(ctx.totalText.length + ctx.thinkingText.length);
  const usage: Record<string, unknown> = {
    prompt_tokens: promptTokens,
    completion_tokens: completionTokens,
    total_tokens: promptTokens + completionTokens,
    estimated: true,
  };
  if (ctx.thinkingText.length > 0) {
    usage.completion_tokens_details = {
      reasoning_tokens: estimateOutputTokens(ctx.thinkingText.length),
    };
  }
  return addBufferToUsage(usage);
}

function emitUsage(ctx: StreamCtx, body: { messages?: ChatMessage[] }) {
  // Always emit a usage chunk on the success path — the OpenAI streaming
  // contract is that every completed response carries usage. buildCursorUsage
  // already degrades cleanly to prompt-only counts when the model produced no
  // text/thinking (e.g. an empty turn), so there's no need to skip it. The
  // mid-stream-error path in finalizeSseStream returns before calling this, so
  // errored responses still don't get a spurious usage chunk.
  const usage = buildCursorUsage(ctx, body);
  const payload = {
    id: ctx.responseId,
    object: "chat.completion.chunk",
    created: ctx.created,
    model: ctx.model,
    choices: [],
    usage,
  };
  ctx.emit(`data: ${JSON.stringify(payload)}\n\n`);
}

function emitDone(ctx: StreamCtx) {
  ctx.emit("data: [DONE]\n\n");
}

/**
 * Process one decoded Connect-RPC frame payload: dispatch ExecServerMessage
 * events (rejection / context ack / mcp_args), decode AgentServerMessage
 * interaction updates, and emit OpenAI SSE deltas for any text content.
 *
 * Returns true if an end-of-response signal was observed.
 *
 * The h2 `req` (used to write rejection acks back on the same stream) is
 * passed via opts so this function works for both the streaming h2 path
 * and the buffered fetch fallback (where opts.req is undefined).
 *
 * Mutates `ackedExecIds` so each exec_id is dispatched exactly once even
 * when the same payload is seen multiple times during incremental decoding.
 */
export function processFrame(
  payload: Buffer,
  ctx: StreamCtx,
  ackedExecIds: Set<string>,
  opts: {
    h2Req?: import("http2").ClientHttp2Stream;
    mcpTools?: McpToolDefinition[];
    blobStore?: Map<string, Buffer>;
  } = {}
): void {
  // 1. JSON error envelope (Connect-RPC style — usually status > 200).
  const jsonError = tryParseJsonError(payload);
  if (jsonError) {
    if (ctx.totalText.length === 0) {
      ctx.midStreamError = jsonError;
      ctx.endReason = "server_end";
    } else {
      // Already streamed content — terminate cleanly.
      ctx.endReason = "server_end";
    }
    return;
  }

  // 2a. KV server message: cursor requesting a blob (system prompt) or
  // saving an assistant turn. We reply on the same stream so the model
  // proceeds. The opaque request_metadata is echoed so cursor can match
  // request to response.
  const kvEvent = decodeKvServerEvent(payload);
  if (kvEvent && opts.h2Req) {
    if (kvEvent.kind === "kv_get_blob") {
      const hex = kvEvent.blobId.toString("hex");
      const blob = opts.blobStore?.get(hex) ?? Buffer.alloc(0);
      try {
        opts.h2Req.write(encodeKvGetBlobResult(kvEvent.kvId, blob, kvEvent.requestMetadata));
      } catch {}
    } else if (kvEvent.kind === "kv_set_blob") {
      if (opts.blobStore) {
        opts.blobStore.set(kvEvent.blobId.toString("hex"), kvEvent.blobData);
      }
      try {
        opts.h2Req.write(encodeKvSetBlobResult(kvEvent.kvId, kvEvent.requestMetadata));
      } catch {}
    }
  }

  // 2b. ExecServerMessage dispatch (request_context, built-in rejection, mcp).
  // Dedup by kind+execId+execMsgId — request_context and mcp_args both
  // arrive with empty execId in the current cursor schema, so a single
  // execId-only set would collapse them.
  const event = decodeExecServerEvent(payload);
  const dedupKey = event ? `${event.kind}:${event.execId}:${event.execMsgId}` : "";
  if (event && !ackedExecIds.has(dedupKey)) {
    ackedExecIds.add(dedupKey);
    if (event.kind === "exec_request_context") {
      if (opts.h2Req) {
        try {
          // Cursor receives tools via AgentRunRequest.mcp_tools (request body)
          // — sending them again in the request_context ack causes the
          // server to stall silently. Empty ack only.
          opts.h2Req.write(encodeRequestContextResponse(event.execMsgId, event.execId));
        } catch {}
      }
    } else if (event.kind === "exec_mcp") {
      // Phase 5: surface the model-invoked MCP tool as an OpenAI tool_calls
      // SSE delta. Two chunks are emitted per call: an init chunk with the
      // tool's id+name+empty args, then a chunk with the JSON-stringified
      // args. Parallel tool calls share one finish chunk (Phase 8 closes).
      if (!ctx.emittedRoleChunk) {
        emitChunk(ctx, { role: "assistant", content: "" });
        ctx.emittedRoleChunk = true;
      }
      const idx = ctx.emittedToolCallIndex++;
      const openAIToolCallId = generateToolCallId();
      const argumentsJson = JSON.stringify(event.args ?? {});
      emitChunk(ctx, {
        tool_calls: [
          {
            index: idx,
            id: openAIToolCallId,
            type: "function",
            function: { name: event.toolName, arguments: "" },
          },
        ],
      });
      emitChunk(ctx, {
        tool_calls: [
          {
            index: idx,
            function: { arguments: argumentsJson },
          },
        ],
      });
      ctx.toolCalls.push({
        id: openAIToolCallId,
        name: event.toolName,
        argumentsJson,
      });
      // Phase 6: remember the cursor exec ids so a follow-up role:"tool"
      // message can be replied with encodeExecMcpResult on the open h2 stream.
      ctx.pendingToolCalls.set(openAIToolCallId, {
        execMsgId: event.execMsgId,
        execId: event.execId,
        toolName: event.toolName,
      });
      // Cursor pauses after mcp_args waiting for the client to either send
      // a tool result via ExecMcpResult or close the stream. We mark
      // endReason now so driveH2 returns; the session manager keeps the h2
      // alive for the next OpenAI call (which arrives with role:"tool").
      ctx.endReason = "tool_calls";
    } else {
      const rejection = buildExecRejection(event);
      if (rejection && opts.h2Req) {
        try {
          opts.h2Req.write(rejection);
        } catch {}
      }
    }
  }

  // 3. Interaction update deltas → OpenAI SSE chunks.
  let deltas;
  try {
    deltas = decodeAgentServerMessage(payload);
  } catch (err) {
    debugLog("[cursor-agent] decode failed:", (err as Error).message);
    return;
  }
  for (const d of deltas) {
    if (d.kind === "text" && d.text) {
      if (!ctx.emittedRoleChunk) {
        emitChunk(ctx, { role: "assistant", content: "" });
        ctx.emittedRoleChunk = true;
      }
      ctx.totalText += d.text;
      ctx.receivedText = true;
      emitChunk(ctx, { content: d.text });
    } else if (d.kind === "thinking" && d.text) {
      if (!ctx.emittedRoleChunk) {
        emitChunk(ctx, { role: "assistant", content: "" });
        ctx.emittedRoleChunk = true;
      }
      ctx.thinkingText += d.text;
      ctx.receivedText = true;
      emitChunk(ctx, { reasoning_content: d.text });
    } else if (d.kind === "token_delta") {
      ctx.tokenDelta += d.tokens;
    } else if (d.kind === "turn_ended") {
      ctx.endReason = "turn_ended";
    } else if (d.kind === "tool_call_completed" && ctx.toolCalls.length > 0) {
      // Phase 6: model paused awaiting tool result. driveH2 returns but the
      // h2 stream stays open — the session manager keeps it alive for the
      // next OpenAI call (which will arrive with role:"tool" results).
      ctx.endReason = "tool_calls";
    } else if (d.kind === "kv_server_message" && ctx.receivedText) {
      // Cursor short-circuits turn_ended for plain chats — kv_server_message
      // after text means the model finished and the server is saving the
      // turn. Phase 8 keeps both signals as defense-in-depth.
      //
      // Safe vs tool calls: when the model invokes a tool, the exec_mcp event
      // always arrives at or before this kv checkpoint (verified across many
      // live composer-2.5 trials — a tool call never follows kv_after_text), so
      // endReason is already "tool_calls" by the time we get here. Ending on
      // kv_after_text therefore never truncates a pending tool call.
      ctx.kvAfterTextSeen = true;
      ctx.endReason = "kv_after_text";
    }
  }
}

export class CursorExecutor extends BaseExecutor {
  constructor() {
    super("cursor", PROVIDERS.cursor);
  }

  buildUrl() {
    return CURSOR_AGENT_URL;
  }

  buildHeaders(credentials) {
    const accessToken = credentials.accessToken;
    const ghostMode = credentials.providerSpecificData?.ghostMode !== false;
    const cleanToken = accessToken.includes("::") ? accessToken.split("::")[1] : accessToken;
    const requestId = crypto.randomUUID();
    const traceParent = `00-${crypto.randomBytes(16).toString("hex")}-${crypto.randomBytes(8).toString("hex")}-01`;

    // Mirrors cursor-agent's actual headers for agent.v1.AgentService/Run.
    // Notably: no x-cursor-checksum, no machineId, no x-amzn-trace-id.
    // Only advertise gzip (not brotli) — our Connect-RPC frame decoder
    // only handles gzip-compressed message bodies.
    return {
      authorization: `Bearer ${cleanToken}`,
      "backend-traceparent": traceParent,
      "connect-accept-encoding": "gzip",
      "connect-protocol-version": "1",
      "content-type": "application/connect+proto",
      traceparent: traceParent,
      "user-agent": "connect-es/1.6.1",
      "x-cursor-client-type": "cli",
      "x-cursor-client-version": `cli-${getCursorVersion()}`,
      "x-ghost-mode": ghostMode ? "true" : "false",
      "x-original-request-id": requestId,
      "x-request-id": requestId,
    };
  }

  /**
   * Build the request body and return it alongside the request-scoped
   * blobStore. cursor's models (auto, claude-*, gpt-*) don't reliably
   * follow system-role content delivered via the KV blob channel — even
   * though the blob is requested and our reply is accepted, the model
   * proceeds without applying the prompt.
   *
   * As a pragmatic workaround we prepend the system content into the
   * UserMessage text (the pre-Phase-7 behavior). The KV-blob handshake
   * machinery is still in place for any future schema where cursor honors
   * root_prompt_messages_json semantically — verified end-to-end with
   * wire-tap captures.
   */
  /**
   * Assemble the user text + resolved tools shared by the sync (transformRequest)
   * and async (buildRequest) request builders. Image resolution is intentionally
   * NOT done here — it's async and only the cold-path buildRequest needs it.
   */
  private assembleTextAndTools(body: {
    messages?: ChatMessage[];
    tools?: unknown;
    tool_choice?: unknown;
    max_tokens?: unknown;
    max_completion_tokens?: unknown;
    stop?: unknown;
    response_format?: unknown;
  }): { userText: string; tools: OpenAITool[] | undefined } {
    const messages: ChatMessage[] = body.messages || [];
    const declaredTools: OpenAITool[] | undefined = Array.isArray(body.tools)
      ? (body.tools as OpenAITool[])
      : undefined;
    // tool_choice:"none" means "do not call any tool" — honor it by advertising
    // no tools at all (matches OpenAI semantics; composer-api does the same).
    const tools = body.tool_choice === "none" ? undefined : declaredTools;

    // flattenMessages prepends any role:"system" messages into the user
    // text (proven path that cursor's models honor). Image parts in the content
    // are ignored here (they carry no text) and resolved separately.
    let userText = flattenMessages(messages);

    // When the request declares tools, prepend the tool-commit directive so
    // composer-2.5 reliably invokes them instead of narrating intent and
    // stopping. Measured live: tool-call rate ~53% → ~88% with the directive.
    // tool_choice "required"/specific-function add a forcing line on top.
    // Default-on; set CURSOR_TOOL_DIRECTIVE=0 to opt out. See TOOL_COMMIT_DIRECTIVE.
    if (tools && tools.length > 0 && process.env.CURSOR_TOOL_DIRECTIVE !== "0") {
      userText = `${TOOL_COMMIT_DIRECTIVE}${toolChoiceDirectiveLine(body.tool_choice)}\n\n${userText}`;
    }

    // Surface OpenAI output params cursor ignores natively (response_format /
    // max_tokens / stop) as trailing prompt constraints.
    userText += buildCursorOutputConstraints(body);

    return { userText, tools };
  }

  /**
   * Resolve any OpenAI image_url parts in the request's user messages into
   * inlined cursor images. Returns undefined when the request carries no
   * images (keeps the request byte-identical to the text-only path). Throws
   * CursorImageError on invalid / oversized / SSRF-blocked input.
   */
  private async resolveRequestImages(body: {
    messages?: ChatMessage[];
  }): Promise<EncodedImage[] | undefined> {
    const messages: ChatMessage[] = body.messages || [];
    const imageUrls: string[] = [];
    for (const m of messages) {
      // Images only ride on user turns (the openai-to-cursor translator keeps
      // them only there). System/assistant/tool turns carry no vision input.
      if (m.role === "user") {
        for (const u of extractImageUrls(m.content)) imageUrls.push(u);
      }
    }
    if (imageUrls.length === 0) return undefined;
    return resolveCursorImages(imageUrls);
  }

  private async buildRequest(
    model: string,
    body: {
      messages?: ChatMessage[];
      tools?: unknown;
      tool_choice?: unknown;
      conversation_id?: string;
      max_tokens?: unknown;
      max_completion_tokens?: unknown;
      stop?: unknown;
      response_format?: unknown;
    }
  ): Promise<{ body: Uint8Array; blobStore: Map<string, Buffer> }> {
    const { userText, tools } = this.assembleTextAndTools(body);
    const images = await this.resolveRequestImages(body);

    const blobStore = new Map<string, Buffer>();
    const requestBody = buildAgentRequestBody({
      modelId: model,
      userText,
      conversationId: body.conversation_id,
      tools,
      blobStore,
      images,
    });
    return { body: requestBody, blobStore };
  }

  transformRequest(model, body, _stream, _credentials) {
    // Sync interface method (not used by cursor's own execute() path, which
    // uses the async buildRequest). Text-only — image resolution is async.
    const { userText, tools } = this.assembleTextAndTools(body);
    const blobStore = new Map<string, Buffer>();
    return buildAgentRequestBody({
      modelId: model,
      userText,
      conversationId: body.conversation_id,
      tools,
      blobStore,
    });
  }

  // ─── h2 lifecycle: open + drive (Phase 4 streaming refactor) ─────────────
  //
  // openH2 establishes the bidirectional stream and waits for the response
  // headers (so we can decide whether to commit to a streaming SSE Response
  // or return an error). driveH2 then consumes data events incrementally,
  // dispatching frames through processFrame so SSE chunks land on the
  // ReadableStream controller as the upstream produces them.
  //
  // The fetch fallback (cloud envs without http2) preserves the legacy
  // buffer-then-decode behavior — Connect-RPC bidirectional ack-on-same-stream
  // can't run over a one-shot fetch anyway.

  private async openH2(
    url: string,
    headers: Record<string, string>,
    body: Uint8Array,
    signal?: AbortSignal
  ): Promise<{
    status: number;
    headers: Record<string, string | number>;
    client: import("http2").ClientHttp2Session;
    req: import("http2").ClientHttp2Stream;
    initialBytes: Buffer;
    consumeError: () => Promise<Buffer>;
  }> {
    if (!http2) throw new Error("http2 module not available");

    return new Promise((resolve, reject) => {
      const urlObj = new URL(url);
      const client = http2!.connect(`https://${urlObj.host}`);
      const earlyChunks: Buffer[] = [];
      let resolved = false;

      client.on("error", (err) => {
        if (!resolved) reject(err);
      });

      const req = client.request({
        ":method": "POST",
        ":path": urlObj.pathname,
        ":authority": urlObj.host,
        ":scheme": "https",
        ...headers,
      });

      const onAbort = () => {
        try {
          req.close();
          client.close();
        } catch {}
        if (!resolved) {
          resolved = true;
          reject(new Error("aborted"));
        }
      };
      if (signal) signal.addEventListener("abort", onAbort);

      req.on("response", (h) => {
        if (resolved) return;
        resolved = true;
        const status = Number(h[":status"] ?? HTTP_STATUS.SERVER_ERROR);
        // For non-200 statuses, drain the remaining body for an error message.
        // The caller calls consumeError() to await the full body.
        const consumeError = () =>
          new Promise<Buffer>((res) => {
            const out = [...earlyChunks];
            req.on("data", (c) => out.push(Buffer.from(c)));
            req.on("end", () => {
              try {
                req.close();
                client.close();
              } catch {}
              if (signal) signal.removeEventListener("abort", onAbort);
              res(Buffer.concat(out));
            });
            req.on("error", () => {
              try {
                req.close();
                client.close();
              } catch {}
              if (signal) signal.removeEventListener("abort", onAbort);
              res(Buffer.concat(out));
            });
          });
        resolve({
          status,
          headers: h as Record<string, string | number>,
          client,
          req,
          initialBytes: Buffer.concat(earlyChunks),
          consumeError,
        });
      });

      // Buffer any data that arrives before the response event resolves.
      // (In practice the response event fires first, but this guards against
      // implementation differences in node:http2.)
      req.on("data", (chunk) => {
        if (!resolved) earlyChunks.push(Buffer.from(chunk));
      });

      req.on("error", (err) => {
        if (!resolved) {
          resolved = true;
          if (signal) signal.removeEventListener("abort", onAbort);
          reject(err);
        }
      });

      // Bidirectional streaming: write the init message but DO NOT send
      // END_STREAM — cursor's server stops responding once we close our side.
      // Guard the write like every h2Req.write in processFrame: a synchronous
      // failure here (e.g. stream already torn down) would otherwise leave the
      // request hung until the safety timeout instead of failing fast.
      try {
        req.write(body);
      } catch (err) {
        if (!resolved) {
          resolved = true;
          if (signal) signal.removeEventListener("abort", onAbort);
          try {
            req.close();
            client.close();
          } catch {}
          reject(err instanceof Error ? err : new Error(String(err)));
        }
      }
    });
  }

  /**
   * Drive an open h2 stream to completion. processFrame populates ctx as
   * each Connect-RPC frame is decoded; the loop closes when ctx.endReason
   * is set (turn_ended, kv_after_text, server_end) or the stream errors.
   *
   * Phase 8 will add a max-stream safety timeout here.
   */
  private driveH2(
    h2: {
      req: import("http2").ClientHttp2Stream;
      client: import("http2").ClientHttp2Session;
      initialBytes: Buffer;
    },
    ctx: StreamCtx,
    mcpTools: McpToolDefinition[] | undefined,
    blobStore: Map<string, Buffer> | undefined,
    signal?: AbortSignal
  ): Promise<void> {
    const ackedExecIds = new Set<string>();
    // Rolling buffer: chunks arrive on `data`, get appended, and consumed
    // frames are sliced off so we don't re-scan + re-concat on every event
    // (avoids O(N²) for long-running streams).
    let buf: Buffer = h2.initialBytes.length > 0 ? h2.initialBytes : Buffer.alloc(0);

    return new Promise((resolve, reject) => {
      let scanning = false;
      let settled = false;
      // Phase 8: safety timeout. If neither turn_ended, kv_after_text, nor
      // server-end fires within CURSOR_STREAM_TIMEOUT_MS, abort the stream
      // so a stuck upstream doesn't keep the response open indefinitely.
      const safetyTimer = setTimeout(() => {
        if (ctx.endReason) return;
        debugLog("[cursor-agent] stream safety timeout fired");
        teardown();
        reject(new Error("cursor-agent stream timed out"));
      }, CURSOR_STREAM_TIMEOUT_MS);

      const onData = (chunk: Buffer) => {
        if (CURSOR_DEBUG && process.env.CURSOR_DUMP_FILE) {
          fs.appendFileSync(process.env.CURSOR_DUMP_FILE, chunk);
        }
        buf = buf.length === 0 ? Buffer.from(chunk) : Buffer.concat([buf, chunk]);
        void tryScan();
      };
      const onEnd = () => {
        if (settled) return;
        settled = true;
        if (!ctx.endReason) ctx.endReason = "server_end";
        detachListeners();
        resolve();
      };
      const onErr = (err: Error) => {
        if (settled) return;
        settled = true;
        teardown();
        reject(err);
      };
      const onAbort = () => {
        if (settled) return;
        settled = true;
        teardown();
        reject(new Error("aborted"));
      };

      // detachListeners removes data/end/error/abort handlers and clears the
      // safety timer. Called on successful resolve when the caller keeps the
      // h2 alive (Phase 6 session reuse).
      const detachListeners = () => {
        clearTimeout(safetyTimer);
        h2.req.off("data", onData);
        h2.req.off("end", onEnd);
        h2.req.off("error", onErr);
        if (signal) signal.removeEventListener("abort", onAbort);
      };
      // teardown additionally closes the h2 stream. Used on error / abort /
      // safety-timeout — the connection isn't worth keeping at that point.
      const teardown = () => {
        detachListeners();
        try {
          h2.req.close();
          h2.client.close();
        } catch {}
      };

      if (signal) signal.addEventListener("abort", onAbort);

      const hasCompleteFrame = () => buf.length >= 5 && buf.length >= 5 + buf.readUInt32BE(1);

      const tryScan = async () => {
        if (scanning || settled) return;
        scanning = true;
        try {
          let pos = 0;
          while (!settled && pos + 5 <= buf.length) {
            const length = buf.readUInt32BE(pos + 1);
            if (length > CURSOR_MAX_FRAME_BYTES) {
              // Refuse to buffer an implausibly large frame — fail fast instead
              // of letting the rolling buffer grow toward OOM.
              settled = true;
              teardown();
              reject(new Error(`cursor-agent frame too large (${length} bytes)`));
              return;
            }
            if (pos + 5 + length > buf.length) break; // partial frame; wait
            const flag = buf[pos];
            const raw = buf.subarray(pos + 5, pos + 5 + length);
            // Per-frame error isolation: if gunzip or processFrame throws on
            // one frame, log and skip past it instead of getting stuck on
            // the same offset and hanging until the safety timer fires.
            try {
              const payload = flag & 0x1 ? await gunzipAsync(raw) : raw;
              if (settled) return;
              processFrame(payload, ctx, ackedExecIds, { h2Req: h2.req, mcpTools, blobStore });
            } catch (err) {
              debugLog(
                "[cursor-agent] frame decode failed at pos",
                pos,
                ":",
                (err as Error).message
              );
            }
            pos += 5 + length;
            if (ctx.endReason) {
              buf = buf.subarray(pos);
              settled = true;
              detachListeners();
              resolve();
              return;
            }
          }
          // Splice off processed bytes so the buffer stays bounded.
          if (pos > 0) buf = buf.subarray(pos);
        } finally {
          scanning = false;
        }

        if (!settled && hasCompleteFrame()) {
          void tryScan();
        }
      };

      h2.req.on("data", onData);
      h2.req.on("end", onEnd);
      h2.req.on("error", onErr);

      // Process any bytes already buffered from openH2.
      void tryScan();
    });
  }

  async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders }) {
    const url = this.buildUrl();
    const headers = this.buildHeaders(credentials);
    mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);

    const messages: ChatMessage[] = body.messages || [];
    const conversationId: string =
      typeof body.conversation_id === "string" && body.conversation_id
        ? body.conversation_id
        : crypto.randomUUID();
    const lastMessage = messages[messages.length - 1];
    const isToolFollowUp = lastMessage?.role === "tool";

    // Tools embedded in the RequestContext ack throughout the turn —
    // synced with mcp_tools in the encoded request body.
    const mcpTools: McpToolDefinition[] | undefined = Array.isArray(body.tools)
      ? openAIToolsToMcpDefs(body.tools as OpenAITool[])
      : undefined;

    // Sanitize error messages: strip stack traces and absolute paths to
    // prevent information exposure. Shared helper in utils/error.ts.
    const buildErrorResponse = (status: number, message: string, type = "invalid_request_error") =>
      new Response(
        JSON.stringify({ error: { message: sanitizeErrorMessage(message), type, code: "" } }),
        { status, headers: { "Content-Type": "application/json" } }
      );

    // Cursor's agent.v1.AgentService/Run is a bidirectional Connect-RPC:
    // request_context, KV blob lookups, and exec rejections must be
    // written back on the same h2 stream while the response is still
    // being read. One-shot fetch can't do that, so cloud/edge runtimes
    // without node:http2 cannot drive cursor at all — fail fast with a
    // clear error rather than silently producing incomplete output.
    if (!http2) {
      return {
        response: buildErrorResponse(
          501,
          "Cursor provider requires Node.js http2, which is unavailable in this runtime (Edge / Cloudflare Workers / similar). Run OmniRoute on a Node.js runtime to use cursor.",
          "unsupported_runtime"
        ),
        url,
        headers,
        transformedBody: body,
      };
    }

    // ── h2 path with inline session manager (Phase 6) ──
    //
    // 1. If this is a tool-result follow-up (last message role:"tool") AND
    //    we have an alive session for the conversation, send the tool
    //    result on the existing h2 stream (inline resume).
    // 2. Otherwise, open a fresh h2 stream, send a new RunRequest, and
    //    register it as a session.
    //
    // Cold-resume fallback (acquire returns undefined, or sendToolResult
    // doesn't match): always lands on path #2, which now flattens the full
    // history (including role:"tool" messages) into UserText via
    // flattenMessages.

    type H2Like = {
      req: import("http2").ClientHttp2Stream;
      client: import("http2").ClientHttp2Session;
      initialBytes: Buffer;
    };

    let session: CursorSession | undefined;
    let h2: H2Like;
    let blobStore: Map<string, Buffer>;

    if (isToolFollowUp) {
      session = cursorSessionManager.acquire(conversationId);
    }

    if (session) {
      // Inline resume: send ExecMcpResult only for tool messages whose
      // tool_call_id is currently pending in this session. Older tool
      // messages from prior turns are already consumed by cursor and
      // sit in the request history harmlessly — sending them again
      // would either be a no-op or wedge the session, so we skip.
      // We require at least one match so we don't reuse the session
      // for a request that has no relevant tool results.
      blobStore = session.blobStore;
      let matched = 0;
      let hadFailure = false;
      for (const msg of messages) {
        if (msg.role !== "tool") continue;
        const id = msg.tool_call_id ?? "";
        if (!session.pendingToolCalls.has(id)) continue;
        const content = typeof msg.content === "string" ? msg.content : "";
        if (cursorSessionManager.sendToolResult(session, id, content, false)) {
          matched++;
        } else {
          hadFailure = true;
          break;
        }
      }
      if (matched === 0 || hadFailure) {
        cursorSessionManager.close(session);
        session = undefined;
      } else {
        h2 = {
          client: session.h2Client,
          req: session.h2Req,
          initialBytes: Buffer.alloc(0),
        };
      }
    }

    if (!session) {
      // Cold path: open fresh h2 stream with the full message history
      // flattened into UserText (Phase 6 flattenMessages handles role:"tool"
      // and assistant.tool_calls). buildRequest also resolves any image_url
      // parts (base64 / remote) into inlined cursor images.
      let built;
      try {
        built = await this.buildRequest(model, body);
      } catch (err) {
        // Image resolution failures (invalid / oversized / SSRF-blocked) are
        // client errors — return a sanitized 400 rather than a 500.
        if (err instanceof CursorImageError) {
          return {
            response: buildErrorResponse(err.status, err.message, "invalid_request_error"),
            url,
            headers,
            transformedBody: body,
          };
        }
        const message = err instanceof Error ? err.message : String(err);
        return {
          response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"),
          url,
          headers,
          transformedBody: body,
        };
      }
      blobStore = built.blobStore;
      let opened;
      try {
        opened = await this.openH2(url, headers, built.body, signal);
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return {
          response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"),
          url,
          headers,
          transformedBody: body,
        };
      }
      if (opened.status !== 200) {
        const errBuf = await opened.consumeError();
        const errText = errBuf.toString("utf8") || "Unknown error";
        return {
          response: buildErrorResponse(opened.status, `[${opened.status}]: ${errText}`),
          url,
          headers,
          transformedBody: body,
        };
      }
      h2 = opened;
      session = cursorSessionManager.open(conversationId, opened.client, opened.req, blobStore);
    }

    // Closure to share the post-drive lifecycle between stream/non-stream paths.
    const sessionToUse = session;
    const finishLifecycle = (ctx: StreamCtx, errored: boolean) => {
      // Persist any new pendingToolCalls from this turn into the session.
      for (const [id, info] of ctx.pendingToolCalls) {
        sessionToUse.pendingToolCalls.set(id, info);
      }
      if (errored || ctx.endReason !== "tool_calls") {
        cursorSessionManager.close(sessionToUse);
      } else {
        cursorSessionManager.release(sessionToUse, "awaiting_tool_result");
      }
    };

    // Stream mode: ReadableStream that emits SSE chunks as they're decoded.
    if (stream !== false) {
      const enc = new TextEncoder();
      const sseStream = new ReadableStream(
        {
          start: async (controller) => {
            const ctx = newStreamCtx(model, (s) => controller.enqueue(enc.encode(s)));
            try {
              await this.driveH2(h2, ctx, mcpTools, blobStore, signal);
              this.finalizeSseStream(ctx, body);
              finishLifecycle(ctx, false);
              controller.close();
            } catch (err) {
              finishLifecycle(ctx, true);
              controller.error(err);
            }
          },
        },
        { highWaterMark: 16384 }
      );
      return {
        response: new Response(sseStream, {
          status: 200,
          headers: {
            "Content-Type": "text/event-stream",
            "Cache-Control": "no-cache",
            Connection: "keep-alive",
          },
        }),
        url,
        headers,
        transformedBody: body,
      };
    }

    // Non-streaming: drive to completion, return chat.completion JSON.
    const ctx = newStreamCtx(model, () => {});
    try {
      await this.driveH2(h2, ctx, mcpTools, blobStore, signal);
    } catch (err) {
      finishLifecycle(ctx, true);
      const message = err instanceof Error ? err.message : String(err);
      return {
        response: buildErrorResponse(HTTP_STATUS.SERVER_ERROR, message, "connection_error"),
        url,
        headers,
        transformedBody: body,
      };
    }
    finishLifecycle(ctx, false);
    return {
      response: this.buildResponseFromCtx(ctx, body),
      url,
      headers,
      transformedBody: body,
    };
  }

  /**
   * Emit the trailing SSE chunks (finish + usage + DONE) onto an already-open
   * stream. Called once driveH2 returns and ctx.endReason is set. The
   * mid-stream-error path emits an error chunk instead.
   */
  private finalizeSseStream(ctx: StreamCtx, body: { messages?: ChatMessage[] }) {
    if (ctx.midStreamError && ctx.totalText.length === 0) {
      const payload = {
        id: ctx.responseId,
        object: "chat.completion.chunk",
        created: ctx.created,
        model: ctx.model,
        choices: [],
        error: {
          message: ctx.midStreamError.message,
          type:
            ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED
              ? "rate_limit_error"
              : "api_error",
        },
      };
      ctx.emit(`data: ${JSON.stringify(payload)}\n\n`);
      ctx.emit("data: [DONE]\n\n");
      return;
    }
    if (!ctx.emittedRoleChunk) {
      // Edge case: empty response. Emit a role chunk so clients see at least
      // one delta before finish.
      emitChunk(ctx, { role: "assistant", content: "" });
    }
    // OpenAI finish_reason: "tool_calls" if the model invoked any declared
    // tool, else "stop". A turn with mixed text + tool_calls finishes with
    // "tool_calls" (the tool calls are the actionable signal for the client).
    const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop";
    emitChunk(ctx, {}, finishReason);
    emitUsage(ctx, body);
    emitDone(ctx);
  }

  /**
   * Build a non-streaming chat.completion JSON Response from a fully-driven
   * StreamCtx. The streaming path emits chunks live via finalizeSseStream
   * and never calls this method.
   */
  private buildResponseFromCtx(ctx: StreamCtx, body: { messages?: ChatMessage[] }): Response {
    if (ctx.midStreamError && ctx.totalText.length === 0) {
      return new Response(
        JSON.stringify({
          error: {
            message: ctx.midStreamError.message,
            type:
              ctx.midStreamError.status === HTTP_STATUS.RATE_LIMITED
                ? "rate_limit_error"
                : "api_error",
          },
        }),
        {
          status: ctx.midStreamError.status,
          headers: { "Content-Type": "application/json" },
        }
      );
    }

    // Non-streaming: chat.completion shape. Include tool_calls in the
    // assistant message when the model invoked any (Phase 5).
    const usage = buildCursorUsage(ctx, body);
    const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop";
    const message: {
      role: "assistant";
      content: string | null;
      reasoning_content?: string;
      tool_calls?: Array<{
        id: string;
        type: "function";
        function: { name: string; arguments: string };
      }>;
    } = {
      role: "assistant",
      content: ctx.totalText.length > 0 ? ctx.totalText : null,
    };
    if (ctx.thinkingText.length > 0) {
      message.reasoning_content = ctx.thinkingText;
    }
    if (ctx.toolCalls.length > 0) {
      message.tool_calls = ctx.toolCalls.map((tc) => ({
        id: tc.id,
        type: "function",
        function: { name: tc.name, arguments: tc.argumentsJson },
      }));
    }
    return new Response(
      JSON.stringify({
        id: ctx.responseId,
        object: "chat.completion",
        created: ctx.created,
        model: ctx.model,
        choices: [
          {
            index: 0,
            message,
            finish_reason: finishReason,
          },
        ],
        usage,
      }),
      { status: 200, headers: { "Content-Type": "application/json" } }
    );
  }

  async refreshCredentials() {
    return null;
  }
}

export default CursorExecutor;
