import http2 from "node:http2"; import { type JsonValue } from "@bufbuild/protobuf"; import type { McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb"; import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorExecPairing, CursorTodoSyncHandler, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage } from "../types.js"; import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingEnvelopeId, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; export declare const CURSOR_API_URL = "https://api2.cursor.sh"; export declare const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b"; export interface CursorOptions extends StreamOptions { customSystemPrompt?: string; conversationId?: string; execHandlers?: CursorExecHandlers; onToolResult?: CursorToolResultHandler; } /** * Maps an opaque HTTP/2 negotiation failure into an actionable error. * * bun only opens an HTTP/2 session when TLS-ALPN negotiates `h2`. Behind a * TLS-intercepting proxy that strips ALPN (e.g. Zscaler), the handshake yields * no `h2` protocol and bun throws `ERR_HTTP2_ERROR: h2 is not supported`. The * Cursor run RPC is HTTP/2-only (the ALB rejects HTTP/1.1 with 464), so there * is no h1 fallback the way model discovery has one — the run simply cannot * proceed. Replace the opaque message with one that names the cause and points * at the `providers.cursor.baseUrl` workaround. * * Non-ALPN errors pass through untouched. */ export declare function mapH2TransportError(error: unknown, baseUrl: string): unknown; export declare const streamCursor: StreamFunction<"cursor-agent">; export type ToolCallState = ToolCall & { [kStreamingBlockIndex]: number; [kStreamingPartialJson]?: string; [kStreamingLastParseLen]?: number; [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec" | "connect-scm"; [kStreamingEnvelopeId]?: string; [kCursorExecResolved]?: true; }; export interface BlockState { currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number; }) | null; currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number; }) | null; currentToolCall: ToolCallState | null; /** * Open streamed tool-call blocks, keyed by the interaction envelope's * `call_id`. * * Cursor interleaves calls: two `toolCallStarted` frames can arrive before * either completes. A single "current" slot would let the second overwrite * the first, orphaning a block that nothing then settles. Every keyed block * stays reachable until its own completion, and `currentToolCall` remains * only as the fallback for frames that carry no `call_id`. */ openToolCalls: Map; /** MCP call IDs synthesized from exec frames before their redundant streamed block arrives. */ resolvedMcpToolCallIds: Set; firstTokenTime: number | undefined; setTextBlock: (b: (TextContent & { [kStreamingBlockIndex]: number; }) | null) => void; setThinkingBlock: (b: (ThinkingContent & { [kStreamingBlockIndex]: number; }) | null) => void; setToolCall: (t: ToolCallState | null) => void; setFirstTokenTime: () => void; /** Mirror a server-confirmed todo snapshot into local session state. */ onTodoSnapshot?: CursorTodoSyncHandler; /** * Persist a paired `toolResult` for a server-resolved call. Native todo calls * never travel the exec channel, so without this the resolved block has no * matching result and every transcript rebuild strips it as dangling. */ onToolResult?: CursorToolResultHandler; } export interface UsageState { sawTokenDelta: boolean; } /** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */ export declare function handleServerMessage(msg: AgentServerMessage, output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, blobStore: Map, h2Request: http2.ClientHttp2Stream, execHandlers: CursorExecHandlers | undefined, onToolResult: CursorToolResultHandler | undefined, usageState: UsageState, requestContextTools: McpToolDefinition[], onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void): Promise; /** * Exported for tests: verifies handler is invoked with correct `this` when passed as bound. * * Every exit pairs a `toolResult`. The synthesized block was already marked * `kCursorExecResolved` before this runs (`synthesizeCursorExecToolCall`), so * `agent-loop.ts` emits no placeholder for it: a path that returns without a * result leaves the call unpaired and `buildSessionContext` strips the whole * interaction on replay. The three result-less paths — no handler installed, a * handler that produced nothing, and a thrown handler — therefore synthesize * one from the same text the server sees in `execResult`. * * `pairing` is required so a new callsite cannot silently recreate the orphan, * and nullable for the one caller whose block is NOT pre-resolved: MCP without * an `mcp` handler, which `agent-loop.ts` runs locally and pairs itself. */ export declare function resolveExecHandler(args: TArgs, handler: ((args: TArgs) => Promise>) | undefined, onToolResult: CursorToolResultHandler | undefined, buildFromToolResult: (toolResult: ToolResultMessage) => TResult, buildRejected: (reason: string) => TResult, buildError: (error: string) => TResult, pairing: CursorExecPairing | null): Promise<{ execResult: TResult; toolResult?: ToolResultMessage; }>; /** * Reject a Cursor exec-channel `grepArgs` frame whose `pattern` is empty or * whitespace-only. Returns an actionable error message when the pattern is * unusable (with a `glob`-aware hint when the model likely meant to list * files), or `null` when the pattern is valid and grep should run. * * Exported for tests. Cursor's model sometimes sends `pattern=""` together * with a non-empty `glob`, expecting grep to enumerate matching files; the * downstream coding-agent `grep` tool rejects that with a bare "Pattern must * not be empty", which the TUI renders as `?` in the tool preview (issue * #4574). Handling it at the Cursor exec dispatch keeps the synthesized * `toolCall` block off the persisted assistant message and gives the model a * specific recovery hint. */ export declare function emptyGrepPatternRejection(pattern: string | undefined, glob: string | undefined): string | null; /** * Retain a freshly opened streamed tool-call block. * * Keyed by the interaction envelope's `call_id`, which is the only key every * `ToolCall*Update` for that call shares. The block's own `id` is deliberately * not the key: MCP, Pi and connect-SCM blocks are filed under the id carried * inside the call's `args`, because that is what the exec channel pairs its * result under and what the transcript files the visible block under. * * `currentToolCall` is still set, as the fallback for frames that carry no * `call_id` (proto3-optional, and unset on what older builds send). */ /** * Close every tool-call block still open when the stream ends. * * Not just the last one started: with interleaved calls several can be open at * once, and an unclosed block leaves its live card animating and its call * unpaired. * * Only blocks fed by a streamed argument buffer get reparsed. Todo, * connect-SCM and MCP-settled frames arrive with complete `arguments` and * never set the partial buffer; `parseStreamingJson(undefined)` returns `{}`, * so reparsing unconditionally would erase the arguments of every such block * caught open by a truncated stream. * * Server-owned blocks are also paired here. `connect-scm` and `todo` are * stamped {@link kCursorExecResolved} the moment they open, so `agent-loop.ts` * synthesizes no placeholder for them and only their `toolCallCompleted` frame * pairs a result. A transport that closes before that frame would leave the * call unpaired, and `buildSessionContext` strips a dangling call from every * rebuilt transcript — the interaction disappears. An interrupted result is * emitted instead. * * MCP blocks are excluded even when resolved: the exec dispatch that marked * them owns their result, and `drainInFlightDispatches` awaits it before this * runs, so pairing here would duplicate one against the same `toolCallId`. */ export declare function flushOpenToolCalls(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState): void; /** * Merge the decoded completion-frame `McpArgs` map into the args assembled * from streamed `args_text_delta` snapshots. * * The completion frame is authoritative for the scalars it carries — but it * can omit oversized parameters entirely and can downgrade a structured value * to its raw string fallback when `decodeMcpArgValue` cannot parse it as * JSON. Overwriting the streamed args wholesale therefore loses data (e.g. * the task tool's `tasks` array on multi-subagent dispatches, issue #2615). * * Rules per key: * - completion key absent → keep the streamed value. * - completion is a string while the streamed value is structured (object or * array) → keep the streamed value (the completion frame downgraded it). * - otherwise → completion wins. */ export declare function mergeCursorMcpToolCallArgs(streamed: Record | undefined, completion: Record | undefined): Record; /** * Synthesize a completed `toolCall` content block for a Cursor exec-channel * native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`) * or for an MCP exec frame whose corresponding interaction block is absent. * * Args arrive complete on the exec message, so the block opens and closes in * one step — no partial-JSON streaming path. Without this the persisted * assistant message carries only text/thinking blocks, and on replay the * following `toolResult` messages have no matching `toolCall.id` in * `renderSessionContext`, so they render beneath the final answer or disappear. * * The block is stamped with {@link kCursorExecResolved} so the shared * `agent-loop.ts` execution pass skips it — Cursor's server-driven exec * channel already ran the tool via the bridge and buffered the result, so * treating this block as runnable would re-execute the same side-effecting * tool a second time. * * Exported for tests to exercise ordering with adjacent text/thinking blocks. */ export declare function synthesizeCursorExecToolCall(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, toolCallId: string, toolName: string, args: Record): void; /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */ export declare function processInteractionUpdate(update: any, output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, usageState: UsageState): void; export declare function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[]; /** * Build `ConversationStateStructure.rootPromptMessagesJson` blob IDs for the * system prompt plus prior conversation history, as JSON blobs matching * Cursor's internal Vercel-AI-SDK-shaped message format. * * Cursor's server uses `rootPromptMessagesJson` (not `turns[]`) to build the * actual model prompt. `turns[]` is UI/display metadata. Without populating * this field, multi-turn conversations lose prior context — the model sees * only an empty placeholder where historical user turns should be. * The active user message is excluded because it is sent in the action. */ /** * Build one Cursor system-message JSON blob per ordered system prompt. Emitting separate blobs * (rather than a single `\n\n`-joined string) lets Cursor's blob cache hit independently per * entry: changing only the last prompt does not invalidate earlier blob ids, so the prefix * up to the changed prompt remains cached on the server side. * * When no system prompts are provided, returns a single default greeting so we never emit * an empty `rootPromptMessagesJson` head. */ export declare function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined): string[]; /** Exported for tests: decodes Cursor history blobs built from conversation messages. */ export declare function buildCursorHistoryForTest(messages: Message[], activeUserMessageIndex?: number, targetModelId?: string): { rootPromptMessagesJson: unknown[]; turnUserMessagesJson: JsonValue[]; turnStepMessagesJson: JsonValue[][]; };