Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion scripts/copy-runtime.mjs
Original file line number Diff line number Diff line change
@@ -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 });

Expand Down
3 changes: 2 additions & 1 deletion scripts/cursor-capability/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof decodeArgs> };
Expand Down Expand Up @@ -57,7 +58,7 @@ export async function runProbe(input: Parameters<typeof buildRequest>[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) {
Expand Down
3 changes: 2 additions & 1 deletion scripts/probe-opencode-v2-cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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<Parameters<typeof runProbe>[0], "accessToken" | "selection">) => {
Expand Down
83 changes: 64 additions & 19 deletions src/cursor-agent.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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[] }
Expand Down Expand Up @@ -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<number, [result: number, reason?: string]>([
[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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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 (
Expand All @@ -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")
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/cursor-client.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export declare const CURSOR_CLIENT_HEADERS: {
readonly "x-cursor-client-version": string;
readonly "x-cursor-client-type": string;
};
6 changes: 6 additions & 0 deletions src/cursor-client.mjs
Original file line number Diff line number Diff line change
@@ -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",
};
4 changes: 2 additions & 2 deletions src/h2-unary.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 13 additions & 5 deletions src/h2-v2.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 });
Expand All @@ -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");
});
Expand Down
4 changes: 4 additions & 0 deletions src/models/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}

Expand Down
11 changes: 11 additions & 0 deletions src/opencode/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LanguageModelV3StreamPart>,
Expand All @@ -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 ||
Expand Down Expand Up @@ -230,6 +235,9 @@ export function createCursorLanguageModel(
...(event.turnUsage === undefined
? {}
: { turnUsage: reportedCounts(event.turnUsage) }),
...(serverNotices.length
? { serverNotice: serverNotices.join("").trim() }
: {}),
billedCost: "unavailable",
},
},
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading