@playwo/opencode-cursor-oauth 0.0.0-dev.c80ebcb27754 → 0.0.0-dev.e795e5ffd849

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,103 +1,31 @@
1
- # @playwo/opencode-cursor-oauth
1
+ # opencode-cursor-oauth
2
2
 
3
- OpenCode plugin that connects to Cursor's API, giving you access to Cursor
4
- models inside OpenCode with full tool-calling support.
3
+ Use Cursor models (Claude, GPT, Gemini, etc.) inside [OpenCode](https://opencode.ai).
5
4
 
6
- ## Install in OpenCode
5
+ ## What it does
7
6
 
8
- Add this to `~/.config/opencode/opencode.json`:
7
+ - **OAuth login** to Cursor via browser
8
+ - **Model discovery** — automatically fetches your available Cursor models
9
+ - **Local proxy** — runs an OpenAI-compatible endpoint that translates to Cursor's gRPC protocol
10
+ - **Auto-refresh** — handles token expiration automatically
9
11
 
10
- ```jsonc
11
- {
12
- "$schema": "https://opencode.ai/config.json",
13
- "plugin": [
14
- "@playwo/opencode-cursor-oauth"
15
- ],
16
- "provider": {
17
- "cursor": {
18
- "name": "Cursor"
19
- }
20
- }
21
- }
22
- ```
23
-
24
- The `cursor` provider stub is required because OpenCode drops providers that do
25
- not already exist in its bundled provider catalog.
26
-
27
- OpenCode installs npm plugins automatically at startup, so users do not need to
28
- clone this repository.
29
-
30
- ## Authenticate
31
-
32
- ```sh
33
- opencode auth login --provider cursor
34
- ```
35
-
36
- This opens Cursor OAuth in the browser. Tokens are stored in
37
- `~/.local/share/opencode/auth.json` and refreshed automatically.
38
-
39
- ## Use
40
-
41
- Start OpenCode and select any Cursor model. The plugin starts a local
42
- OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
43
-
44
- ## How it works
45
-
46
- 1. OAuth — browser-based login to Cursor via PKCE.
47
- 2. Model discovery — queries Cursor's gRPC API for all available models.
48
- 3. Local proxy — translates `POST /v1/chat/completions` into Cursor's
49
- protobuf/Connect protocol.
50
- 4. Native tool routing — rejects Cursor's built-in filesystem/shell tools and
51
- exposes OpenCode's tool surface via Cursor MCP instead.
52
-
53
- Cursor agent streaming uses Cursor's `RunSSE` + `BidiAppend` transport, so the
54
- plugin runs entirely inside OpenCode without a Node sidecar.
12
+ ## Install
55
13
 
56
- ## Architecture
14
+ Add to your `opencode.json`:
57
15
 
16
+ ```json
17
+ {
18
+ "plugin": ["@playwo/opencode-cursor-oauth"]
19
+ }
58
20
  ```
59
- OpenCode --> /v1/chat/completions --> Bun.serve (proxy)
60
- |
61
- RunSSE stream + BidiAppend writes
62
- |
63
- Cursor Connect/SSE transport
64
- |
65
- api2.cursor.sh gRPC
66
- ```
67
-
68
- ### Tool call flow
69
-
70
- ```
71
- 1. Cursor model receives OpenAI tools via RequestContext (as MCP tool defs)
72
- 2. Model tries native tools (readArgs, shellArgs, etc.)
73
- 3. Proxy rejects each with typed error (ReadRejected, ShellRejected, etc.)
74
- 4. Model falls back to MCP tool -> mcpArgs exec message
75
- 5. Proxy emits OpenAI tool_calls SSE chunk, pauses the Cursor stream
76
- 6. OpenCode executes tool, sends result in follow-up request
77
- 7. Proxy resumes the Cursor stream with mcpResult and continues streaming
78
- ```
79
-
80
- ## Develop locally
81
-
82
- ```sh
83
- bun install
84
- bun run build
85
- bun test/smoke.ts
86
- ```
87
-
88
- ## Publish
89
21
 
90
- GitHub Actions publishes this package with `.github/workflows/publish-npm.yml`.
22
+ Then authenticate via the OpenCode UI (Settings → Providers → Cursor → Login).
91
23
 
92
- - branch pushes publish a `dev` build as `0.0.0-dev.<sha>`
93
- - versioned releases publish `latest` using the `package.json` version and upload the packed `.tgz` to the GitHub release
94
-
95
- Repository secrets required:
24
+ ## Requirements
96
25
 
97
- - `NPM_TOKEN` for npm publish access
26
+ - Cursor account with API access
27
+ - OpenCode 1.2+
98
28
 
99
- ## Requirements
29
+ ## License
100
30
 
101
- - [OpenCode](https://opencode.ai)
102
- - [Bun](https://bun.sh)
103
- - Active [Cursor](https://cursor.com) subscription
31
+ MIT
package/dist/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { generatePKCE } from "./pkce";
2
+ import { errorDetails, logPluginError, logPluginWarn } from "./logger";
2
3
  const CURSOR_LOGIN_URL = "https://cursor.com/loginDeepControl";
3
4
  const CURSOR_POLL_URL = "https://api2.cursor.sh/auth/poll";
4
5
  const CURSOR_REFRESH_URL = process.env.CURSOR_REFRESH_URL ??
@@ -40,13 +41,32 @@ export async function pollCursorAuth(uuid, verifier) {
40
41
  }
41
42
  throw new Error(`Poll failed: ${response.status}`);
42
43
  }
43
- catch {
44
+ catch (error) {
44
45
  consecutiveErrors++;
45
46
  if (consecutiveErrors >= 3) {
47
+ logPluginError("Cursor auth polling failed repeatedly", {
48
+ stage: "oauth_poll",
49
+ uuid,
50
+ attempts: attempt + 1,
51
+ consecutiveErrors,
52
+ ...errorDetails(error),
53
+ });
46
54
  throw new Error("Too many consecutive errors during Cursor auth polling");
47
55
  }
56
+ logPluginWarn("Cursor auth polling attempt failed", {
57
+ stage: "oauth_poll",
58
+ uuid,
59
+ attempt: attempt + 1,
60
+ consecutiveErrors,
61
+ ...errorDetails(error),
62
+ });
48
63
  }
49
64
  }
65
+ logPluginError("Cursor authentication polling timed out", {
66
+ stage: "oauth_poll",
67
+ uuid,
68
+ attempts: POLL_MAX_ATTEMPTS,
69
+ });
50
70
  throw new Error("Cursor authentication polling timeout");
51
71
  }
52
72
  export async function refreshCursorToken(refreshToken) {
@@ -60,6 +80,11 @@ export async function refreshCursorToken(refreshToken) {
60
80
  });
61
81
  if (!response.ok) {
62
82
  const error = await response.text();
83
+ logPluginError("Cursor token refresh failed", {
84
+ stage: "token_refresh",
85
+ status: response.status,
86
+ responseBody: error,
87
+ });
63
88
  throw new Error(`Cursor token refresh failed: ${error}`);
64
89
  }
65
90
  const data = (await response.json());
package/dist/index.js CHANGED
@@ -1,42 +1,27 @@
1
1
  import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
2
+ import { configurePluginLogger, errorDetails, logPluginError, logPluginWarn } from "./logger";
2
3
  import { getCursorModels } from "./models";
3
- import { startProxy } from "./proxy";
4
+ import { startProxy, stopProxy, } from "./proxy";
4
5
  const CURSOR_PROVIDER_ID = "cursor";
6
+ let lastModelDiscoveryError = null;
5
7
  /**
6
8
  * OpenCode plugin that provides Cursor authentication and model access.
7
9
  * Register in opencode.json: { "plugin": ["opencode-cursor-oauth"] }
8
10
  */
9
11
  export const CursorAuthPlugin = async (input) => {
12
+ configurePluginLogger(input);
10
13
  return {
11
14
  auth: {
12
15
  provider: CURSOR_PROVIDER_ID,
13
16
  async loader(getAuth, provider) {
14
- const auth = await getAuth();
15
- if (!auth || auth.type !== "oauth")
16
- return {};
17
- // Ensure we have a valid access token, refreshing if expired
18
- let accessToken = auth.access;
19
- if (!accessToken || auth.expires < Date.now()) {
20
- const refreshed = await refreshCursorToken(auth.refresh);
21
- await input.client.auth.set({
22
- path: { id: CURSOR_PROVIDER_ID },
23
- body: {
24
- type: "oauth",
25
- refresh: refreshed.refresh,
26
- access: refreshed.access,
27
- expires: refreshed.expires,
28
- },
29
- });
30
- accessToken = refreshed.access;
31
- }
32
- const models = await getCursorModels(accessToken);
33
- const port = await startProxy(async () => {
34
- const currentAuth = await getAuth();
35
- if (currentAuth.type !== "oauth") {
36
- throw new Error("Cursor auth not configured");
37
- }
38
- if (!currentAuth.access || currentAuth.expires < Date.now()) {
39
- const refreshed = await refreshCursorToken(currentAuth.refresh);
17
+ try {
18
+ const auth = await getAuth();
19
+ if (!auth || auth.type !== "oauth")
20
+ return {};
21
+ // Ensure we have a valid access token, refreshing if expired
22
+ let accessToken = auth.access;
23
+ if (!accessToken || auth.expires < Date.now()) {
24
+ const refreshed = await refreshCursorToken(auth.refresh);
40
25
  await input.client.auth.set({
41
26
  path: { id: CURSOR_PROVIDER_ID },
42
27
  body: {
@@ -46,32 +31,78 @@ export const CursorAuthPlugin = async (input) => {
46
31
  expires: refreshed.expires,
47
32
  },
48
33
  });
49
- return refreshed.access;
34
+ accessToken = refreshed.access;
50
35
  }
51
- return currentAuth.access;
52
- }, models);
53
- if (provider) {
54
- provider.models = buildCursorProviderModels(models, port);
55
- }
56
- return {
57
- baseURL: `http://localhost:${port}/v1`,
58
- apiKey: "cursor-proxy",
59
- async fetch(requestInput, init) {
60
- if (init?.headers) {
61
- if (init.headers instanceof Headers) {
62
- init.headers.delete("authorization");
63
- }
64
- else if (Array.isArray(init.headers)) {
65
- init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization");
66
- }
67
- else {
68
- delete init.headers["authorization"];
69
- delete init.headers["Authorization"];
70
- }
36
+ let models;
37
+ models = await getCursorModels(accessToken);
38
+ lastModelDiscoveryError = null;
39
+ const port = await startProxy(async () => {
40
+ const currentAuth = await getAuth();
41
+ if (currentAuth.type !== "oauth") {
42
+ const authError = new Error("Cursor auth not configured");
43
+ logPluginError("Cursor proxy access token lookup failed", {
44
+ stage: "proxy_access_token",
45
+ ...errorDetails(authError),
46
+ });
47
+ throw authError;
71
48
  }
72
- return fetch(requestInput, init);
73
- },
74
- };
49
+ if (!currentAuth.access || currentAuth.expires < Date.now()) {
50
+ const refreshed = await refreshCursorToken(currentAuth.refresh);
51
+ await input.client.auth.set({
52
+ path: { id: CURSOR_PROVIDER_ID },
53
+ body: {
54
+ type: "oauth",
55
+ refresh: refreshed.refresh,
56
+ access: refreshed.access,
57
+ expires: refreshed.expires,
58
+ },
59
+ });
60
+ return refreshed.access;
61
+ }
62
+ return currentAuth.access;
63
+ }, models);
64
+ if (provider) {
65
+ provider.models = buildCursorProviderModels(models, port);
66
+ }
67
+ return {
68
+ baseURL: `http://localhost:${port}/v1`,
69
+ apiKey: "cursor-proxy",
70
+ async fetch(requestInput, init) {
71
+ if (init?.headers) {
72
+ if (init.headers instanceof Headers) {
73
+ init.headers.delete("authorization");
74
+ }
75
+ else if (Array.isArray(init.headers)) {
76
+ init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization");
77
+ }
78
+ else {
79
+ delete init.headers["authorization"];
80
+ delete init.headers["Authorization"];
81
+ }
82
+ }
83
+ return fetch(requestInput, init);
84
+ },
85
+ };
86
+ }
87
+ catch (error) {
88
+ const message = error instanceof Error
89
+ ? error.message
90
+ : "Cursor model discovery failed.";
91
+ logPluginError("Cursor auth loader failed", {
92
+ stage: "loader",
93
+ providerID: CURSOR_PROVIDER_ID,
94
+ ...errorDetails(error),
95
+ });
96
+ stopProxy();
97
+ if (provider) {
98
+ provider.models = {};
99
+ }
100
+ if (message !== lastModelDiscoveryError) {
101
+ lastModelDiscoveryError = message;
102
+ await showDiscoveryFailureToast(input, message);
103
+ }
104
+ return buildDisabledProviderConfig(message);
105
+ }
75
106
  },
