diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 3865c65..0d45f97 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -27,6 +27,7 @@ try { for (const required of [ "dist/index.js", "dist/index.d.ts", + "dist/cursor-client.mjs", "dist/h2-v2.mjs", "dist/h2-unary.mjs", "LICENSE", diff --git a/scripts/copy-runtime.mjs b/scripts/copy-runtime.mjs index 5985308..8369cd6 100644 --- a/scripts/copy-runtime.mjs +++ b/scripts/copy-runtime.mjs @@ -1,7 +1,7 @@ import { cpSync, mkdirSync } from "node:fs"; import { join } from "node:path"; -const runtimeFiles = ["h2-unary.mjs", "h2-v2.mjs"]; +const runtimeFiles = ["cursor-client.mjs", "h2-unary.mjs", "h2-v2.mjs"]; mkdirSync("dist", { recursive: true }); diff --git a/scripts/cursor-capability/run.ts b/scripts/cursor-capability/run.ts index 5c701ff..42d11a0 100644 --- a/scripts/cursor-capability/run.ts +++ b/scripts/cursor-capability/run.ts @@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import * as p from "../../src/proto/agent_pb.js"; +import { CURSOR_CLIENT_HEADERS } from "../../src/cursor-client.mjs"; import { buildRequest, decodeArgs, frame } from "./protocol.js"; export type ObservedCall = { id: string; name: string; args: ReturnType }; @@ -57,7 +58,7 @@ export async function runProbe(input: Parameters[0] & { ":method": "POST", ":path": "/agent.v1.AgentService/Run", "content-type": "application/connect+proto", "connect-protocol-version": "1", te: "trailers", authorization: `Bearer ${input.accessToken}`, "x-ghost-mode": "true", - "x-cursor-client-version": "cli-2026.01.09-231024f", "x-cursor-client-type": "cli", + ...CURSOR_CLIENT_HEADERS, "x-request-id": randomUUID(), }; if (!input.tools.length) { diff --git a/scripts/probe-opencode-v2-cursor.ts b/scripts/probe-opencode-v2-cursor.ts index 79719f0..d3379e7 100644 --- a/scripts/probe-opencode-v2-cursor.ts +++ b/scripts/probe-opencode-v2-cursor.ts @@ -3,6 +3,7 @@ import { parseArgs } from "node:util"; import { decodeCursorModelSelection, encodeCursorModelSelection, type CursorModelSelection } from "../src/model-selection.js"; import { contaminatedHistory, errorTrial, syntheticLongContext, tools, toolPrompt, toolTrial } from "./cursor-capability/cases.js"; import { runProbe, type Observation } from "./cursor-capability/run.js"; +import { CURSOR_CLIENT_HEADERS } from "../src/cursor-client.mjs"; async function main() { const { values } = parseArgs({ options: { @@ -59,7 +60,7 @@ async function main() { }; console.log(JSON.stringify({ type: "probe", version: 3, suite: values["follow-up"] ? "follow-up" : "matrix", startedAt: new Date().toISOString(), runBudget, timeoutMs, selections, - endpoint: "https://api2.cursor.sh", clientVersion: "cli-2026.01.09-231024f", + endpoint: "https://api2.cursor.sh", clientVersion: CURSOR_CLIENT_HEADERS["x-cursor-client-version"], })); const run = async (selection: CursorModelSelection, name: string, input: Omit[0], "accessToken" | "selection">) => { diff --git a/src/cursor-agent.ts b/src/cursor-agent.ts index 4a1e985..7fff74a 100644 --- a/src/cursor-agent.ts +++ b/src/cursor-agent.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; -import { BinaryWriter, WireType } from "@bufbuild/protobuf/wire"; +import { BinaryReader, BinaryWriter, WireType } from "@bufbuild/protobuf/wire"; import * as p from "./proto/agent_pb.js"; import { CURSOR_API_URL } from "./cursor-rpc.js"; import { resolveNodeExecutable } from "./node-runtime.js"; @@ -30,6 +30,7 @@ import { readTurnUsage, type CursorTokenUsage } from "./cursor-agent-usage.js"; export type CursorRunEvent = | { type: "text"; text: string } + | { type: "server-notice"; text: string } | { type: "reasoning"; text: string; id: string } | { type: "reasoning-metadata"; signatures: ReasoningSignature[] } | { type: "opaque-reasoning"; annotations: OpaqueReasoning[] } @@ -139,6 +140,33 @@ export function runCursorAgent( return run.open(input.abortSignal); } +const PRESENTATION_UPDATES = new Set([19, 20, 21, 22, 23, 24, 25, 26, 27]); + +// SDK 1.0.37 InteractionQuery fields newer than this descriptor, mapped to +// the reply's fail-closed result field and its string field 1. Web fetch +// keeps the generic hosted-access reason. +const NEWER_QUERIES = new Map([ + [9, [2]], // web fetch: rejected { reason } + [10, [2, "Cursor PR management is disabled."]], // error { error } + [11, [2, "Cursor MCP authentication is disabled."]], // rejected { reason } + [12, [2, "Cursor image generation is disabled."]], // rejected { reason } + [13, [2, "Cursor environment replacement is disabled."]], // failure { error_message } + [14, [2, "Cursor source-control connections are disabled."]], // rejected { reason } +]); + +// SDK 1.0.37: TextDeltaUpdate.is_server_notice = 2 (bool). It marks Cursor's +// own notices, such as a safety fallback, rather than model output. +function isServerNotice(update: p.TextDeltaUpdate): boolean { + return ( + update.$unknown?.some( + (field) => + field.no === 2 && + field.wireType === WireType.Varint && + new BinaryReader(field.data).bool(), + ) ?? false + ); +} + function extractToolOutput(raw: string): string { try { const parsed = JSON.parse(raw); @@ -175,6 +203,7 @@ class AgentRun { private sawTokenDelta = false; private checkpointSeenThisStep = false; private checkpointAfterTokenDelta = false; + private checkpointAfterTurnEnded = false; private traceCount = 0; private outputStarted = false; private resumed = false; @@ -234,6 +263,10 @@ class AgentRun { }, onEnd: (error) => { if (this.status === "closed") return; + if (error?.message.startsWith("Cursor transport: premature-eof")) + error = new Error( + `${error.message} (turnEnded=${this.turnEnded}, finalCheckpoint=${this.checkpointAfterTurnEnded})`, + ); if (error || !this.turnEnded) return this.dispose( error ?? new Error("Cursor Run ended without turnEnded"), @@ -466,7 +499,7 @@ class AgentRun { this.reasoningBlocks.set(event.id, content); appendEntry(this.expected, { role: "assistant", content: [content] }); } - } else + } else if (event.type === "text") appendEntry(this.expected, { role: "assistant", content: [ @@ -767,31 +800,31 @@ class AgentRun { }), }), }; - else if ( - q.case === undefined && - query.$unknown?.some((field) => field.no === 9) - ) - // Web fetch (field 9) postdates this descriptor. Its response has the - // other gates' layout: rejected = 2 { reason = 1 }. + else if (q.case === undefined) { + // Queries newer than this descriptor. Each reply mirrors the query + // field and fails closed as SDK 1.0.37 local runs do. + const field = query.$unknown?.find((item) => NEWER_QUERIES.has(item.no)); + if (!field) throw new Error("Unsupported Cursor interaction"); + const [result, reason] = NEWER_QUERIES.get(field.no)!; response.$unknown = [ { - no: 9, + no: field.no, wireType: WireType.LengthDelimited, data: new BinaryWriter() .bytes( new BinaryWriter() - .tag(2, WireType.LengthDelimited) + .tag(result, WireType.LengthDelimited) .fork() .tag(1, WireType.LengthDelimited) - .string(hosted) + .string(reason ?? hosted) .join() .finish(), ) .finish(), }, ]; - // A VM setup result can only claim success, and other queries have no - // known reply layout. + } + // A VM setup result can only claim success. else throw new Error("Unsupported Cursor interaction"); this.send({ case: "interactionResponse", value: response }); } @@ -1239,6 +1272,13 @@ class AgentRun { private receive(message: p.AgentServerMessage) { if (this.status === "closed") return; const item = message.message; + // SDK 1.0.37: AgentServerMessage.ttft_breakdown = 8 is latency telemetry. + if ( + item.case === undefined && + message.$unknown?.length && + message.$unknown.every((field) => field.no === 8) + ) + return; if (item.case === "interactionUpdate") { const update = item.value.message; if ( @@ -1255,13 +1295,15 @@ class AgentRun { result: record(result)?.case ?? "none", }); } - // SDK 1.0.31: field 21 is a feedback-form notification; 25 is an - // optional timestamp. Neither is model output or an execution request. - // The base AgentService descriptor retains these as unknown fields. + // SDK 1.0.37 adds client-presentation updates the base descriptor keeps + // as unknown fields: tool counts, prompts, branch changes, feedback, + // response comparisons, context state, routed model, and nudges (19-24, + // 26, 27), plus the optional timestamp (25). None is model output or an + // execution request. if ( update.case === undefined && - item.value.$unknown?.some((field) => field.no === 21) && - item.value.$unknown.every((field) => field.no === 21 || field.no === 25) + item.value.$unknown?.length && + item.value.$unknown.every((field) => PRESENTATION_UPDATES.has(field.no)) ) return; if (this.turnEnded && update.case !== "heartbeat") @@ -1270,7 +1312,9 @@ class AgentRun { if (update.value.text) this.emit( update.case === "textDelta" - ? { type: "text", text: update.value.text } + ? isServerNotice(update.value) + ? { type: "server-notice", text: update.value.text } + : { type: "text", text: update.value.text } : { type: "reasoning", text: update.value.text, @@ -1320,6 +1364,7 @@ class AgentRun { return; } if (item.case === "conversationCheckpointUpdate") { + if (this.turnEnded) this.checkpointAfterTurnEnded = true; // A checkpoint without token details must not erase known occupancy. if (item.value.tokenDetails?.usedTokens) { this.contextTokens = item.value.tokenDetails.usedTokens; diff --git a/src/cursor-client.d.mts b/src/cursor-client.d.mts new file mode 100644 index 0000000..d5e2a10 --- /dev/null +++ b/src/cursor-client.d.mts @@ -0,0 +1,4 @@ +export declare const CURSOR_CLIENT_HEADERS: { + readonly "x-cursor-client-version": string; + readonly "x-cursor-client-type": string; +}; diff --git a/src/cursor-client.mjs b/src/cursor-client.mjs new file mode 100644 index 0000000..5b5f0d9 --- /dev/null +++ b/src/cursor-client.mjs @@ -0,0 +1,6 @@ +// The Cursor CLI build this client presents as. Cursor varies server behavior +// by client version, so change it only after a live end-to-end check. +export const CURSOR_CLIENT_HEADERS = { + "x-cursor-client-version": "cli-2026.01.09-231024f", + "x-cursor-client-type": "cli", +}; diff --git a/src/h2-unary.mjs b/src/h2-unary.mjs index a00f95b..e694527 100644 --- a/src/h2-unary.mjs +++ b/src/h2-unary.mjs @@ -2,6 +2,7 @@ import http2 from "node:http2"; import { randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; +import { CURSOR_CLIENT_HEADERS } from "./cursor-client.mjs"; const LIMIT = 8 * 1024 * 1024; let input = Buffer.alloc(0); @@ -62,8 +63,7 @@ process.stdin.on("end", () => { te: "trailers", authorization: `Bearer ${config.accessToken}`, "x-ghost-mode": "true", - "x-cursor-client-version": "cli-2026.01.09-231024f", - "x-cursor-client-type": "cli", + ...CURSOR_CLIENT_HEADERS, "x-request-id": randomUUID(), }); let response = Buffer.alloc(0); diff --git a/src/h2-v2.mjs b/src/h2-v2.mjs index 3ada4ad..8d2d6fa 100644 --- a/src/h2-v2.mjs +++ b/src/h2-v2.mjs @@ -3,6 +3,7 @@ import http2 from "node:http2"; import { randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; +import { CURSOR_CLIENT_HEADERS } from "./cursor-client.mjs"; const LIMIT = 8 * 1024 * 1024; let client; @@ -11,6 +12,7 @@ let ended = false; let finished = false; let encoding = "identity"; let network = Buffer.alloc(0); +let frames = 0; let ipc = Buffer.alloc(0); function packet(type, data = Buffer.alloc(0)) { @@ -75,8 +77,7 @@ function open(config) { te: "trailers", authorization: `Bearer ${config.accessToken}`, "x-ghost-mode": "true", - "x-cursor-client-version": "cli-2026.01.09-231024f", - "x-cursor-client-type": "cli", + ...CURSOR_CLIENT_HEADERS, "x-request-id": randomUUID(), }; // Cursor's MCP tool family. Allowing only mcp_tool_call hides @@ -111,6 +112,7 @@ function open(config) { if (network.length < 5 + size) break; let bytes = network.subarray(5, 5 + size); network = network.subarray(5 + size); + frames++; if (flags & 1) { if (encoding !== "gzip") throw new Error("unsupported-compression"); bytes = gunzipSync(bytes, { maxOutputLength: LIMIT }); @@ -133,9 +135,15 @@ function open(config) { fail("invalid-connect-frame"); } }); - stream.on("end", () => - finish(ended && network.length === 0 ? undefined : "premature-eof"), - ); + stream.on("end", () => { + if (ended && network.length === 0) return finish(); + if (!network.length) return finish("premature-eof"); + // Sizes only: the cut frame's declared and received bytes. + const declared = network.length >= 5 ? network.readUInt32BE(1) : -1; + finish( + `premature-eof-partial-frame (declared=${declared}, received=${Math.max(0, network.length - 5)}, frames=${frames})`, + ); + }); stream.on("close", () => { if (!finished) fail("stream-closed"); }); diff --git a/src/models/publish.ts b/src/models/publish.ts index 8ca5143..ac3bbdc 100644 --- a/src/models/publish.ts +++ b/src/models/publish.ts @@ -17,6 +17,7 @@ export function publishCursorCatalog( const usableWires = usableModelIds(usableRaw); applyUsableWireIds(available, usableWires); const byId = new Map(available.map((model) => [model.id, model])); + const availableWires = new Set(available.flatMap(wireIds)); for (const model of usable) { const existing = byId.get(model.id); @@ -26,6 +27,9 @@ export function publishCursorCatalog( } continue; } + // A usable family sharing a wire ID with an AvailableModels family under + // another id is a legacy alias of it, not a separate model. + if (wireIds(model).some((id) => availableWires.has(id))) continue; byId.set(model.id, model); } diff --git a/src/opencode/language.ts b/src/opencode/language.ts index d4e3fce..59375d6 100644 --- a/src/opencode/language.ts +++ b/src/opencode/language.ts @@ -123,6 +123,7 @@ export function createCursorLanguageModel( ? { sessionID, observer: options.toolObserver } : undefined, }); + const serverNotices: string[] = []; let openBlock: { type: "text" | "reasoning"; id: string } | undefined; const closeBlock = ( controller: TransformStreamDefaultController, @@ -141,6 +142,10 @@ export function createCursorLanguageModel( controller.enqueue({ type: "stream-start", warnings }); }, transform(event, controller) { + if (event.type === "server-notice") { + serverNotices.push(event.text); + return; + } if (event.type === "text" || event.type === "reasoning") { if ( openBlock?.type !== event.type || @@ -230,6 +235,9 @@ export function createCursorLanguageModel( ...(event.turnUsage === undefined ? {} : { turnUsage: reportedCounts(event.turnUsage) }), + ...(serverNotices.length + ? { serverNotice: serverNotices.join("").trim() } + : {}), billedCost: "unavailable", }, }, @@ -291,6 +299,9 @@ export function createCursorLanguageModel( finalUsage = part.usage; finishReason = part.finishReason; providerMetadata = part.providerMetadata; + const notice = providerMetadata?.cursor?.serverNotice; + if (typeof notice === "string" && notice) + warnings = [...warnings, { type: "other", message: notice }]; } } return { diff --git a/test/model-normalizer.test.ts b/test/model-normalizer.test.ts index eda89b6..07f79dd 100644 --- a/test/model-normalizer.test.ts +++ b/test/model-normalizer.test.ts @@ -289,6 +289,37 @@ describe("Cursor model normalization", () => { expect(models[0]?.defaultSelection.publicId).toBe("gemini-3.7-flash"); }); + test("drops a usable family that aliases an AvailableModels family under another id", () => { + const models = publishCursorCatalog( + [{ + name: "claude-opus-9", + clientDisplayName: "Claude Opus 9", + serverModelName: "claude-opus-9", + parameterDefinitions: [{ + id: "effort", + parameterType: { + enumParameter: { values: [{ value: "high" }, { value: "max" }] }, + }, + }], + variants: ["high", "max"].map((effort) => ({ + parameterValues: [{ id: "effort", value: effort }], + legacySlug: `claude-9-opus-${effort}-thinking`, + })), + }], + [ + { modelId: "claude-9-opus-high", displayName: "Claude Opus 9 Legacy" }, + { modelId: "claude-9-opus-high-thinking", displayName: "Claude Opus 9 Legacy" }, + { modelId: "claude-9-opus-max-thinking", displayName: "Claude Opus 9 Legacy" }, + { modelId: "claude-fable-5.1", displayName: "Claude Fable 5.1" }, + ], + ); + + expect(models.map((model) => model.id)).toEqual([ + "claude-fable-5.1", + "claude-opus-9", + ]); + }); + test("keeps grok-4.6 stable and sends the usable wire id", () => { const models = publishCursorCatalog( [ diff --git a/test/v2-agent.test.ts b/test/v2-agent.test.ts index caf8168..d5245f1 100644 --- a/test/v2-agent.test.ts +++ b/test/v2-agent.test.ts @@ -788,8 +788,8 @@ test("drains final checkpoint/blob work after turnEnded before accepting Connect expect(result.providerMetadata?.cursor?.contextTokens).toBe(321); }); -test("ignores a post-turn feedback form but still rejects unknown terminal updates", async () => { - for (const field of [21, 22]) { +test("ignores post-turn presentation updates but still rejects unknown terminal updates", async () => { + for (const field of [21, 24, 99]) { const backend = await peer((message, stream) => { if (message.case !== "runRequest") return; stream.write(end()); @@ -799,14 +799,16 @@ test("ignores a post-turn feedback form but still rejects unknown terminal updat case: "interactionUpdate", value: fromBinary( p.InteractionUpdateSchema, - new Uint8Array([(((field << 3) | 2) & 127) | 128, 1, 0]), + new Uint8Array([(((field << 3) | 2) & 127) | 128, field >> 4, 0]), ), }), ), ); + // SDK 1.0.37 latency telemetry, AgentServerMessage field 8. + stream.write(frame(Uint8Array.of((8 << 3) | 2, 0))); stream.end(close()); }); - if (field === 21) + if (field !== 99) expect( (await model(backend.url).doGenerate({ prompt })).finishReason.unified, ).toBe("stop"); @@ -1690,6 +1692,36 @@ test("a matching final checkpoint uses Cursor input when output deltas omit gene expect(result.providerMetadata?.cursor?.turnUsage).toMatchObject({ input: 90, output: 13 }); }); +test("Cursor server notices become a warning and metadata, never assistant text", async () => { + const notice = "Switched to a fallback model after a safety filter."; + const flagged = create(p.TextDeltaUpdateSchema, { text: `\n\n${notice}` }); + flagged.$unknown = [ + { no: 2, wireType: WireType.Varint, data: Uint8Array.of(1) }, + ]; + const backend = await peer((message, stream) => { + if (message.case !== "runRequest") return; + stream.write(text("answer")); + stream.write( + frame( + encoded({ + case: "interactionUpdate", + value: create(p.InteractionUpdateSchema, { + message: { case: "textDelta", value: flagged }, + }), + }), + ), + ); + stream.write(end()); + stream.end(close()); + }); + const result = await model(backend.url).doGenerate({ prompt }); + expect(result.content).toEqual([ + { type: "text", text: "answer", providerMetadata: undefined }, + ]); + expect(result.warnings).toEqual([{ type: "other", message: notice }]); + expect(result.providerMetadata?.cursor?.serverNotice).toBe(notice); +}); + test("usage preserves explicit zeros and unknown fields, and rejects unsafe counters", () => { const decode = ( input: Parameters>[1], @@ -1873,14 +1905,18 @@ test("declines Cursor-hosted interaction queries so the Run continues with host value: create(p.CreatePlanRequestQuerySchema), }), ); - const webFetch = create(p.InteractionQuerySchema, { id: 7 }); - webFetch.$unknown = [ - { no: 9, wireType: WireType.LengthDelimited, data: Uint8Array.of(0) }, - ]; - stream.write(frame(encoded({ case: "interactionQuery", value: webFetch }))); + // SDK 1.0.37 queries newer than the descriptor: web fetch, PR + // management, MCP auth, image generation, env replacement, SCM. + for (const no of [9, 10, 11, 12, 13, 14]) { + const newer = create(p.InteractionQuerySchema, { id: no - 2 }); + newer.$unknown = [ + { no, wireType: WireType.LengthDelimited, data: Uint8Array.of(0) }, + ]; + stream.write(frame(encoded({ case: "interactionQuery", value: newer }))); + } } else if (message.case === "interactionResponse") { answers.set(message.value.id, message.value); - if (answers.size === 7) { + if (answers.size === 12) { stream.write(text("continued")); stream.write(end()); stream.end(close()); @@ -1902,15 +1938,17 @@ test("declines Cursor-hosted interaction queries so the Run continues with host ["switchModeRequestResponse", "rejected"], ["createPlanRequestResponse", "error"], ]); - const webFetch = answers.get(7)!; - expect(webFetch.result.case).toBeUndefined(); - const field = webFetch.$unknown?.find((item) => item.no === 9); - expect(field?.wireType).toBe(WireType.LengthDelimited); - const response = new BinaryReader(new BinaryReader(field!.data).bytes()); - expect(response.tag()).toEqual([2, WireType.LengthDelimited]); - const rejected = new BinaryReader(response.bytes()); - expect(rejected.tag()).toEqual([1, WireType.LengthDelimited]); - expect(rejected.string()).toContain("host tool"); + for (const no of [9, 10, 11, 12, 13, 14]) { + const answer = answers.get(no - 2)!; + expect(answer.result.case).toBeUndefined(); + const field = answer.$unknown?.find((item) => item.no === no); + expect(field?.wireType).toBe(WireType.LengthDelimited); + const response = new BinaryReader(new BinaryReader(field!.data).bytes()); + expect(response.tag()).toEqual([2, WireType.LengthDelimited]); + const declined = new BinaryReader(response.bytes()); + expect(declined.tag()).toEqual([1, WireType.LengthDelimited]); + expect(declined.string()).toMatch(no === 9 ? /host tool/ : /disabled/); + } }); test("declines Cursor's native edit so formatted host reads are never written back", async () => {