@playwo/opencode-cursor-oauth 0.0.0-dev.b8e6dd72a8b6 → 0.0.0-dev.c6756e0849f3

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; if discovery fails, the plugin disables the Cursor provider for that load and shows a visible error toast instead of crashing OpenCode.
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,6 +1,7 @@
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, stopProxy } from "./proxy";
4
+ import { startProxy, stopProxy, } from "./proxy";
4
5
  const CURSOR_PROVIDER_ID = "cursor";
5
6
  let lastModelDiscoveryError = null;
6
7
  /**
@@ -8,37 +9,90 @@ let lastModelDiscoveryError = null;
8
9
  * Register in opencode.json: { "plugin": ["opencode-cursor-oauth"] }
9
10
  */
10
11
  export const CursorAuthPlugin = async (input) => {
12
+ configurePluginLogger(input);
11
13
  return {
12
14
  auth: {
13
15
  provider: CURSOR_PROVIDER_ID,
14
16
  async loader(getAuth, provider) {
15
- const auth = await getAuth();
16
- if (!auth || auth.type !== "oauth")
17
- return {};
18
- // Ensure we have a valid access token, refreshing if expired
19
- let accessToken = auth.access;
20
- if (!accessToken || auth.expires < Date.now()) {
21
- const refreshed = await refreshCursorToken(auth.refresh);
22
- await input.client.auth.set({
23
- path: { id: CURSOR_PROVIDER_ID },
24
- body: {
25
- type: "oauth",
26
- refresh: refreshed.refresh,
27
- access: refreshed.access,
28
- expires: refreshed.expires,
29
- },
30
- });
31
- accessToken = refreshed.access;
32
- }
33
- let models;
34
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);
25
+ await input.client.auth.set({
26
+ path: { id: CURSOR_PROVIDER_ID },
27
+ body: {
28
+ type: "oauth",
29
+ refresh: refreshed.refresh,
30
+ access: refreshed.access,
31
+ expires: refreshed.expires,
32
+ },
33
+ });
34
+ accessToken = refreshed.access;
35
+ }
36
+ let models;
35
37
  models = await getCursorModels(accessToken);
36
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;
48
+ }
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
+ };
37
86
  }
38
87
  catch (error) {
39
88
  const message = error instanceof Error
40
89
  ? error.message
41
90
  : "Cursor model discovery failed.";
91
+ logPluginError("Cursor auth loader failed", {
92
+ stage: "loader",
93
+ providerID: CURSOR_PROVIDER_ID,
94
+ ...errorDetails(error),
95
+ });
42
96
  stopProxy();
43
97
  if (provider) {
44
98
  provider.models = {};
@@ -49,48 +103,6 @@ export const CursorAuthPlugin = async (input) => {
49
103
  }
50
104
  return buildDisabledProviderConfig(message);
51
105
  }
52
- const port = await startProxy(async () => {
53
- const currentAuth = await getAuth();
54
- if (currentAuth.type !== "oauth") {
55
- throw new Error("Cursor auth not configured");
56
- }
57
- if (!currentAuth.access || currentAuth.expires < Date.now()) {
58
- const refreshed = await refreshCursorToken(currentAuth.refresh);
59
- await input.client.auth.set({
60
- path: { id: CURSOR_PROVIDER_ID },
61
- body: {
62
- type: "oauth",
63
- refresh: refreshed.refresh,
64
- access: refreshed.access,
65
- expires: refreshed.expires,
66
- },
67
- });
68
- return refreshed.access;
69
- }
70
- return currentAuth.access;
71
- }, models);
72
- if (provider) {
73
- provider.models = buildCursorProviderModels(models, port);
74
- }
75
- return {
76
- baseURL: `http://localhost:${port}/v1`,
77
- apiKey: "cursor-proxy",
78
- async fetch(requestInput, init) {
79
- if (init?.headers) {
80
- if (init.headers instanceof Headers) {
81
- init.headers.delete("authorization");
82
- }
83
- else if (Array.isArray(init.headers)) {
84
- init.headers = init.headers.filter(([key]) => key.toLowerCase() !== "authorization");
85
- }
86
- else {
87
- delete init.headers["authorization"];
88
- delete init.headers["Authorization"];
89
- }
90
- }
91
- return fetch(requestInput, init);
92
- },
93
- };
94
106
  },
95
107
  methods: [
96
108
  {
@@ -175,7 +187,13 @@ async function showDiscoveryFailureToast(input, message) {
175
187
  },
176
188
  });