76
107
  methods: [
77
108
  {
@@ -145,6 +176,43 @@ function buildCursorProviderModels(models, port) {
145
176
  },
146
177
  ]));
147
178
  }
179
+ async function showDiscoveryFailureToast(input, message) {
180
+ try {
181
+ await input.client.tui.showToast({
182
+ body: {
183
+ title: "Cursor plugin disabled",
184
+ message,
185
+ variant: "error",
186
+ duration: 8_000,
187
+ },
188
+ });
189
+ }
190
+ catch (error) {
191
+ logPluginWarn("Failed to display Cursor plugin toast", {
192
+ title: "Cursor plugin disabled",
193
+ message,
194
+ ...errorDetails(error),
195
+ });
196
+ }
197
+ }
198
+ function buildDisabledProviderConfig(message) {
199
+ return {
200
+ baseURL: "http://127.0.0.1/cursor-disabled/v1",
201
+ apiKey: "cursor-disabled",
202
+ async fetch() {
203
+ return new Response(JSON.stringify({
204
+ error: {
205
+ message,
206
+ type: "server_error",
207
+ code: "cursor_model_discovery_failed",
208
+ },
209
+ }), {
210
+ status: 503,
211
+ headers: { "Content-Type": "application/json" },
212
+ });
213
+ },
214
+ };
215
+ }
148
216
  // $/M token rates from cursor.com/docs/models-and-pricing
149
217
  const MODEL_COST_TABLE = {
150
218
  // Anthropic
@@ -0,0 +1,6 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ export declare function configurePluginLogger(input: PluginInput): void;
3
+ export declare function errorDetails(error: unknown): Record<string, unknown>;
4
+ export declare function logPluginWarn(message: string, extra?: Record<string, unknown>): void;
5
+ export declare function logPluginError(message: string, extra?: Record<string, unknown>): void;
6
+ export declare function flushPluginLogs(): Promise<void>;
package/dist/logger.js ADDED
@@ -0,0 +1,142 @@
1
+ const PLUGIN_LOG_SERVICE = "opencode-cursor-oauth";
2
+ const MAX_STRING_LENGTH = 1_500;
3
+ const MAX_ARRAY_LENGTH = 20;
4
+ const MAX_OBJECT_KEYS = 25;
5
+ let currentLogger;
6
+ let pendingLogWrites = Promise.resolve();
7
+ export function configurePluginLogger(input) {
8
+ currentLogger = {
9
+ client: input.client,
10
+ directory: input.directory,
11
+ };
12
+ }
13
+ export function errorDetails(error) {
14
+ if (error instanceof Error) {
15
+ return {
16
+ errorName: error.name,
17
+ errorMessage: error.message,
18
+ errorStack: error.stack,
19
+ errorCause: serializeValue(error.cause, 1),
20
+ };
21
+ }
22
+ return {
23
+ errorType: typeof error,
24
+ errorValue: serializeValue(error, 1),
25
+ };
26
+ }
27
+ export function logPluginWarn(message, extra = {}) {
28
+ logPlugin("warn", message, extra);
29
+ }
30
+ export function logPluginError(message, extra = {}) {
31
+ logPlugin("error", message, extra);
32
+ }
33
+ export function flushPluginLogs() {
34
+ return pendingLogWrites;
35
+ }
36
+ function logPlugin(level, message, extra) {
37
+ const serializedExtra = serializeValue(extra, 0);
38
+ writeConsoleLog(level, message, serializedExtra);
39
+ if (!currentLogger?.client?.app?.log) {
40
+ return;
41
+ }
42
+ pendingLogWrites = pendingLogWrites
43
+ .catch(() => { })
44
+ .then(async () => {
45
+ try {
46
+ await currentLogger?.client.app.log({
47
+ query: { directory: currentLogger.directory },
48
+ body: {
49
+ service: PLUGIN_LOG_SERVICE,
50
+ level,
51
+ message,
52
+ extra: serializedExtra,
53
+ },
54
+ });
55
+ }
56
+ catch (logError) {
57
+ writeConsoleLog("warn", "Failed to forward plugin log to OpenCode", {
58
+ originalLevel: level,
59
+ originalMessage: message,
60
+ ...errorDetails(logError),
61
+ });
62
+ }
63
+ });
64
+ }
65
+ function writeConsoleLog(level, message, extra) {
66
+ const prefix = `[${PLUGIN_LOG_SERVICE}] ${message}`;
67
+ const suffix = Object.keys(extra).length > 0 ? ` ${JSON.stringify(extra)}` : "";
68
+ if (level === "error") {
69
+ console.error(`${prefix}${suffix}`);
70
+ return;
71
+ }
72
+ console.warn(`${prefix}${suffix}`);
73
+ }
74
+ function serializeValue(value, depth, seen = new WeakSet()) {
75
+ if (value === null || value === undefined)
76
+ return value;
77
+ if (typeof value === "string")
78
+ return truncateString(value);
79
+ const valueType = typeof value;
80
+ if (valueType === "number" || valueType === "boolean")
81
+ return value;
82
+ if (valueType === "bigint")
83
+ return value.toString();
84
+ if (valueType === "symbol")
85
+ return String(value);
86
+ if (valueType === "function")
87
+ return `[function ${value.name || "anonymous"}]`;
88
+ if (value instanceof URL)
89
+ return value.toString();
90
+ if (value instanceof Headers)
91
+ return Object.fromEntries(value.entries());
92
+ if (value instanceof Error) {
93
+ return {
94
+ name: value.name,
95
+ message: value.message,
96
+ stack: truncateString(value.stack),
97
+ cause: serializeValue(value.cause, depth + 1, seen),
98
+ };
99
+ }
100
+ if (value instanceof Uint8Array) {
101
+ return serializeBinary(value);
102
+ }
103
+ if (Array.isArray(value)) {
104
+ if (depth >= 3)
105
+ return `[array(${value.length})]`;
106
+ return value.slice(0, MAX_ARRAY_LENGTH).map((entry) => serializeValue(entry, depth + 1, seen));
107
+ }
108
+ if (typeof value === "object") {
109
+ if (seen.has(value))
110
+ return "[circular]";
111
+ seen.add(value);
112
+ if (depth >= 3) {
113
+ return `[object ${value.constructor?.name || "Object"}]`;
114
+ }
115
+ const entries = Object.entries(value).slice(0, MAX_OBJECT_KEYS);
116
+ return Object.fromEntries(entries.map(([key, entry]) => [key, serializeValue(entry, depth + 1, seen)]));
117
+ }
118
+ return String(value);
119
+ }
120
+ function serializeBinary(value) {
121
+ const text = new TextDecoder().decode(value);
122
+ const printable = /^[\x09\x0a\x0d\x20-\x7e]*$/.test(text);
123
+ if (printable) {
124
+ return {
125
+ type: "uint8array",
126
+ length: value.length,
127
+ text: truncateString(text),
128
+ };
129
+ }
130
+ return {
131
+ type: "uint8array",
132
+ length: value.length,
133
+ base64: truncateString(Buffer.from(value).toString("base64")),
134
+ };
135
+ }
136
+ function truncateString(value) {
137
+ if (value === undefined)
138
+ return undefined;
139
+ if (value.length <= MAX_STRING_LENGTH)
140
+ return value;
141
+ return `${value.slice(0, MAX_STRING_LENGTH - 3)}...`;
142
+ }
package/dist/models.d.ts CHANGED
@@ -5,6 +5,9 @@ export interface CursorModel {
5
5
  contextWindow: number;
6
6
  maxTokens: number;
7
7
  }
8
+ export declare class CursorModelDiscoveryError extends Error {
9
+ constructor(message: string);
10
+ }
8
11
  export declare function getCursorModels(apiKey: string): Promise<CursorModel[]>;
9
12
  /** @internal Test-only. */
10
13
  export declare function clearModelCache(): void;
package/dist/models.js CHANGED
@@ -1,13 +1,10 @@
1
- /**
2
- * Cursor model discovery via GetUsableModels.
3
- * Uses the H2 bridge for transport. Falls back to a hardcoded list
4
- * when discovery fails.
5
- */
6
1
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
7
2
  import { z } from "zod";
3
+ import { errorDetails, logPluginError, logPluginWarn } from "./logger";
8
4
  import { callCursorUnaryRpc } from "./proxy";
9
5
  import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema, } from "./proto/agent_pb";
