import type { CursorRule, McpToolDefinition } from "@oh-my-pi/pi-catalog/discovery/cursor-proto"; import { CURSOR_CLIENT_VERSION } from "@oh-my-pi/pi-catalog/wire/cursor"; import { type AgentServerMessage, type ConversationStateStructure } from "@oh-my-pi/pi-catalog/discovery/cursor-proto"; import { type JsonValue } from "@oh-my-pi/pi-catalog/discovery/protobuf"; import type { AssistantMessage, Context, CursorExecHandlerResult, CursorExecHandlers, CursorExecPairing, CursorTodoSyncHandler, CursorToolResultHandler, Message, Model, 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 { CURSOR_CLIENT_VERSION }; export interface CursorOptions extends StreamOptions { customSystemPrompt?: string; conversationId?: string; execHandlers?: CursorExecHandlers; onToolResult?: CursorToolResultHandler; /** Treat unhandled MCP calls as accepted handoffs to an external executor. */ externalToolExecutor?: boolean; /** Wire model id selected after thinking-effort routing (`resolveWireModelId`). */ wireModelId?: string; /** Run transport. `auto` starts with HTTP/2 and falls back on failed ALPN negotiation. */ transport?: "auto" | "http2" | "http1"; } interface CursorRequestState { conversationId: string; blobStore: Map; conversationState?: ConversationStateStructure; resume?: boolean; } interface CursorGrpcRequest { requestBytes: Uint8Array; blobStore: Map; conversationState: ConversationStateStructure; } interface CursorMessageWriter { write(frame: Uint8Array): void; } /** * 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, the handshake can fail with * `ERR_HTTP2_ERROR: h2 is not supported`. Automatic transport retries the * official RunSSE/BidiAppend HTTP/1 path; this mapped error remains observable * when a caller explicitly forces HTTP/2. * * Non-ALPN errors pass through untouched. */ export declare function mapH2TransportError(error: unknown, baseUrl: string): unknown; /** Streams a Cursor Agent turn, retrying a discovered effort id when its normalized wire id is unavailable. */ export declare const streamCursor: StreamFunction<"cursor-agent">; export type ToolCallState = ToolCall & { [kStreamingBlockIndex]: number; [kStreamingPartialJson]?: string; [kStreamingLastParseLen]?: number; [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec" | "cursor-edit" | "connect-scm" | "web-fetch"; [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; /** * Native `editToolCall` (StrReplace) ids whose materialization `readArgs` / * `writeArgs` must stay raw and must not synthesize extra transcript blocks. * * Optional so existing test harnesses stay valid. Both the interaction * envelope `call_id` and the inner `toolCallId` are recorded — exec frames * pair on the inner id. */ editOwnedToolCallIds?: Set; /** Edit blocks whose write already persisted a `toolResult`. */ pairedEditToolCallIds?: 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, runTransport: CursorMessageWriter, execHandlers: CursorExecHandlers | undefined, onToolResult: CursorToolResultHandler | undefined, usageState: UsageState, requestContextTools: McpToolDefinition[], requestContextRules?: CursorRule[], onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void, externalToolExecutor?: boolean): 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) => R, buildRejected: (reason: string) => R, buildError: (error: string) => R, pairing: CursorExecPairing | null): Promise<{ execResult: R; 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. Pass `{ executed: false }` for a frame nothing ran: a * call handed to an external executor is only a handoff while the marker is * absent, and the consumer that runs it needs a runnable block. * * 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, { executed }?: { executed?: boolean; }): 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; /** * Cursor AgentService reconstructs the model prompt from `requestContext.rules`, * not from the client-supplied `rootPromptMessagesJson` system blobs. Map each * OMP system-prompt entry to a global CursorRule so always-apply rules survive * that reconstruction. */ export declare function buildCursorRequestContextRules(systemPrompt: readonly string[] | undefined): CursorRule[]; export declare function buildMcpToolDefinitions(tools: Tool[] | undefined, requiresCursorToolSchemaProjection?: boolean): 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[][]; }; /** Builds the normalized Cursor Run request used by transport callers and request inspection hooks. */ export declare function buildGrpcRequest(model: Model<"cursor-agent">, context: Context, options: CursorOptions | undefined, state: CursorRequestState): Promise;