177
189
  }
178
- catch { }
190
+ catch (error) {
191
+ logPluginWarn("Failed to display Cursor plugin toast", {
192
+ title: "Cursor plugin disabled",
193
+ message,
194
+ ...errorDetails(error),
195
+ });
196
+ }
179
197
  }
180
198
  function buildDisabledProviderConfig(message) {
181
199
  return {
@@ -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.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
2
2
  import { z } from "zod";
3
+ import { errorDetails, logPluginError, logPluginWarn } from "./logger";
3
4
  import { callCursorUnaryRpc } from "./proxy";
4
5
  import { GetUsableModelsRequestSchema, GetUsableModelsResponseSchema, } from "./proto/agent_pb";
5
6
  const GET_USABLE_MODELS_PATH = "/agent.v1.AgentService/GetUsableModels";
@@ -35,16 +36,32 @@ async function fetchCursorUsableModels(apiKey) {
35
36
  timeoutMs: MODEL_DISCOVERY_TIMEOUT_MS,
36
37
  });
37
38
  if (response.timedOut) {
39
+ logPluginError("Cursor model discovery timed out", {
40
+ rpcPath: GET_USABLE_MODELS_PATH,
41
+ timeoutMs: MODEL_DISCOVERY_TIMEOUT_MS,
42
+ });
38
43
  throw new CursorModelDiscoveryError(`Cursor model discovery timed out after ${MODEL_DISCOVERY_TIMEOUT_MS}ms.`);
39
44
  }
40
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
+ });
41
51
  throw new CursorModelDiscoveryError(buildDiscoveryHttpError(response.exitCode, response.body));
42
52
  }
43
53
  if (response.body.length === 0) {
54
+ logPluginWarn("Cursor model discovery returned an empty response", {
55
+ rpcPath: GET_USABLE_MODELS_PATH,
56
+ });
44
57
  throw new CursorModelDiscoveryError("Cursor model discovery returned an empty response.");
45
58
  }
46
59
  const decoded = decodeGetUsableModelsResponse(response.body);
47
60
  if (!decoded) {
61
+ logPluginError("Cursor model discovery returned an unreadable response", {
62
+ rpcPath: GET_USABLE_MODELS_PATH,
63
+ responseBody: response.body,
64
+ });
48
65
  throw new CursorModelDiscoveryError("Cursor model discovery returned an unreadable response.");
49
66
  }
50
67
  const models = normalizeCursorModels(decoded.models);
@@ -56,6 +73,10 @@ async function fetchCursorUsableModels(apiKey) {
56
73
  catch (error) {
57
74
  if (error instanceof CursorModelDiscoveryError)
58
75
  throw error;
76
+ logPluginError("Cursor model discovery crashed", {
77
+ rpcPath: GET_USABLE_MODELS_PATH,
78
+ ...errorDetails(error),
79
+ });
59
80
  throw new CursorModelDiscoveryError("Cursor model discovery failed.");
60
81
  }
61
82
  }
package/dist/proxy.js CHANGED
@@ -17,6 +17,7 @@ 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
19
  import { connect as connectHttp2 } from "node:http2";
20
+ import { errorDetails, logPluginError, logPluginWarn } from "./logger";
20
21
  const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
21
22
  const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
22
23
  const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
@@ -133,6 +134,11 @@ async function createCursorSession(options) {
133
134
  });
134
135
  if (!response.ok || !response.body) {
135
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
+ });
136
142
  throw new Error(`RunSSE failed: ${response.status}${errorBody ? ` ${errorBody}` : ""}`);
137
143
  }
138
144
  const cbs = {
@@ -163,6 +169,12 @@ async function createCursorSession(options) {
163
169
  });
164
170
  if (!appendResponse.ok) {
165
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
+ });
166
178
  throw new Error(`BidiAppend failed: ${appendResponse.status}${errorBody ? ` ${errorBody}` : ""}`);
167
179
  }
168
180
  await appendResponse.arrayBuffer().catch(() => undefined);
@@ -186,7 +198,11 @@ async function createCursorSession(options) {
186
198
  }
187
199
  }
188
200
  }
189
- catch {
201
+ catch (error) {
202
+ logPluginWarn("Cursor stream reader closed with error", {
203
+ requestId: options.requestId,
204
+ ...errorDetails(error),
205
+ });
190
206
  finish(alive ? 1 : closeCode);
191
207
  }
192
208
  })();