10
6
  const GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
7
+ const MODEL_DISCOVERY_TIMEOUT_MS = 5_000;
11
8
  const DEFAULT_CONTEXT_WINDOW = 200_000;
12
9
  const DEFAULT_MAX_TOKENS = 64_000;
13
10
  const CursorModelDetailsSchema = z.object({
@@ -22,24 +19,12 @@ const CursorModelDetailsSchema = z.object({
22
19
  .transform((aliases) => (aliases ?? []).filter((alias) => typeof alias === "string")),
23
20
  thinkingDetails: z.unknown().optional(),
24
21
  });
25
- const FALLBACK_MODELS = [
26
- // Composer models
27
- { id: "composer-1", name: "Composer 1", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
28
- { id: "composer-1.5", name: "Composer 1.5", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
29
- // Claude models
30
- { id: "claude-4.6-opus-high", name: "Claude 4.6 Opus", reasoning: true, contextWindow: 200_000, maxTokens: 128_000 },
31
- { id: "claude-4.6-sonnet-medium", name: "Claude 4.6 Sonnet", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
32
- { id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet", reasoning: true, contextWindow: 200_000, maxTokens: 64_000 },
33
- // GPT models
34
- { id: "gpt-5.4-medium", name: "GPT-5.4", reasoning: true, contextWindow: 272_000, maxTokens: 128_000 },
35
- { id: "gpt-5.2", name: "GPT-5.2", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
36
- { id: "gpt-5.2-codex", name: "GPT-5.2 Codex", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
37
- { id: "gpt-5.3-codex", name: "GPT-5.3 Codex", reasoning: true, contextWindow: 400_000, maxTokens: 128_000 },
38
- { id: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark", reasoning: true, contextWindow: 128_000, maxTokens: 128_000 },
39
- // Other models
40
- { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", reasoning: true, contextWindow: 1_000_000, maxTokens: 64_000 },
41
- { id: "grok-code-fast-1", name: "Grok Code Fast 1", reasoning: false, contextWindow: 128_000, maxTokens: 64_000 },
42
- ];
22
+ export class CursorModelDiscoveryError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "CursorModelDiscoveryError";
26
+ }
27
+ }
43
28
  async function fetchCursorUsableModels(apiKey) {
44
29
  try {
45
30
  const requestPayload = create(GetUsableModelsRequestSchema, {});
@@ -48,18 +33,51 @@ async function fetchCursorUsableModels(apiKey) {
48
33
  accessToken: apiKey,
49
34
  rpcPath: GET_USABLE_MODELS_PATH,
50
35
  requestBody,
36
+ timeoutMs: MODEL_DISCOVERY_TIMEOUT_MS,
51
37
  });
52
- if (response.timedOut || response.exitCode !== 0 || response.body.length === 0) {
53
- return null;
38
+ if (response.timedOut) {
39
+ logPluginError("Cursor model discovery timed out", {
40
+ rpcPath: GET_USABLE_MODELS_PATH,
41
+ timeoutMs: MODEL_DISCOVERY_TIMEOUT_MS,
42
+ });
43
+ throw new CursorModelDiscoveryError(`Cursor model discovery timed out after ${MODEL_DISCOVERY_TIMEOUT_MS}ms.`);
44
+ }
45
+ if (response.exitCode !== 0) {
46
+ logPluginError("Cursor model discovery HTTP failure", {
47
+ rpcPath: GET_USABLE_MODELS_PATH,
48
+ exitCode: response.exitCode,
49
+ responseBody: response.body,
50
+ });
51
+ throw new CursorModelDiscoveryError(buildDiscoveryHttpError(response.exitCode, response.body));
52
+ }
53
+ if (response.body.length === 0) {
54
+ logPluginWarn("Cursor model discovery returned an empty response", {
55
+ rpcPath: GET_USABLE_MODELS_PATH,
56
+ });
57
+ throw new CursorModelDiscoveryError("Cursor model discovery returned an empty response.");
54
58
  }
55
59
  const decoded = decodeGetUsableModelsResponse(response.body);
56
- if (!decoded)
57
- return null;
60
+ if (!decoded) {
61
+ logPluginError("Cursor model discovery returned an unreadable response", {
62
+ rpcPath: GET_USABLE_MODELS_PATH,
63
+ responseBody: response.body,
64
+ });
65
+ throw new CursorModelDiscoveryError("Cursor model discovery returned an unreadable response.");
66
+ }
58
67
  const models = normalizeCursorModels(decoded.models);
59
- return models.length > 0 ? models : null;
68
+ if (models.length === 0) {
69
+ throw new CursorModelDiscoveryError("Cursor model discovery returned no usable models.");
70
+ }
71
+ return models;
60
72
  }
61
- catch {
62
- return null;
73
+ catch (error) {
74
+ if (error instanceof CursorModelDiscoveryError)
75
+ throw error;
76
+ logPluginError("Cursor model discovery crashed", {
77
+ rpcPath: GET_USABLE_MODELS_PATH,
78
+ ...errorDetails(error),
79
+ });
80
+ throw new CursorModelDiscoveryError("Cursor model discovery failed.");
63
81
  }
64
82
  }
65
83
  let cachedModels = null;
@@ -67,13 +85,43 @@ export async function getCursorModels(apiKey) {
67
85
  if (cachedModels)
68
86
  return cachedModels;
69
87
  const discovered = await fetchCursorUsableModels(apiKey);
70
- cachedModels = discovered && discovered.length > 0 ? discovered : FALLBACK_MODELS;
88
+ cachedModels = discovered;
71
89
  return cachedModels;
72
90
  }
73
91
  /** @internal Test-only. */
74
92
  export function clearModelCache() {
75
93
  cachedModels = null;
76
94
  }
95
+ function buildDiscoveryHttpError(exitCode, body) {
96
+ const detail = extractDiscoveryErrorDetail(body);
97
+ const protocolHint = exitCode === 464
98
+ ? " Likely protocol mismatch: Cursor appears to expect an HTTP/2 Connect unary request."
99
+ : "";
100
+ if (!detail) {
101
+ return `Cursor model discovery failed with HTTP ${exitCode}.${protocolHint}`;
102
+ }
103
+ return `Cursor model discovery failed with HTTP ${exitCode}: ${detail}.${protocolHint}`;
104
+ }
105
+ function extractDiscoveryErrorDetail(body) {
106
+ if (body.length === 0)
107
+ return null;
108
+ const text = new TextDecoder().decode(body).trim();
109
+ if (!text)
110
+ return null;
111
+ try {
112
+ const parsed = JSON.parse(text);
113
+ const code = typeof parsed.code === "string" ? parsed.code : undefined;
114
+ const message = typeof parsed.message === "string" ? parsed.message : undefined;
115
+ if (message && code)
116
+ return `${message} (${code})`;
117
+ if (message)
118
+ return message;
119
+ if (code)
120
+ return code;
121
+ }
122
+ catch { }
123
+ return text.length > 200 ? `${text.slice(0, 197)}...` : text;
124
+ }
77
125
  function decodeGetUsableModelsResponse(payload) {
78
126
  try {
79
127
  return fromBinary(GetUsableModelsResponseSchema, payload);
package/dist/proxy.d.ts CHANGED
@@ -4,6 +4,7 @@ interface CursorUnaryRpcOptions {
4
4
  requestBody: Uint8Array;
5
5
  url?: string;
6
6
  timeoutMs?: number;
7
+ transport?: "auto" | "fetch" | "http2";
7
8
  }
8
9
  export declare function callCursorUnaryRpc(options: CursorUnaryRpcOptions): Promise<{
9
10
  body: Uint8Array;
package/dist/proxy.js CHANGED
@@ -16,8 +16,11 @@ import { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protob
16
16
  import { ValueSchema } from "@bufbuild/protobuf/wkt";
17
17
  import { AgentClientMessageSchema, AgentRunRequestSchema, AgentServerMessageSchema, BidiRequestIdSchema, ClientHeartbeatSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolDefinitionSchema, McpToolResultContentItemSchema, ModelDetailsSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, UserMessageSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "./proto/agent_pb";
18
18
  import { createHash } from "node:crypto";
19
+ import { connect as connectHttp2 } from "node:http2";
20
+ import { errorDetails, logPluginError, logPluginWarn } from "./logger";
19
21
  const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
20
22
  const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
23
+ const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
21
24
  const CONNECT_END_STREAM_FLAG = 0b00000010;
22
25
  const SSE_HEADERS = {
23
26
  "Content-Type": "text/event-stream",
@@ -47,18 +50,19 @@ function frameConnectMessage(data, flags = 0) {
47
50
  return frame;
48
51
  }
49
52
  function buildCursorHeaders(options, contentType, extra = {}) {
50
- const headers = new Headers({
53
+ const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
54
+ return headers;
55
+ }
56
+ function buildCursorHeaderValues(options, contentType, extra = {}) {
57
+ return {
51
58
  authorization: `Bearer ${options.accessToken}`,
52
59
  "content-type": contentType,
53
60
  "x-ghost-mode": "true",
54
61
  "x-cursor-client-version": CURSOR_CLIENT_VERSION,
55
62
  "x-cursor-client-type": "cli",
56
63
  "x-request-id": crypto.randomUUID(),
57
- });
58
- for (const [key, value] of Object.entries(extra)) {
59
- headers.set(key, value);
60
- }
61
- return headers;
64
+ ...extra,
65
+ };
62
66
  }
63
67
  function encodeVarint(value) {
64
68
  if (!Number.isSafeInteger(value) || value < 0) {
@@ -130,6 +134,11 @@ async function createCursorSession(options) {
130
134
  });
131
135
  if (!response.ok || !response.body) {
132
136
  const errorBody = await response.text().catch(() => "");
137
+ logPluginError("Cursor RunSSE request failed", {
138
+ requestId: options.requestId,
139
+ status: response.status,
140
+ responseBody: errorBody,
141
+ });
133
142
  throw new Error(`RunSSE failed: ${response.status}${errorBody ? ` ${errorBody}` : ""}`);
134
143
  }
135
144
  const cbs = {
@@ -160,6 +169,12 @@ async function createCursorSession(options) {
160
169
  });
161
170
  if (!appendResponse.ok) {
162
171
  const errorBody = await appendResponse.text().catch(() => "");
172
+ logPluginError("Cursor BidiAppend request failed", {
173
+ requestId: options.requestId,
174
+ appendSeqno: appendSeqno - 1,
175
+ status: appendResponse.status,
176
+ responseBody: errorBody,
177
+ });
163
178
  throw new Error(`BidiAppend failed: ${appendResponse.status}${errorBody ? ` ${errorBody}` : ""}`);
164
179
  }
165
180
  await appendResponse.arrayBuffer().catch(() => undefined);
@@ -183,7 +198,11 @@ async function createCursorSession(options) {
183
198
  }
184
199
  }
185
200
  }
186
- catch {
201
+ catch (error) {
202
+ logPluginWarn("Cursor stream reader closed with error", {
203
+ requestId: options.requestId,
204
+ ...errorDetails(error),
205
+ });
187
206
  finish(alive ? 1 : closeCode);
188
207
  }
189
208
  })();
@@ -196,7 +215,11 @@ async function createCursorSession(options) {
196
215
  return;
197
216
  writeChain = writeChain
198
217
  .then(() => append(data))
199
- .catch(() => {
218
+ .catch((error) => {
219
+ logPluginError("Cursor stream append failed", {
220
+ requestId: options.requestId,
221
+ ...errorDetails(error),
222
+ });
200
223
  try {
201
224
  abortController.abort();
202
225
  }
@@ -236,6 +259,17 @@ async function createCursorSession(options) {
236
259
  };
237
260
  }
238
261
  export async function callCursorUnaryRpc(options) {
262
+ const target = new URL(options.rpcPath, options.url ?? CURSOR_API_URL);
263
+ const transport = options.transport ?? "auto";
264
+ if (transport === "http2" || (transport === "auto" && target.protocol === "https:")) {
265
+ const http2Result = await callCursorUnaryRpcOverHttp2(options, target);
266
+ if (transport === "http2" || http2Result.timedOut || http2Result.exitCode !== 1) {
267
+ return http2Result;
268
+ }
269
+ }
270
+ return callCursorUnaryRpcOverFetch(options, target);
271
+ }
272
+ async function callCursorUnaryRpcOverFetch(options, target) {
239
273
  let timedOut = false;
240
274
  const timeoutMs = options.timeoutMs ?? 5_000;
241
275
  const controller = new AbortController();
@@ -246,9 +280,13 @@ export async function callCursorUnaryRpc(options) {
246
280
  }, timeoutMs)
247
281
  : undefined;
248
282
  try {
249
- const response = await fetch(new URL(options.rpcPath, options.url ?? CURSOR_API_URL), {
283
+ const response = await fetch(target, {
250
284
  method: "POST",
251
- headers: buildCursorHeaders(options, "application/proto"),
285
+ headers: buildCursorHeaders(options, "application/proto", {
286
+ accept: "application/proto, application/json",
287
+ "connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
288
+ "connect-timeout-ms": String(timeoutMs),
289
+ }),
252
290
  body: toFetchBody(options.requestBody),
253
291
  signal: controller.signal,
254
292
  });
@@ -260,6 +298,12 @@ export async function callCursorUnaryRpc(options) {
260
298
  };
261
299
  }
262
300
  catch {
301
+ logPluginError("Cursor unary fetch transport failed", {
302
+ rpcPath: options.rpcPath,
303
+ url: target.toString(),
304
+ timeoutMs,
305
+ timedOut,
306
+ });
263
307
  return {
264
308
  body: new Uint8Array(),
265
309
  exitCode: timedOut ? 124 : 1,
@@ -271,6 +315,121 @@ export async function callCursorUnaryRpc(options) {
271
315
  clearTimeout(timeout);
272
316
  }
273
317
  }
318
+ async function callCursorUnaryRpcOverHttp2(options, target) {
319
+ const timeoutMs = options.timeoutMs ?? 5_000;
320
+ const authority = `${target.protocol}//${target.host}`;
321
+ return new Promise((resolve) => {
322
+ let settled = false;
323
+ let timedOut = false;
324
+ let session;
325
+ let stream;
326
+ const finish = (result) => {
327
+ if (settled)
328
+ return;
329
+ settled = true;
330
+ if (timeout)
331
+ clearTimeout(timeout);
332
+ try {
333
+ stream?.close();
334
+ }
335
+ catch { }
336
+ try {
337
+ session?.close();
338
+ }
339
+ catch { }
340
+ resolve(result);
341
+ };
342
+ const timeout = timeoutMs > 0
343
+ ? setTimeout(() => {
344
+ timedOut = true;
345
+ finish({
346
+ body: new Uint8Array(),
347
+ exitCode: 124,
348
+ timedOut: true,
349
+ });
350
+ }, timeoutMs)
351
+ : undefined;
352
+ try {
353
+ session = connectHttp2(authority);
354
+ session.once("error", (error) => {
355
+ logPluginError("Cursor unary HTTP/2 session failed", {
356
+ rpcPath: options.rpcPath,
357
+ url: target.toString(),
358
+ timedOut,
359
+ ...errorDetails(error),
360
+ });
361
+ finish({
362
+ body: new Uint8Array(),
363
+ exitCode: timedOut ? 124 : 1,
364
+ timedOut,
365
+ });
366
+ });
367
+ const headers = {
368
+ ":method": "POST",
369
+ ":path": `${target.pathname}${target.search}`,
370
+ ...buildCursorHeaderValues(options, "application/proto", {
371
+ accept: "application/proto, application/json",
372
+ "connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
373
+ "connect-timeout-ms": String(timeoutMs),
374
+ }),
375
+ };
376
+ stream = session.request(headers);
377
+ let statusCode = 0;
378
+ const chunks = [];
379
+ stream.once("response", (responseHeaders) => {
380
+ const statusHeader = responseHeaders[":status"];
381
+ statusCode = typeof statusHeader === "number"
382
+ ? statusHeader
383
+ : Number(statusHeader ?? 0);
384
+ });
385
+ stream.on("data", (chunk) => {
386
+ chunks.push(Buffer.from(chunk));
387
+ });
388
+ stream.once("end", () => {
389
+ const body = new Uint8Array(Buffer.concat(chunks));
390
+ finish({
391
+ body,
392
+ exitCode: statusCode >= 200 && statusCode < 300 ? 0 : (statusCode || 1),
393
+ timedOut,
394
+ });
395
+ });
396
+ stream.once("error", (error) => {
397
+ logPluginError("Cursor unary HTTP/2 stream failed", {
398
+ rpcPath: options.rpcPath,
399
+ url: target.toString(),
400
+ timedOut,
401
+ ...errorDetails(error),
402
+ });
403
+ finish({
404
+ body: new Uint8Array(),
405
+ exitCode: timedOut ? 124 : 1,
406
+ timedOut,
407
+ });
408
+ });
409
+ // Bun's node:http2 client currently breaks on end(Buffer.alloc(0)) against
410
+ // Cursor's HTTPS endpoint, but a header-only end() succeeds for empty unary bodies.
411
+ if (options.requestBody.length > 0) {
412
+ stream.end(Buffer.from(options.requestBody));
413
+ }
414
+ else {
415
+ stream.end();
416
+ }
417
+ }
418
+ catch (error) {
419
+ logPluginError("Cursor unary HTTP/2 setup failed", {
420
+ rpcPath: options.rpcPath,
421
+ url: target.toString(),
422
+ timedOut,
423
+ ...errorDetails(error),
424
+ });
425
+ finish({
426
+ body: new Uint8Array(),
427
+ exitCode: timedOut ? 124 : 1,
428
+ timedOut,
429
+ });
430
+ }
431
+ });
432
+ }
274
433
  let proxyServer;
275
434
  let proxyPort;
276
435
  let proxyAccessTokenProvider;
@@ -316,6 +475,11 @@ export async function startProxy(getAccessToken, models = []) {
316
475
  }
317
476
  catch (err) {
318
477
  const message = err instanceof Error ? err.message : String(err);
478
+ logPluginError("Cursor proxy request failed", {
479
+ path: url.pathname,
480
+ method: req.method,
481
+ ...errorDetails(err),
482
+ });
319
483
  return new Response(JSON.stringify({
320
484
  error: { message, type: "server_error", code: "internal_error" },
321
485
  }), { status: 500, headers: { "Content-Type": "application/json" } });
@@ -382,7 +546,7 @@ function handleChatCompletion(body, accessToken) {
382
546
  let stored = conversationStates.get(convKey);
383
547
  if (!stored) {
384
548
  stored = {
385
- conversationId: deterministicConversationId(convKey),
549
+ conversationId: crypto.randomUUID(),
386
550
  checkpoint: null,
387
551
  blobStore: new Map(),
388
552
  lastAccessMs: Date.now(),
@@ -602,6 +766,12 @@ function makeHeartbeatBytes() {
602
766
  });
603
767
  return toBinary(AgentClientMessageSchema, heartbeat);
604
768
  }
769
+ function scheduleBridgeEnd(bridge) {
770
+ queueMicrotask(() => {
771
+ if (bridge.alive)
772
+ bridge.end();
773
+ });
774
+ }
605
775
  /**
606
776
  * Create a stateful parser for Connect protocol frames.
607
777
  * Handles buffering partial data across chunks.
@@ -744,6 +914,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
744
914
  const blobId = kvMsg.message.value.blobId;
745
915
  const blobIdKey = Buffer.from(blobId).toString("hex");
746
916
  const blobData = blobStore.get(blobIdKey);
917
+ if (!blobData) {
918
+ logPluginWarn("Cursor requested missing blob", {
919
+ blobId: blobIdKey,
920
+ knownBlobCount: blobStore.size,
921
+ });
922
+ }
747
923
  sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
748
924
  }
749
925
  else if (kvCase === "setBlobArgs") {
@@ -919,28 +1095,16 @@ function deriveBridgeKey(modelId, messages) {
919
1095
  }
920
1096
  /** Derive a key for conversation state. Model-independent so context survives model switches. */
921
1097
  function deriveConversationKey(messages) {
922
- const firstUserMsg = messages.find((m) => m.role === "user");
923
- const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
924
1098
  return createHash("sha256")
925
- .update(`conv:${firstUserText.slice(0, 200)}`)
1099
+ .update(buildConversationFingerprint(messages))
926
1100
  .digest("hex")
927
1101
  .slice(0, 16);
928
1102
  }
929
- /** Deterministic UUID derived from convKey so Cursor's server-side conversation
930
- * persists across proxy restarts. Formats 16 bytes of SHA-256 as a v4-shaped UUID. */
931
- function deterministicConversationId(convKey) {
932
- const hex = createHash("sha256")
933
- .update(`cursor-conv-id:${convKey}`)
934
- .digest("hex")
935
- .slice(0, 32);
936
- // Format as UUID: xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx
937
- return [
938
- hex.slice(0, 8),
939
- hex.slice(8, 12),
940
- `4${hex.slice(13, 16)}`,
941
- `${(0x8 | (parseInt(hex[16], 16) & 0x3)).toString(16)}${hex.slice(17, 20)}`,
942
- hex.slice(20, 32),
943
- ].join("-");
1103
+ function buildConversationFingerprint(messages) {
1104
+ return messages.map((message) => {
1105
+ const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
1106
+ return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
1107
+ }).join("\n---\n");
944
1108
  }
945
1109
  /** Create an SSE streaming Response that reads from a live bridge. */
946
1110
  function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
@@ -992,6 +1156,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
992
1156
  };
993
1157
  const tagFilter = createThinkingTagFilter();
994
1158
  let mcpExecReceived = false;
1159
+ let endStreamError = null;
995
1160
  const processChunk = createConnectFrameParser((messageBytes) => {
996
1161
  try {
997
1162
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
@@ -1051,10 +1216,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1051
1216
  // Skip unparseable messages
1052
1217
  }
1053
1218
  }, (endStreamBytes) => {
1054
- const endError = parseConnectEndStream(endStreamBytes);
1055
- if (endError) {
1056
- sendSSE(makeChunk({ content: `\n[Error: ${endError.message}]` }));
1219
+ endStreamError = parseConnectEndStream(endStreamBytes);
1220
+ if (endStreamError) {
1221
+ logPluginError("Cursor stream returned Connect end-stream error", {
1222
+ modelId,
1223
+ bridgeKey,
1224
+ convKey,
1225
+ ...errorDetails(endStreamError),
1226
+ });
1057
1227
  }
1228
+ scheduleBridgeEnd(bridge);
1058
1229
  });
1059
1230
  bridge.onData(processChunk);
1060
1231
  bridge.onClose((code) => {
@@ -1065,6 +1236,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1065
1236
  stored.blobStore.set(k, v);
1066
1237
  stored.lastAccessMs = Date.now();
1067
1238
  }
1239
+ if (endStreamError) {
1240
+ activeBridges.delete(bridgeKey);
1241
+ if (!closed) {
1242
+ closed = true;
1243
+ controller.error(endStreamError);
1244
+ }
1245
+ return;
1246
+ }
1068
1247
  if (!mcpExecReceived) {
1069
1248
  const flushed = tagFilter.flush();
1070
1249
  if (flushed.reasoning)
@@ -1076,16 +1255,17 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1076
1255
  sendDone();
1077
1256
  closeController();
1078
1257
  }
1079
- else if (code !== 0) {
1080
- // Bridge died while tool calls are pending (timeout, crash, etc.).
1081
- // Close the SSE stream so the client doesn't hang forever.
1082
- sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
1083
- sendSSE(makeChunk({}, "stop"));
1084
- sendSSE(makeUsageChunk());
1085
- sendDone();
1086
- closeController();
1087
- // Remove stale entry so the next request doesn't try to resume it.
1258
+ else {
1088
1259
  activeBridges.delete(bridgeKey);
1260
+ if (code !== 0 && !closed) {
1261
+ // Bridge died while tool calls are pending (timeout, crash, etc.).
1262
+ // Close the SSE stream so the client doesn't hang forever.
1263
+ sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
1264
+ sendSSE(makeChunk({}, "stop"));
1265
+ sendSSE(makeUsageChunk());
1266
+ sendDone();
1267
+ closeController();
1268
+ }
1089
1269
  }
1090
1270
  });
1091
1271
  },
@@ -1154,7 +1334,7 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
1154
1334
  async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
1155
1335
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1156
1336
  const created = Math.floor(Date.now() / 1000);
1157
- const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
1337
+ const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
1158
1338
  return new Response(JSON.stringify({
1159
1339
  id: completionId,
1160
1340
  object: "chat.completion",
@@ -1170,9 +1350,10 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
1170
1350
  usage,
1171
1351
  }), { headers: { "Content-Type": "application/json" } });
1172
1352
  }
1173
- async function collectFullResponse(payload, accessToken, convKey) {
1174
- const { promise, resolve } = Promise.withResolvers();
1353
+ async function collectFullResponse(payload, accessToken, modelId, convKey) {
1354
+ const { promise, resolve, reject } = Promise.withResolvers();
1175
1355
  let fullText = "";
1356
+ let endStreamError = null;
1176
1357
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1177
1358
  const state = {
1178
1359
  toolCallIndex: 0,
@@ -1200,7 +1381,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
1200
1381
  catch {
1201
1382
  // Skip
1202
1383
  }
1203
- }, () => { }));
1384
+ }, (endStreamBytes) => {
1385
+ endStreamError = parseConnectEndStream(endStreamBytes);
1386
+ if (endStreamError) {
1387
+ logPluginError("Cursor non-streaming response returned Connect end-stream error", {
1388
+ modelId,
1389
+ convKey,
1390
+ ...errorDetails(endStreamError),
1391
+ });
1392
+ }
1393
+ scheduleBridgeEnd(bridge);
1394
+ }));
1204
1395
  bridge.onClose(() => {
1205
1396
  clearInterval(heartbeatTimer);
1206
1397
  const stored = conversationStates.get(convKey);
@@ -1211,6 +1402,10 @@ async function collectFullResponse(payload, accessToken, convKey) {
1211
1402
  }
1212
1403
  const flushed = tagFilter.flush();
1213
1404
  fullText += flushed.content;
1405
+ if (endStreamError) {
1406
+ reject(endStreamError);
1407
+ return;
1408
+ }
1214
1409
  const usage = computeUsage(state);
1215
1410
  resolve({
1216
1411
  text: fullText,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwo/opencode-cursor-oauth",
3
- "version": "0.0.0-dev.c80ebcb27754",
3
+ "version": "0.0.0-dev.e795e5ffd849",
4
4
  "description": "OpenCode plugin that connects Cursor's API to OpenCode via OAuth, model discovery, and a local OpenAI-compatible proxy.",
5
5
  "license": "MIT",
6
6
  "type": "module",