@playwo/opencode-cursor-oauth 0.0.0-dev.d7836f7ad39f → 0.0.0-dev.da5538092563
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 +33 -12
- package/dist/cursor/bidi-session.d.ts +1 -2
- package/dist/cursor/bidi-session.js +153 -138
- package/dist/cursor/index.d.ts +1 -1
- package/dist/cursor/index.js +1 -1
- package/dist/cursor/unary-rpc.d.ts +0 -1
- package/dist/cursor/unary-rpc.js +2 -59
- package/dist/logger.d.ts +1 -0
- package/dist/logger.js +3 -0
- package/dist/openai/messages.d.ts +0 -1
- package/dist/openai/messages.js +0 -3
- package/dist/plugin/cursor-auth-plugin.js +0 -1
- package/dist/proxy/bridge-close-controller.d.ts +6 -0
- package/dist/proxy/bridge-close-controller.js +37 -0
- package/dist/proxy/bridge-non-streaming.js +8 -4
- package/dist/proxy/bridge-session.js +1 -3
- package/dist/proxy/bridge-streaming.d.ts +1 -1
- package/dist/proxy/bridge-streaming.js +118 -20
- package/dist/proxy/chat-completion.js +45 -30
- package/dist/proxy/cursor-request.js +12 -15
- package/dist/proxy/server.js +23 -5
- package/dist/proxy/stream-dispatch.d.ts +1 -1
- package/dist/proxy/stream-dispatch.js +210 -60
- package/dist/proxy/stream-state.d.ts +0 -2
- package/dist/proxy/types.d.ts +6 -1
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
# opencode-cursor-oauth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Disclaimer
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This project is a **fork** of [ephraimduncan/opencode-cursor](https://github.com/ephraimduncan/opencode-cursor). Upstream may differ in behavior, features, or maintenance; treat this repository as its own line of development.
|
|
4
7
|
|
|
5
8
|
## What it does
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
10
|
+
This is an [OpenCode](https://opencode.ai) plugin that lets you use **Cursor cloud models** (Claude, GPT, Gemini, and whatever your Cursor account exposes) from inside OpenCode.
|
|
11
|
+
|
|
12
|
+
- **OAuth login** to Cursor in the browser
|
|
13
|
+
- **Model discovery** — loads the models available to your Cursor account
|
|
14
|
+
- **Local OpenAI-compatible proxy** — translates OpenCode’s requests to Cursor’s gRPC API
|
|
15
|
+
- **Token refresh** — refreshes access tokens so sessions keep working
|
|
16
|
+
|
|
17
|
+
There are **no extra runtime requirements** beyond what OpenCode already needs: you do not install Node, Python, or Docker separately for this plugin. Enable it in OpenCode’s config and complete login in the UI.
|
|
11
18
|
|
|
12
19
|
## Install
|
|
13
20
|
|
|
14
|
-
Add to your `opencode.json
|
|
21
|
+
Add the package to your OpenCode configuration (for example `opencode.json`):
|
|
15
22
|
|
|
16
23
|
```json
|
|
17
24
|
{
|
|
@@ -19,13 +26,27 @@ Add to your `opencode.json`:
|
|
|
19
26
|
}
|
|
20
27
|
```
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
Install or update dependencies the way you normally do for OpenCode plugins (e.g. ensure the package is available to your OpenCode environment). You need **OpenCode 1.2+** and a **Cursor account** with API/model access.
|
|
30
|
+
|
|
31
|
+
## Connect auth and use it
|
|
32
|
+
|
|
33
|
+
1. Start OpenCode with the plugin enabled.
|
|
34
|
+
2. Open **Settings → Providers → Cursor** (wording may vary slightly by OpenCode version).
|
|
35
|
+
3. Choose **Login** (or equivalent) and complete **OAuth** in the browser when prompted.
|
|
36
|
+
4. After login, pick a Cursor-backed model from the model list and use OpenCode as usual.
|
|
37
|
+
|
|
38
|
+
If something fails, check that you are signed into the correct Cursor account and that your plan includes the models you expect.
|
|
39
|
+
|
|
40
|
+
## Compatibiliy Notes
|
|
41
|
+
|
|
42
|
+
Cursor is not a raw model endpoint like the others supported in Opencode. It brings its own system prompt tools and mechanics.
|
|
43
|
+
This plugin does try its best to make mcps, skills etc installed in Opencode work in Cursor.
|
|
23
44
|
|
|
24
|
-
|
|
45
|
+
There are some issues with Cursors system prompt in this environment tho. Cursor adds various tools to the agent which opencode does not have, hence when the agent calls these they will be rejected which can sometimes lead to the agent no longer responding. Am still looking for a way to fix this, till then when the agent stops responding for a while interrupt it and tell it to continue again.
|
|
25
46
|
|
|
26
|
-
|
|
27
|
-
- OpenCode 1.2+
|
|
47
|
+
## Stability and issues
|
|
28
48
|
|
|
29
|
-
|
|
49
|
+
This integration can be **buggy** or break when Cursor or OpenCode change their APIs or UI.
|
|
30
50
|
|
|
31
|
-
|
|
51
|
+
> [!TIP]
|
|
52
|
+
> If you hit problems, missing models, or confusing errors, please **[open an issue](https://github.com/PoolPirate/opencode-cursor/issues)** on this repository with steps to reproduce and logs or screenshots when possible.
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { type CursorBaseRequestOptions } from "./headers";
|
|
2
|
-
export declare function encodeBidiAppendRequest(dataHex: string, requestId: string, appendSeqno: number): Uint8Array;
|
|
3
2
|
export interface CursorSession {
|
|
4
3
|
write: (data: Uint8Array) => void;
|
|
5
4
|
end: () => void;
|
|
@@ -8,6 +7,6 @@ export interface CursorSession {
|
|
|
8
7
|
readonly alive: boolean;
|
|
9
8
|
}
|
|
10
9
|
export interface CreateCursorSessionOptions extends CursorBaseRequestOptions {
|
|
11
|
-
|
|
10
|
+
initialRequestBytes: Uint8Array;
|
|
12
11
|
}
|
|
13
12
|
export declare function createCursorSession(options: CreateCursorSessionOptions): Promise<CursorSession>;
|
|
@@ -1,149 +1,164 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
export function encodeBidiAppendRequest(dataHex, requestId, appendSeqno) {
|
|
8
|
-
const requestIdBytes = toBinary(BidiRequestIdSchema, create(BidiRequestIdSchema, { requestId }));
|
|
9
|
-
return concatBytes([
|
|
10
|
-
encodeProtoStringField(1, dataHex),
|
|
11
|
-
encodeProtoMessageField(2, requestIdBytes),
|
|
12
|
-
encodeProtoVarintField(3, appendSeqno),
|
|
13
|
-
]);
|
|
14
|
-
}
|
|
1
|
+
import { connect as connectHttp2, } from "node:http2";
|
|
2
|
+
import { CURSOR_API_URL, CURSOR_CONNECT_PROTOCOL_VERSION } from "./config";
|
|
3
|
+
import { frameConnectMessage } from "./connect-framing";
|
|
4
|
+
import { buildCursorHeaderValues, } from "./headers";
|
|
5
|
+
import { errorDetails, logPluginError } from "../logger";
|
|
6
|
+
const CURSOR_BIDI_RUN_PATH = "/agent.v1.AgentService/Run";
|
|
15
7
|
export async function createCursorSession(options) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
headers: buildCursorHeaders(options, "application/connect+proto", {
|
|
19
|
-
accept: "text/event-stream",
|
|
20
|
-
"connect-protocol-version": "1",
|
|
21
|
-
}),
|
|
22
|
-
body: toFetchBody(frameConnectMessage(toBinary(BidiRequestIdSchema, create(BidiRequestIdSchema, { requestId: options.requestId })))),
|
|
23
|
-
});
|
|
24
|
-
if (!response.ok || !response.body) {
|
|
25
|
-
const errorBody = await response.text().catch(() => "");
|
|
26
|
-
logPluginError("Cursor RunSSE request failed", {
|
|
27
|
-
requestId: options.requestId,
|
|
28
|
-
status: response.status,
|
|
29
|
-
responseBody: errorBody,
|
|
30
|
-
});
|
|
31
|
-
throw new Error(`RunSSE failed: ${response.status}${errorBody ? ` ${errorBody}` : ""}`);
|
|
8
|
+
if (options.initialRequestBytes.length === 0) {
|
|
9
|
+
throw new Error("Cursor sessions require an initial request message");
|
|
32
10
|
}
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const appendResponse = await fetch(new URL("/aiserver.v1.BidiService/BidiAppend", options.url ?? CURSOR_API_URL), {
|
|
54
|
-
method: "POST",
|
|
55
|
-
headers: buildCursorHeaders(options, "application/proto"),
|
|
56
|
-
body: toFetchBody(requestBody),
|
|
57
|
-
signal: abortController.signal,
|
|
58
|
-
});
|
|
59
|
-
if (!appendResponse.ok) {
|
|
60
|
-
const errorBody = await appendResponse.text().catch(() => "");
|
|
61
|
-
logPluginError("Cursor BidiAppend request failed", {
|
|
62
|
-
requestId: options.requestId,
|
|
63
|
-
appendSeqno: appendSeqno - 1,
|
|
64
|
-
status: appendResponse.status,
|
|
65
|
-
responseBody: errorBody,
|
|
66
|
-
});
|
|
67
|
-
throw new Error(`BidiAppend failed: ${appendResponse.status}${errorBody ? ` ${errorBody}` : ""}`);
|
|
68
|
-
}
|
|
69
|
-
await appendResponse.arrayBuffer().catch(() => undefined);
|
|
70
|
-
};
|
|
71
|
-
(async () => {
|
|
72
|
-
try {
|
|
73
|
-
while (true) {
|
|
74
|
-
const { done, value } = await reader.read();
|
|
75
|
-
if (done) {
|
|
76
|
-
finish(0);
|
|
77
|
-
break;
|
|
78
|
-
}
|
|
79
|
-
if (value && value.length > 0) {
|
|
80
|
-
const chunk = Buffer.from(value);
|
|
81
|
-
if (cbs.data) {
|
|
82
|
-
cbs.data(chunk);
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
pendingChunks.push(chunk);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
11
|
+
const target = new URL(CURSOR_BIDI_RUN_PATH, options.url ?? CURSOR_API_URL);
|
|
12
|
+
const authority = `${target.protocol}//${target.host}`;
|
|
13
|
+
const requestId = crypto.randomUUID();
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const cbs = {
|
|
16
|
+
data: null,
|
|
17
|
+
close: null,
|
|
18
|
+
};
|
|
19
|
+
let session;
|
|
20
|
+
let stream;
|
|
21
|
+
let alive = true;
|
|
22
|
+
let closeCode = 0;
|
|
23
|
+
let opened = false;
|
|
24
|
+
let settled = false;
|
|
25
|
+
let statusCode = 0;
|
|
26
|
+
const pendingChunks = [];
|
|
27
|
+
const errorChunks = [];
|
|
28
|
+
const closeTransport = () => {
|
|
29
|
+
try {
|
|
30
|
+
stream?.close();
|
|
88
31
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
32
|
+
catch { }
|
|
33
|
+
try {
|
|
34
|
+
session?.close();
|
|
35
|
+
}
|
|
36
|
+
catch { }
|
|
37
|
+
};
|
|
38
|
+
const finish = (code) => {
|
|
39
|
+
if (!alive)
|
|
40
|
+
return;
|
|
41
|
+
alive = false;
|
|
42
|
+
closeCode = code;
|
|
43
|
+
cbs.close?.(code);
|
|
44
|
+
closeTransport();
|
|
45
|
+
};
|
|
46
|
+
const rejectOpen = (error) => {
|
|
47
|
+
if (settled)
|
|
48
|
+
return;
|
|
49
|
+
settled = true;
|
|
50
|
+
alive = false;
|
|
51
|
+
closeTransport();
|
|
52
|
+
reject(error);
|
|
53
|
+
};
|
|
54
|
+
const resolveOpen = (sessionHandle) => {
|
|
55
|
+
if (settled)
|
|
56
|
+
return;
|
|
57
|
+
settled = true;
|
|
58
|
+
opened = true;
|
|
59
|
+
resolve(sessionHandle);
|
|
60
|
+
};
|
|
61
|
+
const handleTransportError = (message, error) => {
|
|
62
|
+
logPluginError(message, {
|
|
63
|
+
requestId,
|
|
64
|
+
url: target.toString(),
|
|
93
65
|
...errorDetails(error),
|
|
94
66
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
})();
|
|
98
|
-
return {
|
|
99
|
-
get alive() {
|
|
100
|
-
return alive;
|
|
101
|
-
},
|
|
102
|
-
write(data) {
|
|
103
|
-
if (!alive)
|
|
67
|
+
if (!opened) {
|
|
68
|
+
rejectOpen(new Error(error instanceof Error ? error.message : String(error ?? message)));
|
|
104
69
|
return;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
70
|
+
}
|
|
71
|
+
finish(1);
|
|
72
|
+
};
|
|
73
|
+
const sessionHandle = {
|
|
74
|
+
get alive() {
|
|
75
|
+
return alive;
|
|
76
|
+
},
|
|
77
|
+
write(data) {
|
|
78
|
+
if (!alive || !stream)
|
|
79
|
+
return;
|
|
112
80
|
try {
|
|
113
|
-
|
|
81
|
+
stream.write(frameConnectMessage(data));
|
|
114
82
|
}
|
|
115
|
-
catch {
|
|
116
|
-
|
|
117
|
-
|
|
83
|
+
catch (error) {
|
|
84
|
+
handleTransportError("Cursor HTTP/2 write failed", error);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
end() {
|
|
88
|
+
finish(0);
|
|
89
|
+
},
|
|
90
|
+
onData(cb) {
|
|
91
|
+
cbs.data = cb;
|
|
92
|
+
while (pendingChunks.length > 0) {
|
|
93
|
+
cb(pendingChunks.shift());
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
onClose(cb) {
|
|
97
|
+
if (!alive) {
|
|
98
|
+
queueMicrotask(() => cb(closeCode));
|
|
118
99
|
}
|
|
119
|
-
|
|
120
|
-
|
|
100
|
+
else {
|
|
101
|
+
cbs.close = cb;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
session = connectHttp2(authority);
|
|
107
|
+
session.once("error", (error) => {
|
|
108
|
+
handleTransportError("Cursor HTTP/2 session failed", error);
|
|
121
109
|
});
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
110
|
+
const headers = {
|
|
111
|
+
":method": "POST",
|
|
112
|
+
":path": `${target.pathname}${target.search}`,
|
|
113
|
+
...buildCursorHeaderValues(options, "application/connect+proto", {
|
|
114
|
+
accept: "application/connect+proto",
|
|
115
|
+
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
116
|
+
}),
|
|
117
|
+
};
|
|
118
|
+
stream = session.request(headers);
|
|
119
|
+
stream.once("response", (responseHeaders) => {
|
|
120
|
+
const statusHeader = responseHeaders[":status"];
|
|
121
|
+
statusCode =
|
|
122
|
+
typeof statusHeader === "number"
|
|
123
|
+
? statusHeader
|
|
124
|
+
: Number(statusHeader ?? 0);
|
|
125
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
126
|
+
resolveOpen(sessionHandle);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
stream.on("data", (chunk) => {
|
|
130
|
+
const buffer = Buffer.from(chunk);
|
|
131
|
+
if (!opened && statusCode >= 400) {
|
|
132
|
+
errorChunks.push(buffer);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (cbs.data) {
|
|
136
|
+
cbs.data(buffer);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
pendingChunks.push(buffer);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
stream.once("end", () => {
|
|
143
|
+
if (!opened) {
|
|
144
|
+
const errorBody = Buffer.concat(errorChunks).toString("utf8").trim();
|
|
145
|
+
logPluginError("Cursor HTTP/2 Run request failed", {
|
|
146
|
+
requestId,
|
|
147
|
+
status: statusCode,
|
|
148
|
+
responseBody: errorBody,
|
|
149
|
+
});
|
|
150
|
+
rejectOpen(new Error(`Run failed: ${statusCode || 1}${errorBody ? ` ${errorBody}` : ""}`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
finish(statusCode >= 200 && statusCode < 300 ? 0 : statusCode || 1);
|
|
154
|
+
});
|
|
155
|
+
stream.once("error", (error) => {
|
|
156
|
+
handleTransportError("Cursor HTTP/2 stream failed", error);
|
|
157
|
+
});
|
|
158
|
+
stream.write(frameConnectMessage(options.initialRequestBytes));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
handleTransportError("Cursor HTTP/2 transport setup failed", error);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
149
164
|
}
|
package/dist/cursor/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { CURSOR_API_URL, CURSOR_CLIENT_VERSION, CURSOR_CONNECT_PROTOCOL_VERSION, CONNECT_END_STREAM_FLAG, } from "./config";
|
|
2
2
|
export { concatBytes, decodeConnectUnaryBody, encodeProtoMessageField, encodeProtoStringField, encodeProtoVarintField, encodeVarint, frameConnectMessage, toFetchBody, } from "./connect-framing";
|
|
3
3
|
export { buildCursorHeaders, buildCursorHeaderValues, type CursorBaseRequestOptions, } from "./headers";
|
|
4
|
-
export { createCursorSession,
|
|
4
|
+
export { createCursorSession, type CreateCursorSessionOptions, type CursorSession, } from "./bidi-session";
|
|
5
5
|
export { callCursorUnaryRpc, type CursorUnaryRpcOptions } from "./unary-rpc";
|
package/dist/cursor/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { CURSOR_API_URL, CURSOR_CLIENT_VERSION, CURSOR_CONNECT_PROTOCOL_VERSION, CONNECT_END_STREAM_FLAG, } from "./config";
|
|
2
2
|
export { concatBytes, decodeConnectUnaryBody, encodeProtoMessageField, encodeProtoStringField, encodeProtoVarintField, encodeVarint, frameConnectMessage, toFetchBody, } from "./connect-framing";
|
|
3
3
|
export { buildCursorHeaders, buildCursorHeaderValues, } from "./headers";
|
|
4
|
-
export { createCursorSession,
|
|
4
|
+
export { createCursorSession, } from "./bidi-session";
|
|
5
5
|
export { callCursorUnaryRpc } from "./unary-rpc";
|
package/dist/cursor/unary-rpc.js
CHANGED
|
@@ -1,67 +1,10 @@
|
|
|
1
1
|
import { connect as connectHttp2, } from "node:http2";
|
|
2
2
|
import { CURSOR_API_URL, CURSOR_CONNECT_PROTOCOL_VERSION } from "./config";
|
|
3
|
-
import {
|
|
4
|
-
import { buildCursorHeaders, buildCursorHeaderValues } from "./headers";
|
|
3
|
+
import { buildCursorHeaderValues } from "./headers";
|
|
5
4
|
import { errorDetails, logPluginError } from "../logger";
|
|
6
5
|
export async function callCursorUnaryRpc(options) {
|
|
7
6
|
const target = new URL(options.rpcPath, options.url ?? CURSOR_API_URL);
|
|
8
|
-
|
|
9
|
-
if (transport === "http2" ||
|
|
10
|
-
(transport === "auto" && target.protocol === "https:")) {
|
|
11
|
-
const http2Result = await callCursorUnaryRpcOverHttp2(options, target);
|
|
12
|
-
if (transport === "http2" ||
|
|
13
|
-
http2Result.timedOut ||
|
|
14
|
-
http2Result.exitCode !== 1) {
|
|
15
|
-
return http2Result;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return callCursorUnaryRpcOverFetch(options, target);
|
|
19
|
-
}
|
|
20
|
-
async function callCursorUnaryRpcOverFetch(options, target) {
|
|
21
|
-
let timedOut = false;
|
|
22
|
-
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
23
|
-
const controller = new AbortController();
|
|
24
|
-
const timeout = timeoutMs > 0
|
|
25
|
-
? setTimeout(() => {
|
|
26
|
-
timedOut = true;
|
|
27
|
-
controller.abort();
|
|
28
|
-
}, timeoutMs)
|
|
29
|
-
: undefined;
|
|
30
|
-
try {
|
|
31
|
-
const response = await fetch(target, {
|
|
32
|
-
method: "POST",
|
|
33
|
-
headers: buildCursorHeaders(options, "application/proto", {
|
|
34
|
-
accept: "application/proto, application/json",
|
|
35
|
-
"connect-protocol-version": CURSOR_CONNECT_PROTOCOL_VERSION,
|
|
36
|
-
"connect-timeout-ms": String(timeoutMs),
|
|
37
|
-
}),
|
|
38
|
-
body: toFetchBody(options.requestBody),
|
|
39
|
-
signal: controller.signal,
|
|
40
|
-
});
|
|
41
|
-
const body = new Uint8Array(await response.arrayBuffer());
|
|
42
|
-
return {
|
|
43
|
-
body,
|
|
44
|
-
exitCode: response.ok ? 0 : response.status,
|
|
45
|
-
timedOut,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
logPluginError("Cursor unary fetch transport failed", {
|
|
50
|
-
rpcPath: options.rpcPath,
|
|
51
|
-
url: target.toString(),
|
|
52
|
-
timeoutMs,
|
|
53
|
-
timedOut,
|
|
54
|
-
});
|
|
55
|
-
return {
|
|
56
|
-
body: new Uint8Array(),
|
|
57
|
-
exitCode: timedOut ? 124 : 1,
|
|
58
|
-
timedOut,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
finally {
|
|
62
|
-
if (timeout)
|
|
63
|
-
clearTimeout(timeout);
|
|
64
|
-
}
|
|
7
|
+
return callCursorUnaryRpcOverHttp2(options, target);
|
|
65
8
|
}
|
|
66
9
|
async function callCursorUnaryRpcOverHttp2(options, target) {
|
|
67
10
|
const timeoutMs = options.timeoutMs ?? 5_000;
|
package/dist/logger.d.ts
CHANGED
|
@@ -2,5 +2,6 @@ import type { PluginInput } from "@opencode-ai/plugin";
|
|
|
2
2
|
export declare function configurePluginLogger(input: PluginInput): void;
|
|
3
3
|
export declare function errorDetails(error: unknown): Record<string, unknown>;
|
|
4
4
|
export declare function logPluginWarn(message: string, extra?: Record<string, unknown>): void;
|
|
5
|
+
export declare function logPluginInfo(message: string, extra?: Record<string, unknown>): void;
|
|
5
6
|
export declare function logPluginError(message: string, extra?: Record<string, unknown>): void;
|
|
6
7
|
export declare function flushPluginLogs(): Promise<void>;
|
package/dist/logger.js
CHANGED
|
@@ -27,6 +27,9 @@ export function errorDetails(error) {
|
|
|
27
27
|
export function logPluginWarn(message, extra = {}) {
|
|
28
28
|
logPlugin("warn", message, extra);
|
|
29
29
|
}
|
|
30
|
+
export function logPluginInfo(message, extra = {}) {
|
|
31
|
+
logPlugin("info", message, extra);
|
|
32
|
+
}
|
|
30
33
|
export function logPluginError(message, extra = {}) {
|
|
31
34
|
logPlugin("error", message, extra);
|
|
32
35
|
}
|
|
@@ -13,7 +13,6 @@ interface ParsedMessages {
|
|
|
13
13
|
toolResults: ToolResultInfo[];
|
|
14
14
|
pendingAssistantSummary: string;
|
|
15
15
|
completedTurnsFingerprint: string;
|
|
16
|
-
assistantContinuation: boolean;
|
|
17
16
|
}
|
|
18
17
|
/** Normalize OpenAI message content to a plain string. */
|
|
19
18
|
export declare function textContent(content: OpenAIMessage["content"]): string;
|
package/dist/openai/messages.js
CHANGED
|
@@ -64,7 +64,6 @@ export function parseMessages(messages) {
|
|
|
64
64
|
let userText = "";
|
|
65
65
|
let toolResults = [];
|
|
66
66
|
let pendingAssistantSummary = "";
|
|
67
|
-
let assistantContinuation = false;
|
|
68
67
|
let completedTurnStates = parsedTurns;
|
|
69
68
|
const lastTurn = parsedTurns.at(-1);
|
|
70
69
|
if (lastTurn) {
|
|
@@ -84,7 +83,6 @@ export function parseMessages(messages) {
|
|
|
84
83
|
completedTurnStates = parsedTurns.slice(0, -1);
|
|
85
84
|
userText = lastTurn.userText;
|
|
86
85
|
pendingAssistantSummary = summarizeTurnSegments(lastTurn.segments);
|
|
87
|
-
assistantContinuation = true;
|
|
88
86
|
}
|
|
89
87
|
}
|
|
90
88
|
const turns = completedTurnStates
|
|
@@ -100,7 +98,6 @@ export function parseMessages(messages) {
|
|
|
100
98
|
toolResults,
|
|
101
99
|
pendingAssistantSummary,
|
|
102
100
|
completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
|
|
103
|
-
assistantContinuation,
|
|
104
101
|
};
|
|
105
102
|
}
|
|
106
103
|
function splitTrailingToolResults(segments) {
|
|
@@ -110,7 +110,6 @@ export const CursorAuthPlugin = async (input) => {
|
|
|
110
110
|
async "chat.headers"(incoming, output) {
|
|
111
111
|
if (incoming.model.providerID !== CURSOR_PROVIDER_ID)
|
|
112
112
|
return;
|
|
113
|
-
output.headers["x-opencode-session-id"] = incoming.sessionID;
|
|
114
113
|
output.headers["x-session-id"] = incoming.sessionID;
|
|
115
114
|
if (incoming.agent) {
|
|
116
115
|
output.headers["x-opencode-agent"] = incoming.agent;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { scheduleBridgeEnd } from "./stream-dispatch";
|
|
2
|
+
const TURN_END_GRACE_MS = 750;
|
|
3
|
+
export function createBridgeCloseController(bridge) {
|
|
4
|
+
let turnEnded = false;
|
|
5
|
+
let checkpointSeen = false;
|
|
6
|
+
let closeTimer;
|
|
7
|
+
const clearCloseTimer = () => {
|
|
8
|
+
if (!closeTimer)
|
|
9
|
+
return;
|
|
10
|
+
clearTimeout(closeTimer);
|
|
11
|
+
closeTimer = undefined;
|
|
12
|
+
};
|
|
13
|
+
const closeBridge = () => {
|
|
14
|
+
clearCloseTimer();
|
|
15
|
+
scheduleBridgeEnd(bridge);
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
noteTurnEnded() {
|
|
19
|
+
turnEnded = true;
|
|
20
|
+
if (checkpointSeen) {
|
|
21
|
+
closeBridge();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
clearCloseTimer();
|
|
25
|
+
closeTimer = setTimeout(closeBridge, TURN_END_GRACE_MS);
|
|
26
|
+
},
|
|
27
|
+
noteCheckpoint() {
|
|
28
|
+
checkpointSeen = true;
|
|
29
|
+
if (turnEnded) {
|
|
30
|
+
closeBridge();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
dispose() {
|
|
34
|
+
clearCloseTimer();
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -5,6 +5,7 @@ import { updateStoredConversationAfterCompletion } from "./conversation-state";
|
|
|
5
5
|
import { startBridge } from "./bridge-session";
|
|
6
6
|
import { updateConversationCheckpoint, syncStoredBlobStore, } from "./state-sync";
|
|
7
7
|
import { computeUsage, createConnectFrameParser, createThinkingTagFilter, parseConnectEndStream, processServerMessage, scheduleBridgeEnd, } from "./stream-dispatch";
|
|
8
|
+
import { createBridgeCloseController } from "./bridge-close-controller";
|
|
8
9
|
export async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
9
10
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
10
11
|
const created = Math.floor(Date.now() / 1000);
|
|
@@ -33,19 +34,18 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
|
|
|
33
34
|
let endStreamError = null;
|
|
34
35
|
const pendingToolCalls = [];
|
|
35
36
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
37
|
+
const bridgeCloseController = createBridgeCloseController(bridge);
|
|
36
38
|
const state = {
|
|
37
39
|
toolCallIndex: 0,
|
|
38
40
|
pendingExecs: [],
|
|
39
41
|
outputTokens: 0,
|
|
40
42
|
totalTokens: 0,
|
|
41
|
-
interactionToolArgsText: new Map(),
|
|
42
|
-
emittedToolCallIds: new Set(),
|
|
43
43
|
};
|
|
44
44
|
const tagFilter = createThinkingTagFilter();
|
|
45
45
|
bridge.onData(createConnectFrameParser((messageBytes) => {
|
|
46
46
|
try {
|
|
47
47
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
48
|
-
processServerMessage(serverMessage, payload.blobStore, payload.mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
|
|
48
|
+
processServerMessage(serverMessage, payload.blobStore, payload.cloudRule, payload.mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
|
|
49
49
|
if (isThinking)
|
|
50
50
|
return;
|
|
51
51
|
const { content } = tagFilter.process(text);
|
|
@@ -60,7 +60,10 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
|
|
|
60
60
|
},
|
|
61
61
|
});
|
|
62
62
|
scheduleBridgeEnd(bridge);
|
|
63
|
-
}, (checkpointBytes) =>
|
|
63
|
+
}, (checkpointBytes) => {
|
|
64
|
+
updateConversationCheckpoint(convKey, checkpointBytes);
|
|
65
|
+
bridgeCloseController.noteCheckpoint();
|
|
66
|
+
}, () => bridgeCloseController.noteTurnEnded(), (info) => {
|
|
64
67
|
endStreamError = new Error(`Cursor returned unsupported ${info.category}: ${info.caseName}${info.detail ? ` (${info.detail})` : ""}`);
|
|
65
68
|
logPluginError("Closing non-streaming Cursor bridge after unsupported message", {
|
|
66
69
|
modelId,
|
|
@@ -97,6 +100,7 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
|
|
|
97
100
|
scheduleBridgeEnd(bridge);
|
|
98
101
|
}));
|
|
99
102
|
bridge.onClose(() => {
|
|
103
|
+
bridgeCloseController.dispose();
|
|
100
104
|
clearInterval(heartbeatTimer);
|
|
101
105
|
syncStoredBlobStore(convKey, payload.blobStore);
|
|
102
106
|
const flushed = tagFilter.flush();
|
|
@@ -2,12 +2,10 @@ import { createCursorSession } from "../cursor/bidi-session";
|
|
|
2
2
|
import { makeHeartbeatBytes } from "./stream-dispatch";
|
|
3
3
|
const HEARTBEAT_INTERVAL_MS = 5_000;
|
|
4
4
|
export async function startBridge(accessToken, requestBytes) {
|
|
5
|
-
const requestId = crypto.randomUUID();
|
|
6
5
|
const bridge = await createCursorSession({
|
|
7
6
|
accessToken,
|
|
8
|
-
|
|
7
|
+
initialRequestBytes: requestBytes,
|
|
9
8
|
});
|
|
10
|
-
bridge.write(requestBytes);
|
|
11
9
|
const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), HEARTBEAT_INTERVAL_MS);
|
|
12
10
|
return { bridge, heartbeatTimer };
|
|
13
11
|
}
|