@@ -199,7 +215,11 @@ async function createCursorSession(options) {
199
215
  return;
200
216
  writeChain = writeChain
201
217
  .then(() => append(data))
202
- .catch(() => {
218
+ .catch((error) => {
219
+ logPluginError("Cursor stream append failed", {
220
+ requestId: options.requestId,
221
+ ...errorDetails(error),
222
+ });
203
223
  try {
204
224
  abortController.abort();
205
225
  }
@@ -278,6 +298,12 @@ async function callCursorUnaryRpcOverFetch(options, target) {
278
298
  };
279
299
  }
280
300
  catch {
301
+ logPluginError("Cursor unary fetch transport failed", {
302
+ rpcPath: options.rpcPath,
303
+ url: target.toString(),
304
+ timeoutMs,
305
+ timedOut,
306
+ });
281
307
  return {
282
308
  body: new Uint8Array(),
283
309
  exitCode: timedOut ? 124 : 1,
@@ -325,7 +351,13 @@ async function callCursorUnaryRpcOverHttp2(options, target) {
325
351
  : undefined;
326
352
  try {
327
353
  session = connectHttp2(authority);
328
- session.once("error", () => {
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
+ });
329
361
  finish({
330
362
  body: new Uint8Array(),
331
363
  exitCode: timedOut ? 124 : 1,
@@ -361,16 +393,35 @@ async function callCursorUnaryRpcOverHttp2(options, target) {
361
393
  timedOut,
362
394
  });
363
395
  });
364
- stream.once("error", () => {
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
+ });
365
403
  finish({
366
404
  body: new Uint8Array(),
367
405
  exitCode: timedOut ? 124 : 1,
368
406
  timedOut,
369
407
  });
370
408
  });
371
- stream.end(Buffer.from(options.requestBody));
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
+ }
372
417
  }
373
- catch {
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
+ });
374
425
  finish({
375
426
  body: new Uint8Array(),
376
427
  exitCode: timedOut ? 124 : 1,
@@ -424,6 +475,11 @@ export async function startProxy(getAccessToken, models = []) {
424
475
  }
425
476
  catch (err) {
426
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
+ });
427
483
  return new Response(JSON.stringify({
428
484
  error: { message, type: "server_error", code: "internal_error" },
429
485
  }), { status: 500, headers: { "Content-Type": "application/json" } });
@@ -490,7 +546,7 @@ function handleChatCompletion(body, accessToken) {
490
546
  let stored = conversationStates.get(convKey);
491
547
  if (!stored) {
492
548
  stored = {
493
- conversationId: deterministicConversationId(convKey),
549
+ conversationId: crypto.randomUUID(),
494
550
  checkpoint: null,
495
551
  blobStore: new Map(),
496
552
  lastAccessMs: Date.now(),
@@ -852,6 +908,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
852
908
  const blobId = kvMsg.message.value.blobId;
853
909
  const blobIdKey = Buffer.from(blobId).toString("hex");
854
910
  const blobData = blobStore.get(blobIdKey);
911
+ if (!blobData) {
912
+ logPluginWarn("Cursor requested missing blob", {
913
+ blobId: blobIdKey,
914
+ knownBlobCount: blobStore.size,
915
+ });
916
+ }
855
917
  sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
856
918
  }
857
919
  else if (kvCase === "setBlobArgs") {
@@ -1027,28 +1089,16 @@ function deriveBridgeKey(modelId, messages) {
1027
1089
  }
1028
1090
  /** Derive a key for conversation state. Model-independent so context survives model switches. */
1029
1091
  function deriveConversationKey(messages) {
1030
- const firstUserMsg = messages.find((m) => m.role === "user");
1031
- const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1032
1092
  return createHash("sha256")
1033
- .update(`conv:${firstUserText.slice(0, 200)}`)
1093
+ .update(buildConversationFingerprint(messages))
1034
1094
  .digest("hex")
1035
1095
  .slice(0, 16);
1036
1096
  }
1037
- /** Deterministic UUID derived from convKey so Cursor's server-side conversation
1038
- * persists across proxy restarts. Formats 16 bytes of SHA-256 as a v4-shaped UUID. */
1039
- function deterministicConversationId(convKey) {
1040
- const hex = createHash("sha256")
1041
- .update(`cursor-conv-id:${convKey}`)
1042
- .digest("hex")
1043
- .slice(0, 32);
1044
- // Format as UUID: xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx
1045
- return [
1046
- hex.slice(0, 8),
1047
- hex.slice(8, 12),
1048
- `4${hex.slice(13, 16)}`,
1049
- `${(0x8 | (parseInt(hex[16], 16) & 0x3)).toString(16)}${hex.slice(17, 20)}`,
1050
- hex.slice(20, 32),
1051
- ].join("-");
1097
+ function buildConversationFingerprint(messages) {
1098
+ return messages.map((message) => {
1099
+ const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
1100
+ return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
1101
+ }).join("\n---\n");
1052
1102
  }
1053
1103
  /** Create an SSE streaming Response that reads from a live bridge. */
1054
1104
  function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
@@ -1100,6 +1150,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1100
1150
  };
1101
1151
  const tagFilter = createThinkingTagFilter();
1102
1152
  let mcpExecReceived = false;
1153
+ let endStreamError = null;
1103
1154
  const processChunk = createConnectFrameParser((messageBytes) => {
1104
1155
  try {
1105
1156
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
@@ -1159,9 +1210,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1159
1210
  // Skip unparseable messages
1160
1211
  }
1161
1212
  }, (endStreamBytes) => {
1162
- const endError = parseConnectEndStream(endStreamBytes);
1163
- if (endError) {
1164
- sendSSE(makeChunk({ content: `\n[Error: ${endError.message}]` }));
1213
+ endStreamError = parseConnectEndStream(endStreamBytes);
1214
+ if (endStreamError) {
1215
+ logPluginError("Cursor stream returned Connect end-stream error", {
1216
+ modelId,
1217
+ bridgeKey,
1218
+ convKey,
1219
+ ...errorDetails(endStreamError),
1220
+ });
1165
1221
  }
1166
1222
  });
1167
1223
  bridge.onData(processChunk);
@@ -1173,6 +1229,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1173
1229
  stored.blobStore.set(k, v);
1174
1230
  stored.lastAccessMs = Date.now();
1175
1231
  }
1232
+ if (endStreamError) {
1233
+ activeBridges.delete(bridgeKey);
1234
+ if (!closed) {
1235
+ closed = true;
1236
+ controller.error(endStreamError);
1237
+ }
1238
+ return;
1239
+ }
1176
1240
  if (!mcpExecReceived) {
1177
1241
  const flushed = tagFilter.flush();
1178
1242
  if (flushed.reasoning)
@@ -1262,7 +1326,7 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
1262
1326
  async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
1263
1327
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1264
1328
  const created = Math.floor(Date.now() / 1000);
1265
- const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
1329
+ const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
1266
1330
  return new Response(JSON.stringify({
1267
1331
  id: completionId,
1268
1332
  object: "chat.completion",
@@ -1278,9 +1342,10 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
1278
1342
  usage,
1279
1343
  }), { headers: { "Content-Type": "application/json" } });
1280
1344
  }
1281
- async function collectFullResponse(payload, accessToken, convKey) {
1282
- const { promise, resolve } = Promise.withResolvers();
1345
+ async function collectFullResponse(payload, accessToken, modelId, convKey) {
1346
+ const { promise, resolve, reject } = Promise.withResolvers();
1283
1347
  let fullText = "";
1348
+ let endStreamError = null;
1284
1349
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1285
1350
  const state = {
1286
1351
  toolCallIndex: 0,
@@ -1308,7 +1373,16 @@ async function collectFullResponse(payload, accessToken, convKey) {
1308
1373
  catch {
1309
1374
  // Skip
1310
1375
  }
1311
- }, () => { }));
1376
+ }, (endStreamBytes) => {
1377
+ endStreamError = parseConnectEndStream(endStreamBytes);
1378
+ if (endStreamError) {
1379
+ logPluginError("Cursor non-streaming response returned Connect end-stream error", {
1380
+ modelId,
1381
+ convKey,
1382
+ ...errorDetails(endStreamError),
1383
+ });
1384
+ }
1385
+ }));
1312
1386
  bridge.onClose(() => {
1313
1387
  clearInterval(heartbeatTimer);
1314
1388
  const stored = conversationStates.get(convKey);
@@ -1319,6 +1393,10 @@ async function collectFullResponse(payload, accessToken, convKey) {
1319
1393
  }
1320
1394
  const flushed = tagFilter.flush();
1321
1395
  fullText += flushed.content;
1396
+ if (endStreamError) {
1397
+ reject(endStreamError);
1398
+ return;
1399
+ }
1322
1400
  const usage = computeUsage(state);
1323
1401
  resolve({
1324
1402
  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.b8e6dd72a8b6",
3
+ "version": "0.0.0-dev.c6756e0849f3",
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",