@playwo/opencode-cursor-oauth 0.0.0-dev.b8e6dd72a8b6 → 0.0.0-dev.c1bf17c092a2
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 +87 -63
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +142 -0
- package/dist/models.js +21 -0
- package/dist/proxy.js +149 -47
- 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 { 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,12 @@ export const CursorAuthPlugin = async (input) => {
|
|
|
116
128
|
},
|
|
117
129
|
],
|
|
118
130
|
},
|
|
131
|
+
async "chat.headers"(incoming, output) {
|
|
132
|
+
if (incoming.model.providerID !== CURSOR_PROVIDER_ID)
|
|
133
|
+
return;
|
|
134
|
+
output.headers["x-opencode-session-id"] = incoming.sessionID;
|
|
135
|
+
output.headers["x-session-id"] = incoming.sessionID;
|
|
136
|
+
},
|
|
119
137
|
};
|
|
120
138
|
};
|
|
121
139
|
function buildCursorProviderModels(models, port) {
|
|
@@ -175,7 +193,13 @@ async function showDiscoveryFailureToast(input, message) {
|
|
|
175
193
|
},
|
|
176
194
|
});
|
|
177
195
|
}
|
|
178
|
-
catch {
|
|
196
|
+
catch (error) {
|
|
197
|
+
logPluginWarn("Failed to display Cursor plugin toast", {
|
|
198
|
+
title: "Cursor plugin disabled",
|
|
199
|
+
message,
|
|
200
|
+
...errorDetails(error),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
179
203
|
}
|
|
180
204
|
function buildDisabledProviderConfig(message) {
|
|
181
205
|
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
|
}
|
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
|
-
|
|
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,
|
|
@@ -420,10 +471,18 @@ export async function startProxy(getAccessToken, models = []) {
|
|
|
420
471
|
throw new Error("Cursor proxy access token provider not configured");
|
|
421
472
|
}
|
|
422
473
|
const accessToken = await proxyAccessTokenProvider();
|
|
423
|
-
|
|
474
|
+
const sessionId = req.headers.get("x-opencode-session-id")
|
|
475
|
+
?? req.headers.get("x-session-id")
|
|
476
|
+
?? undefined;
|
|
477
|
+
return handleChatCompletion(body, accessToken, { sessionId });
|
|
424
478
|
}
|
|
425
479
|
catch (err) {
|
|
426
480
|
const message = err instanceof Error ? err.message : String(err);
|
|
481
|
+
logPluginError("Cursor proxy request failed", {
|
|
482
|
+
path: url.pathname,
|
|
483
|
+
method: req.method,
|
|
484
|
+
...errorDetails(err),
|
|
485
|
+
});
|
|
427
486
|
return new Response(JSON.stringify({
|
|
428
487
|
error: { message, type: "server_error", code: "internal_error" },
|
|
429
488
|
}), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
@@ -453,7 +512,7 @@ export function stopProxy() {
|
|
|
453
512
|
activeBridges.clear();
|
|
454
513
|
conversationStates.clear();
|
|
455
514
|
}
|
|
456
|
-
function handleChatCompletion(body, accessToken) {
|
|
515
|
+
function handleChatCompletion(body, accessToken, context = {}) {
|
|
457
516
|
const { systemPrompt, userText, turns, toolResults } = parseMessages(body.messages);
|
|
458
517
|
const modelId = body.model;
|
|
459
518
|
const tools = body.tools ?? [];
|
|
@@ -467,8 +526,8 @@ function handleChatCompletion(body, accessToken) {
|
|
|
467
526
|
}
|
|
468
527
|
// bridgeKey: model-specific, for active tool-call bridges
|
|
469
528
|
// convKey: model-independent, for conversation state that survives model switches
|
|
470
|
-
const bridgeKey = deriveBridgeKey(modelId, body.messages);
|
|
471
|
-
const convKey = deriveConversationKey(body.messages);
|
|
529
|
+
const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId);
|
|
530
|
+
const convKey = deriveConversationKey(body.messages, context.sessionId);
|
|
472
531
|
const activeBridge = activeBridges.get(bridgeKey);
|
|
473
532
|
if (activeBridge && toolResults.length > 0) {
|
|
474
533
|
activeBridges.delete(bridgeKey);
|
|
@@ -490,7 +549,7 @@ function handleChatCompletion(body, accessToken) {
|
|
|
490
549
|
let stored = conversationStates.get(convKey);
|
|
491
550
|
if (!stored) {
|
|
492
551
|
stored = {
|
|
493
|
-
conversationId:
|
|
552
|
+
conversationId: crypto.randomUUID(),
|
|
494
553
|
checkpoint: null,
|
|
495
554
|
blobStore: new Map(),
|
|
496
555
|
lastAccessMs: Date.now(),
|
|
@@ -710,6 +769,12 @@ function makeHeartbeatBytes() {
|
|
|
710
769
|
});
|
|
711
770
|
return toBinary(AgentClientMessageSchema, heartbeat);
|
|
712
771
|
}
|
|
772
|
+
function scheduleBridgeEnd(bridge) {
|
|
773
|
+
queueMicrotask(() => {
|
|
774
|
+
if (bridge.alive)
|
|
775
|
+
bridge.end();
|
|
776
|
+
});
|
|
777
|
+
}
|
|
713
778
|
/**
|
|
714
779
|
* Create a stateful parser for Connect protocol frames.
|
|
715
780
|
* Handles buffering partial data across chunks.
|
|
@@ -852,6 +917,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
|
|
|
852
917
|
const blobId = kvMsg.message.value.blobId;
|
|
853
918
|
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
854
919
|
const blobData = blobStore.get(blobIdKey);
|
|
920
|
+
if (!blobData) {
|
|
921
|
+
logPluginWarn("Cursor requested missing blob", {
|
|
922
|
+
blobId: blobIdKey,
|
|
923
|
+
knownBlobCount: blobStore.size,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
855
926
|
sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
|
|
856
927
|
}
|
|
857
928
|
else if (kvCase === "setBlobArgs") {
|
|
@@ -1017,7 +1088,13 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
|
|
|
1017
1088
|
sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1018
1089
|
}
|
|
1019
1090
|
/** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */
|
|
1020
|
-
function deriveBridgeKey(modelId, messages) {
|
|
1091
|
+
function deriveBridgeKey(modelId, messages, sessionId) {
|
|
1092
|
+
if (sessionId) {
|
|
1093
|
+
return createHash("sha256")
|
|
1094
|
+
.update(`bridge:${sessionId}:${modelId}`)
|
|
1095
|
+
.digest("hex")
|
|
1096
|
+
.slice(0, 16);
|
|
1097
|
+
}
|
|
1021
1098
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1022
1099
|
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1023
1100
|
return createHash("sha256")
|
|
@@ -1026,29 +1103,23 @@ function deriveBridgeKey(modelId, messages) {
|
|
|
1026
1103
|
.slice(0, 16);
|
|
1027
1104
|
}
|
|
1028
1105
|
/** Derive a key for conversation state. Model-independent so context survives model switches. */
|
|
1029
|
-
function deriveConversationKey(messages) {
|
|
1030
|
-
|
|
1031
|
-
|
|
1106
|
+
function deriveConversationKey(messages, sessionId) {
|
|
1107
|
+
if (sessionId) {
|
|
1108
|
+
return createHash("sha256")
|
|
1109
|
+
.update(`session:${sessionId}`)
|
|
1110
|
+
.digest("hex")
|
|
1111
|
+
.slice(0, 16);
|
|
1112
|
+
}
|
|
1032
1113
|
return createHash("sha256")
|
|
1033
|
-
.update(
|
|
1114
|
+
.update(buildConversationFingerprint(messages))
|
|
1034
1115
|
.digest("hex")
|
|
1035
1116
|
.slice(0, 16);
|
|
1036
1117
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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("-");
|
|
1118
|
+
function buildConversationFingerprint(messages) {
|
|
1119
|
+
return messages.map((message) => {
|
|
1120
|
+
const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
|
|
1121
|
+
return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
|
|
1122
|
+
}).join("\n---\n");
|
|
1052
1123
|
}
|
|
1053
1124
|
/** Create an SSE streaming Response that reads from a live bridge. */
|
|
1054
1125
|
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
|
|
@@ -1100,6 +1171,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1100
1171
|
};
|
|
1101
1172
|
const tagFilter = createThinkingTagFilter();
|
|
1102
1173
|
let mcpExecReceived = false;
|
|
1174
|
+
let endStreamError = null;
|
|
1103
1175
|
const processChunk = createConnectFrameParser((messageBytes) => {
|
|
1104
1176
|
try {
|
|
1105
1177
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
@@ -1159,10 +1231,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1159
1231
|
// Skip unparseable messages
|
|
1160
1232
|
}
|
|
1161
1233
|
}, (endStreamBytes) => {
|
|
1162
|
-
|
|
1163
|
-
if (
|
|
1164
|
-
|
|
1234
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1235
|
+
if (endStreamError) {
|
|
1236
|
+
logPluginError("Cursor stream returned Connect end-stream error", {
|
|
1237
|
+
modelId,
|
|
1238
|
+
bridgeKey,
|
|
1239
|
+
convKey,
|
|
1240
|
+
...errorDetails(endStreamError),
|
|
1241
|
+
});
|
|
1165
1242
|
}
|
|
1243
|
+
scheduleBridgeEnd(bridge);
|
|
1166
1244
|
});
|
|
1167
1245
|
bridge.onData(processChunk);
|
|
1168
1246
|
bridge.onClose((code) => {
|
|
@@ -1173,6 +1251,14 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1173
1251
|
stored.blobStore.set(k, v);
|
|
1174
1252
|
stored.lastAccessMs = Date.now();
|
|
1175
1253
|
}
|
|
1254
|
+
if (endStreamError) {
|
|
1255
|
+
activeBridges.delete(bridgeKey);
|
|
1256
|
+
if (!closed) {
|
|
1257
|
+
closed = true;
|
|
1258
|
+
controller.error(endStreamError);
|
|
1259
|
+
}
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1176
1262
|
if (!mcpExecReceived) {
|
|
1177
1263
|
const flushed = tagFilter.flush();
|
|
1178
1264
|
if (flushed.reasoning)
|
|
@@ -1184,16 +1270,17 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1184
1270
|
sendDone();
|
|
1185
1271
|
closeController();
|
|
1186
1272
|
}
|
|
1187
|
-
else
|
|
1188
|
-
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1189
|
-
// Close the SSE stream so the client doesn't hang forever.
|
|
1190
|
-
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1191
|
-
sendSSE(makeChunk({}, "stop"));
|
|
1192
|
-
sendSSE(makeUsageChunk());
|
|
1193
|
-
sendDone();
|
|
1194
|
-
closeController();
|
|
1195
|
-
// Remove stale entry so the next request doesn't try to resume it.
|
|
1273
|
+
else {
|
|
1196
1274
|
activeBridges.delete(bridgeKey);
|
|
1275
|
+
if (code !== 0 && !closed) {
|
|
1276
|
+
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1277
|
+
// Close the SSE stream so the client doesn't hang forever.
|
|
1278
|
+
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1279
|
+
sendSSE(makeChunk({}, "stop"));
|
|
1280
|
+
sendSSE(makeUsageChunk());
|
|
1281
|
+
sendDone();
|
|
1282
|
+
closeController();
|
|
1283
|
+
}
|
|
1197
1284
|
}
|
|
1198
1285
|
});
|
|
1199
1286
|
},
|
|
@@ -1262,7 +1349,7 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
|
|
|
1262
1349
|
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
|
|
1263
1350
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1264
1351
|
const created = Math.floor(Date.now() / 1000);
|
|
1265
|
-
const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
|
|
1352
|
+
const { text, usage } = await collectFullResponse(payload, accessToken, modelId, convKey);
|
|
1266
1353
|
return new Response(JSON.stringify({
|
|
1267
1354
|
id: completionId,
|
|
1268
1355
|
object: "chat.completion",
|
|
@@ -1278,9 +1365,10 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
|
1278
1365
|
usage,
|
|
1279
1366
|
}), { headers: { "Content-Type": "application/json" } });
|
|
1280
1367
|
}
|
|
1281
|
-
async function collectFullResponse(payload, accessToken, convKey) {
|
|
1282
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
1368
|
+
async function collectFullResponse(payload, accessToken, modelId, convKey) {
|
|
1369
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1283
1370
|
let fullText = "";
|
|
1371
|
+
let endStreamError = null;
|
|
1284
1372
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1285
1373
|
const state = {
|
|
1286
1374
|
toolCallIndex: 0,
|
|
@@ -1308,7 +1396,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1308
1396
|
catch {
|
|
1309
1397
|
// Skip
|
|
1310
1398
|
}
|
|
1311
|
-
}, () => {
|
|
1399
|
+
}, (endStreamBytes) => {
|
|
1400
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1401
|
+
if (endStreamError) {
|
|
1402
|
+
logPluginError("Cursor non-streaming response returned Connect end-stream error", {
|
|
1403
|
+
modelId,
|
|
1404
|
+
convKey,
|
|
1405
|
+
...errorDetails(endStreamError),
|
|
1406
|
+
});
|
|
1407
|
+
}
|
|
1408
|
+
scheduleBridgeEnd(bridge);
|
|
1409
|
+
}));
|
|
1312
1410
|
bridge.onClose(() => {
|
|
1313
1411
|
clearInterval(heartbeatTimer);
|
|
1314
1412
|
const stored = conversationStates.get(convKey);
|
|
@@ -1319,6 +1417,10 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1319
1417
|
}
|
|
1320
1418
|
const flushed = tagFilter.flush();
|
|
1321
1419
|
fullText += flushed.content;
|
|
1420
|
+
if (endStreamError) {
|
|
1421
|
+
reject(endStreamError);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1322
1424
|
const usage = computeUsage(state);
|
|
1323
1425
|
resolve({
|
|
1324
1426
|
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.c1bf17c092a2",
|
|
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",
|