@playwo/opencode-cursor-oauth 0.0.0-dev.17eadae36ea6 → 0.0.0-dev.65683458d3f1
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 +19 -91
- package/dist/auth.js +26 -1
- package/dist/index.js +88 -63
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +142 -0
- package/dist/models.js +26 -2
- package/dist/proxy.d.ts +4 -0
- package/dist/proxy.js +244 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,103 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-cursor-oauth
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
5
|
+
## What it does
|
|
7
6
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
Then authenticate via the OpenCode UI (Settings → Providers → Cursor → Login).
|
|
91
23
|
|
|
92
|
-
|
|
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
|
-
-
|
|
26
|
+
- Cursor account with API access
|
|
27
|
+
- OpenCode 1.2+
|
|
98
28
|
|
|
99
|
-
##
|
|
29
|
+
## License
|
|
100
30
|
|
|
101
|
-
|
|
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 { OPENCODE_AGENT_HEADER, OPENCODE_MESSAGE_ID_HEADER, OPENCODE_SESSION_ID_HEADER, 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
|
{
|
|
@@ -116,6 +128,13 @@ export const CursorAuthPlugin = async (input) => {
|
|
|
116
128
|
},
|
|
117
129
|
],
|
|
118
130
|
},
|
|
131
|
+
async "chat.headers"(input, output) {
|
|
132
|
+
if (input.model.providerID !== CURSOR_PROVIDER_ID)
|
|
133
|
+
return;
|
|
134
|
+
output.headers[OPENCODE_SESSION_ID_HEADER] = input.sessionID;
|
|
135
|
+
output.headers[OPENCODE_AGENT_HEADER] = input.agent;
|
|
136
|
+
output.headers[OPENCODE_MESSAGE_ID_HEADER] = input.message.id;
|
|
137
|
+
},
|
|
119
138
|
};
|
|
120
139
|
};
|
|
121
140
|
function buildCursorProviderModels(models, port) {
|
|
@@ -175,7 +194,13 @@ async function showDiscoveryFailureToast(input, message) {
|
|
|
175
194
|
},
|
|
176
195
|
});
|
|
177
196
|
}
|
|
178
|
-
catch {
|
|
197
|
+
catch (error) {
|
|
198
|
+
logPluginWarn("Failed to display Cursor plugin toast", {
|
|
199
|
+
title: "Cursor plugin disabled",
|
|
200
|
+
message,
|
|
201
|
+
...errorDetails(error),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
179
204
|
}
|
|
180
205
|
function buildDisabledProviderConfig(message) {
|
|
181
206
|
return {
|
package/dist/logger.d.ts
ADDED
|
@@ -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
|
}
|
|
@@ -73,10 +94,13 @@ export function clearModelCache() {
|
|
|
73
94
|
}
|
|
74
95
|
function buildDiscoveryHttpError(exitCode, body) {
|
|
75
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
|
+
: "";
|
|
76
100
|
if (!detail) {
|
|
77
|
-
return `Cursor model discovery failed with HTTP ${exitCode}
|
|
101
|
+
return `Cursor model discovery failed with HTTP ${exitCode}.${protocolHint}`;
|
|
78
102
|
}
|
|
79
|
-
return `Cursor model discovery failed with HTTP ${exitCode}: ${detail}`;
|
|
103
|
+
return `Cursor model discovery failed with HTTP ${exitCode}: ${detail}.${protocolHint}`;
|
|
80
104
|
}
|
|
81
105
|
function extractDiscoveryErrorDetail(body) {
|
|
82
106
|
if (body.length === 0)
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
export declare const OPENCODE_SESSION_ID_HEADER = "x-opencode-session-id";
|
|
2
|
+
export declare const OPENCODE_AGENT_HEADER = "x-opencode-agent";
|
|
3
|
+
export declare const OPENCODE_MESSAGE_ID_HEADER = "x-opencode-message-id";
|
|
1
4
|
interface CursorUnaryRpcOptions {
|
|
2
5
|
accessToken: string;
|
|
3
6
|
rpcPath: string;
|
|
4
7
|
requestBody: Uint8Array;
|
|
5
8
|
url?: string;
|
|
6
9
|
timeoutMs?: number;
|
|
10
|
+
transport?: "auto" | "fetch" | "http2";
|
|
7
11
|
}
|
|
8
12
|
export declare function callCursorUnaryRpc(options: CursorUnaryRpcOptions): Promise<{
|
|
9
13
|
body: Uint8Array;
|
package/dist/proxy.js
CHANGED
|
@@ -16,14 +16,21 @@ 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;
|
|
25
|
+
export const OPENCODE_SESSION_ID_HEADER = "x-opencode-session-id";
|
|
26
|
+
export const OPENCODE_AGENT_HEADER = "x-opencode-agent";
|
|
27
|
+
export const OPENCODE_MESSAGE_ID_HEADER = "x-opencode-message-id";
|
|
22
28
|
const SSE_HEADERS = {
|
|
23
29
|
"Content-Type": "text/event-stream",
|
|
24
30
|
"Cache-Control": "no-cache",
|
|
25
31
|
Connection: "keep-alive",
|
|
26
32
|
};
|
|
33
|
+
const EPHEMERAL_CURSOR_AGENTS = new Set(["title", "summary"]);
|
|
27
34
|
// Active bridges keyed by a session token (derived from conversation state).
|
|
28
35
|
// When tool_calls are returned, the bridge stays alive. The next request
|
|
29
36
|
// with tool results looks up the bridge and sends mcpResult messages.
|
|
@@ -47,18 +54,19 @@ function frameConnectMessage(data, flags = 0) {
|
|
|
47
54
|
return frame;
|
|
48
55
|
}
|
|
49
56
|
function buildCursorHeaders(options, contentType, extra = {}) {
|
|
50
|
-
const headers = new Headers(
|
|
57
|
+
const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
|
|
58
|
+
return headers;
|
|
59
|
+
}
|
|
60
|
+
function buildCursorHeaderValues(options, contentType, extra = {}) {
|
|
61
|
+
return {
|
|
51
62
|
authorization: `Bearer ${options.accessToken}`,
|
|
52
63
|
"content-type": contentType,
|
|
53
64
|
"x-ghost-mode": "true",
|
|
54
65
|
"x-cursor-client-version": CURSOR_CLIENT_VERSION,
|
|
55
66
|
"x-cursor-client-type": "cli",
|
|
56
67
|
"x-request-id": crypto.randomUUID(),
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
headers.set(key, value);
|
|
60
|
-
}
|
|
61
|
-
return headers;
|
|
68
|
+
...extra,
|
|
69
|
+
};
|
|
62
70
|
}
|
|
63
71
|
function encodeVarint(value) {
|
|
64
72
|
if (!Number.isSafeInteger(value) || value < 0) {
|
|
@@ -130,6 +138,11 @@ async function createCursorSession(options) {
|
|
|
130
138
|
});
|
|
131
139
|
if (!response.ok || !response.body) {
|
|
132
140
|
const errorBody = await response.text().catch(() => "");
|
|
141
|
+
logPluginError("Cursor RunSSE request failed", {
|
|
142
|
+
requestId: options.requestId,
|
|
143
|
+
status: response.status,
|
|
144
|
+
responseBody: errorBody,
|
|
145
|
+
});
|
|
133
146
|
throw new Error(`RunSSE failed: ${response.status}${errorBody ? ` ${errorBody}` : ""}`);
|
|
134
147
|
}
|
|
135
148
|
const cbs = {
|
|
@@ -160,6 +173,12 @@ async function createCursorSession(options) {
|
|
|
160
173
|
});
|
|
161
174
|
if (!appendResponse.ok) {
|
|
162
175
|
const errorBody = await appendResponse.text().catch(() => "");
|
|
176
|
+
logPluginError("Cursor BidiAppend request failed", {
|
|
177
|
+
requestId: options.requestId,
|
|
178
|
+
appendSeqno: appendSeqno - 1,
|
|
179
|
+
status: appendResponse.status,
|
|
180
|
+
responseBody: errorBody,
|
|
181
|
+
});
|
|
163
182
|
throw new Error(`BidiAppend failed: ${appendResponse.status}${errorBody ? ` ${errorBody}` : ""}`);
|
|
164
183
|
}
|
|
165
184
|
await appendResponse.arrayBuffer().catch(() => undefined);
|
|
@@ -183,7 +202,11 @@ async function createCursorSession(options) {
|
|
|
183
202
|
}
|
|
184
203
|
}
|
|
185
204
|
}
|
|
186
|
-
catch {
|
|
205
|
+
catch (error) {
|
|
206
|
+
logPluginWarn("Cursor stream reader closed with error", {
|
|
207
|
+
requestId: options.requestId,
|
|
208
|
+
...errorDetails(error),
|
|
209
|
+
});
|
|
187
210
|
finish(alive ? 1 : closeCode);
|
|
188
211
|
}
|
|
189
212
|
})();
|
|
@@ -196,7 +219,11 @@ async function createCursorSession(options) {
|
|
|
196
219
|
return;
|
|
197
220
|
writeChain = writeChain
|
|
198
221
|
.then(() => append(data))
|
|
199
|
-
.catch(() => {
|
|
222
|
+
.catch((error) => {
|
|
223
|
+
logPluginError("Cursor stream append failed", {
|
|
224
|
+
requestId: options.requestId,
|
|
225
|
+
...errorDetails(error),
|
|
226
|
+
});
|
|
200
227
|
try {
|
|
201
228
|
abortController.abort();
|
|
202
229
|
}
|
|
@@ -236,6 +263,17 @@ async function createCursorSession(options) {
|
|
|
236
263
|
};
|
|
237
264
|
}
|
|
238
265
|
export async function callCursorUnaryRpc(options) {
|
|
266
|
+
const target = new URL(options.rpcPath, options.url ?? CURSOR_API_URL);
|
|
267
|
+
const transport = options.transport ?? "auto";
|
|
268
|
+
if (transport === "http2" || (transport === "auto" && target.protocol === "https:")) {
|
|
269
|
+
const http2Result = await callCursorUnaryRpcOverHttp2(options, target);
|
|
270
|
+
if (transport === "http2" || http2Result.timedOut || http2Result.exitCode !== 1) {
|
|
271
|
+
return http2Result;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return callCursorUnaryRpcOverFetch(options, target);
|
|
275
|
+
}
|
|
276
|
+
async function callCursorUnaryRpcOverFetch(options, target) {
|
|
239
277
|
let timedOut = false;
|
|
240
278
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
241
279
|
const controller = new AbortController();
|
|
@@ -246,9 +284,13 @@ export async function callCursorUnaryRpc(options) {
|
|
|
246
284
|
}, timeoutMs)
|
|
247
285
|
: undefined;
|
|
248
286
|
try {
|
|
249
|
-
const response = await fetch(
|
|
287
|
+
const response = await fetch(target, {
|
|
250
288
|
method: "POST",
|
|
251
|
-
headers: buildCursorHeaders(options, "application/proto"
|
|
289
|
+
headers: buildCursorHeaders(options, "application/proto", {
|
|
290
|
+
accept: "application/proto, application/json",
|
|
291
|
+
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
292
|
+
"connect-timeout-ms": String(timeoutMs),
|
|
293
|
+
}),
|
|
252
294
|
body: toFetchBody(options.requestBody),
|
|
253
295
|
signal: controller.signal,
|
|
254
296
|
});
|
|
@@ -260,6 +302,12 @@ export async function callCursorUnaryRpc(options) {
|
|
|
260
302
|
};
|
|
261
303
|
}
|
|
262
304
|
catch {
|
|
305
|
+
logPluginError("Cursor unary fetch transport failed", {
|
|
306
|
+
rpcPath: options.rpcPath,
|
|
307
|
+
url: target.toString(),
|
|
308
|
+
timeoutMs,
|
|
309
|
+
timedOut,
|
|
310
|
+
});
|
|
263
311
|
return {
|
|
264
312
|
body: new Uint8Array(),
|
|
265
313
|
exitCode: timedOut ? 124 : 1,
|
|
@@ -271,6 +319,121 @@ export async function callCursorUnaryRpc(options) {
|
|
|
271
319
|
clearTimeout(timeout);
|
|
272
320
|
}
|
|
273
321
|
}
|
|
322
|
+
async function callCursorUnaryRpcOverHttp2(options, target) {
|
|
323
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
324
|
+
const authority = `${target.protocol}//${target.host}`;
|
|
325
|
+
return new Promise((resolve) => {
|
|
326
|
+
let settled = false;
|
|
327
|
+
let timedOut = false;
|
|
328
|
+
let session;
|
|
329
|
+
let stream;
|
|
330
|
+
const finish = (result) => {
|
|
331
|
+
if (settled)
|
|
332
|
+
return;
|
|
333
|
+
settled = true;
|
|
334
|
+
if (timeout)
|
|
335
|
+
clearTimeout(timeout);
|
|
336
|
+
try {
|
|
337
|
+
stream?.close();
|
|
338
|
+
}
|
|
339
|
+
catch { }
|
|
340
|
+
try {
|
|
341
|
+
session?.close();
|
|
342
|
+
}
|
|
343
|
+
catch { }
|
|
344
|
+
resolve(result);
|
|
345
|
+
};
|
|
346
|
+
const timeout = timeoutMs > 0
|
|
347
|
+
? setTimeout(() => {
|
|
348
|
+
timedOut = true;
|
|
349
|
+
finish({
|
|
350
|
+
body: new Uint8Array(),
|
|
351
|
+
exitCode: 124,
|
|
352
|
+
timedOut: true,
|
|
353
|
+
});
|
|
354
|
+
}, timeoutMs)
|
|
355
|
+
: undefined;
|
|
356
|
+
try {
|
|
357
|
+
session = connectHttp2(authority);
|
|
358
|
+
session.once("error", (error) => {
|
|
359
|
+
logPluginError("Cursor unary HTTP/2 session failed", {
|
|
360
|
+
rpcPath: options.rpcPath,
|
|
361
|
+
url: target.toString(),
|
|
362
|
+
timedOut,
|
|
363
|
+
...errorDetails(error),
|
|
364
|
+
});
|
|
365
|
+
finish({
|
|
366
|
+
body: new Uint8Array(),
|
|
367
|
+
exitCode: timedOut ? 124 : 1,
|
|
368
|
+
timedOut,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
const headers = {
|
|
372
|
+
":method": "POST",
|
|
373
|
+
":path": `${target.pathname}${target.search}`,
|
|
374
|
+
...buildCursorHeaderValues(options, "application/proto", {
|
|
375
|
+
accept: "application/proto, application/json",
|
|
376
|
+
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
377
|
+
"connect-timeout-ms": String(timeoutMs),
|
|
378
|
+
}),
|
|
379
|
+
};
|
|
380
|
+
stream = session.request(headers);
|
|
381
|
+
let statusCode = 0;
|
|
382
|
+
const chunks = [];
|
|
383
|
+
stream.once("response", (responseHeaders) => {
|
|
384
|
+
const statusHeader = responseHeaders[":status"];
|
|
385
|
+
statusCode = typeof statusHeader === "number"
|
|
386
|
+
? statusHeader
|
|
387
|
+
: Number(statusHeader ?? 0);
|
|
388
|
+
});
|
|
389
|
+
stream.on("data", (chunk) => {
|
|
390
|
+
chunks.push(Buffer.from(chunk));
|
|
391
|
+
});
|
|
392
|
+
stream.once("end", () => {
|
|
393
|
+
const body = new Uint8Array(Buffer.concat(chunks));
|
|
394
|
+
finish({
|
|
395
|
+
body,
|
|
396
|
+
exitCode: statusCode >= 200 && statusCode < 300 ? 0 : (statusCode || 1),
|
|
397
|
+
timedOut,
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
stream.once("error", (error) => {
|
|
401
|
+
logPluginError("Cursor unary HTTP/2 stream failed", {
|
|
402
|
+
rpcPath: options.rpcPath,
|
|
403
|
+
url: target.toString(),
|
|
404
|
+
timedOut,
|
|
405
|
+
...errorDetails(error),
|
|
406
|
+
});
|
|
407
|
+
finish({
|
|
408
|
+
body: new Uint8Array(),
|
|
409
|
+
exitCode: timedOut ? 124 : 1,
|
|
410
|
+
timedOut,
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
// Bun's node:http2 client currently breaks on end(Buffer.alloc(0)) against
|
|
414
|
+
// Cursor's HTTPS endpoint, but a header-only end() succeeds for empty unary bodies.
|
|
415
|
+
if (options.requestBody.length > 0) {
|
|
416
|
+
stream.end(Buffer.from(options.requestBody));
|
|
417
|
+
}
|
|
418
|
+
else {
|
|
419
|
+
stream.end();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
logPluginError("Cursor unary HTTP/2 setup failed", {
|
|
424
|
+
rpcPath: options.rpcPath,
|
|
425
|
+
url: target.toString(),
|
|
426
|
+
timedOut,
|
|
427
|
+
...errorDetails(error),
|
|
428
|
+
});
|
|
429
|
+
finish({
|
|
430
|
+
body: new Uint8Array(),
|
|
431
|
+
exitCode: timedOut ? 124 : 1,
|
|
432
|
+
timedOut,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
274
437
|
let proxyServer;
|
|
275
438
|
let proxyPort;
|
|
276
439
|
let proxyAccessTokenProvider;
|
|
@@ -312,10 +475,19 @@ export async function startProxy(getAccessToken, models = []) {
|
|
|
312
475
|
throw new Error("Cursor proxy access token provider not configured");
|
|
313
476
|
}
|
|
314
477
|
const accessToken = await proxyAccessTokenProvider();
|
|
315
|
-
return handleChatCompletion(body, accessToken
|
|
478
|
+
return handleChatCompletion(body, accessToken, {
|
|
479
|
+
sessionID: req.headers.get(OPENCODE_SESSION_ID_HEADER) ?? undefined,
|
|
480
|
+
agent: req.headers.get(OPENCODE_AGENT_HEADER) ?? undefined,
|
|
481
|
+
messageID: req.headers.get(OPENCODE_MESSAGE_ID_HEADER) ?? undefined,
|
|
482
|
+
});
|
|
316
483
|
}
|
|
317
484
|
catch (err) {
|
|
318
485
|
const message = err instanceof Error ? err.message : String(err);
|
|
486
|
+
logPluginError("Cursor proxy request failed", {
|
|
487
|
+
path: url.pathname,
|
|
488
|
+
method: req.method,
|
|
489
|
+
...errorDetails(err),
|
|
490
|
+
});
|
|
319
491
|
return new Response(JSON.stringify({
|
|
320
492
|
error: { message, type: "server_error", code: "internal_error" },
|
|
321
493
|
}), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
@@ -345,7 +517,7 @@ export function stopProxy() {
|
|
|
345
517
|
activeBridges.clear();
|
|
346
518
|
conversationStates.clear();
|
|
347
519
|
}
|
|
348
|
-
function handleChatCompletion(body, accessToken) {
|
|
520
|
+
function handleChatCompletion(body, accessToken, requestScope = {}) {
|
|
349
521
|
const { systemPrompt, userText, turns, toolResults } = parseMessages(body.messages);
|
|
350
522
|
const modelId = body.model;
|
|
351
523
|
const tools = body.tools ?? [];
|
|
@@ -359,8 +531,8 @@ function handleChatCompletion(body, accessToken) {
|
|
|
359
531
|
}
|
|
360
532
|
// bridgeKey: model-specific, for active tool-call bridges
|
|
361
533
|
// convKey: model-independent, for conversation state that survives model switches
|
|
362
|
-
const bridgeKey = deriveBridgeKey(modelId, body.messages);
|
|
363
|
-
const convKey = deriveConversationKey(body.messages);
|
|
534
|
+
const bridgeKey = deriveBridgeKey(modelId, body.messages, requestScope);
|
|
535
|
+
const convKey = deriveConversationKey(body.messages, requestScope);
|
|
364
536
|
const activeBridge = activeBridges.get(bridgeKey);
|
|
365
537
|
if (activeBridge && toolResults.length > 0) {
|
|
366
538
|
activeBridges.delete(bridgeKey);
|
|
@@ -382,7 +554,7 @@ function handleChatCompletion(body, accessToken) {
|
|
|
382
554
|
let stored = conversationStates.get(convKey);
|
|
383
555
|
if (!stored) {
|
|
384
556
|
stored = {
|
|
385
|
-
conversationId:
|
|
557
|
+
conversationId: crypto.randomUUID(),
|
|
386
558
|
checkpoint: null,
|
|
387
559
|
blobStore: new Map(),
|
|
388
560
|
lastAccessMs: Date.now(),
|
|
@@ -744,6 +916,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
|
|
|
744
916
|
const blobId = kvMsg.message.value.blobId;
|
|
745
917
|
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
746
918
|
const blobData = blobStore.get(blobIdKey);
|
|
919
|
+
if (!blobData) {
|
|
920
|
+
logPluginWarn("Cursor requested missing blob", {
|
|
921
|
+
blobId: blobIdKey,
|
|
922
|
+
knownBlobCount: blobStore.size,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
747
925
|
sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
|
|
748
926
|
}
|
|
749
927
|
else if (kvCase === "setBlobArgs") {
|
|
@@ -909,16 +1087,25 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
|
|
|
909
1087
|
sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
|
|
910
1088
|
}
|
|
911
1089
|
/** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */
|
|
912
|
-
function deriveBridgeKey(modelId, messages) {
|
|
1090
|
+
function deriveBridgeKey(modelId, messages, requestScope) {
|
|
913
1091
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
914
1092
|
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
915
1093
|
return createHash("sha256")
|
|
916
|
-
.update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1094
|
+
.update(`bridge:${requestScope.sessionID ?? ""}:${requestScope.agent ?? ""}:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
917
1095
|
.digest("hex")
|
|
918
1096
|
.slice(0, 16);
|
|
919
1097
|
}
|
|
920
1098
|
/** Derive a key for conversation state. Model-independent so context survives model switches. */
|
|
921
|
-
function deriveConversationKey(messages) {
|
|
1099
|
+
function deriveConversationKey(messages, requestScope) {
|
|
1100
|
+
if (requestScope.sessionID) {
|
|
1101
|
+
const scope = shouldIsolateConversation(requestScope)
|
|
1102
|
+
? `${requestScope.sessionID}:${requestScope.agent ?? ""}:${requestScope.messageID ?? crypto.randomUUID()}`
|
|
1103
|
+
: `${requestScope.sessionID}:${requestScope.agent ?? "default"}`;
|
|
1104
|
+
return createHash("sha256")
|
|
1105
|
+
.update(`conv:${scope}`)
|
|
1106
|
+
.digest("hex")
|
|
1107
|
+
.slice(0, 16);
|
|
1108
|
+
}
|
|
922
1109
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
923
1110
|
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
924
1111
|
return createHash("sha256")
|
|
@@ -926,21 +1113,10 @@ function deriveConversationKey(messages) {
|
|
|
926
1113
|
.digest("hex")
|
|
927
1114
|
.slice(0, 16);
|
|
928
1115
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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("-");
|
|
1116
|
+
function shouldIsolateConversation(requestScope) {
|
|
1117
|
+
return Boolean(requestScope.agent
|
|
1118
|
+
&& EPHEMERAL_CURSOR_AGENTS.has(requestScope.agent)
|
|
1119
|
+
&& requestScope.messageID);
|
|
944
1120
|
}
|
|
945
1121
|
/** Create an SSE streaming Response that reads from a live bridge. */
|
|
946
1122
|
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
|
|
@@ -992,6 +1168,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
992
1168
|
};
|
|
993
1169
|
const tagFilter = createThinkingTagFilter();
|
|
994
1170
|
let mcpExecReceived = false;
|
|
1171
|
+
let endStreamError = null;
|
|
995
1172
|
const processChunk = createConnectFrameParser((messageBytes) => {
|
|
996
1173
|
try {
|
|
997
1174
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
@@ -1051,9 +1228,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1051
1228
|
// Skip unparseable messages
|
|
1052
1229
|
}
|
|
1053
1230
|
}, (endStreamBytes) => {
|
|
1054
|
-
|
|
1055
|
-
if (
|
|
1056
|
-
|
|
1231
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1232
|
+
if (endStreamError) {
|
|
1233
|
+
logPluginError("Cursor stream returned Connect end-stream error", {
|
|
1234
|
+
modelId,
|
|
1235
|
+
bridgeKey,
|
|
1236
|
+
convKey,
|
|
1237
|
+
...errorDetails(endStreamError),
|
|
1238
|
+
});
|
|
1057
1239
|
}
|
|
1058
1240
|
});
|
|
1059
1241
|
bridge.onData(processChunk);
|
|
@@ -1065,6 +1247,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1065
1247
|
stored.blobStore.set(k, v);
|
|
1066
1248
|
stored.lastAccessMs = Date.now();
|
|
1067
1249
|
}
|
|
1250
|
+
if (endStreamError) {
|
|
1251
|
+
activeBridges.delete(bridgeKey);
|
|
1252
|
+
if (!closed) {
|
|
1253
|
+
closed = true;
|
|
1254
|
+
controller.error(endStreamError);
|
|
1255
|
+
}
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1068
1258
|
if (!mcpExecReceived) {
|
|
1069
1259
|
const flushed = tagFilter.flush();
|
|
1070
1260
|
if (flushed.reasoning)
|
|
@@ -1154,7 +1344,7 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
|
|
|
1154
1344
|
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
|
|
1155
1345
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1156
1346
|
const created = Math.floor(Date.now() / 1000);
|
|
1157
|
-
const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
|
|
1347
|
+
const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
|
|
1158
1348
|
return new Response(JSON.stringify({
|
|
1159
1349
|
id: completionId,
|
|
1160
1350
|
object: "chat.completion",
|
|
@@ -1170,9 +1360,10 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
|
1170
1360
|
usage,
|
|
1171
1361
|
}), { headers: { "Content-Type": "application/json" } });
|
|
1172
1362
|
}
|
|
1173
|
-
async function collectFullResponse(payload, accessToken, convKey) {
|
|
1174
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
1363
|
+
async function collectFullResponse(payload, accessToken, modelId, convKey) {
|
|
1364
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1175
1365
|
let fullText = "";
|
|
1366
|
+
let endStreamError = null;
|
|
1176
1367
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1177
1368
|
const state = {
|
|
1178
1369
|
toolCallIndex: 0,
|
|
@@ -1200,7 +1391,16 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1200
1391
|
catch {
|
|
1201
1392
|
// Skip
|
|
1202
1393
|
}
|
|
1203
|
-
}, () => {
|
|
1394
|
+
}, (endStreamBytes) => {
|
|
1395
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1396
|
+
if (endStreamError) {
|
|
1397
|
+
logPluginError("Cursor non-streaming response returned Connect end-stream error", {
|
|
1398
|
+
modelId,
|
|
1399
|
+
convKey,
|
|
1400
|
+
...errorDetails(endStreamError),
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
}));
|
|
1204
1404
|
bridge.onClose(() => {
|
|
1205
1405
|
clearInterval(heartbeatTimer);
|
|
1206
1406
|
const stored = conversationStates.get(convKey);
|
|
@@ -1211,6 +1411,10 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1211
1411
|
}
|
|
1212
1412
|
const flushed = tagFilter.flush();
|
|
1213
1413
|
fullText += flushed.content;
|
|
1414
|
+
if (endStreamError) {
|
|
1415
|
+
reject(endStreamError);
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1214
1418
|
const usage = computeUsage(state);
|
|
1215
1419
|
resolve({
|
|
1216
1420
|
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.
|
|
3
|
+
"version": "0.0.0-dev.65683458d3f1",
|
|
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",
|