@playwo/opencode-cursor-oauth 0.0.0-dev.1c231591c1ee → 0.0.0-dev.240de9fcc758

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.
@@ -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
- requestId: string;
10
+ initialRequestBytes: Uint8Array;
12
11
  }
13
12
  export declare function createCursorSession(options: CreateCursorSessionOptions): Promise<CursorSession>;
@@ -1,149 +1,164 @@
1
- import { create, toBinary } from "@bufbuild/protobuf";
2
- import { BidiRequestIdSchema } from "../proto/agent_pb";
3
- import { CURSOR_API_URL } from "./config";
4
- import { concatBytes, encodeProtoMessageField, encodeProtoStringField, encodeProtoVarintField, frameConnectMessage, toFetchBody, } from "./connect-framing";
5
- import { buildCursorHeaders } from "./headers";
6
- import { errorDetails, logPluginError, logPluginWarn } from "../logger";
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
- const response = await fetch(new URL("/agent.v1.AgentService/RunSSE", options.url ?? CURSOR_API_URL), {
17
- method: "POST",
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 cbs = {
34
- data: null,
35
- close: null,
36
- };
37
- const abortController = new AbortController();
38
- const reader = response.body.getReader();
39
- let appendSeqno = 0;
40
- let alive = true;
41
- let closeCode = 0;
42
- let writeChain = Promise.resolve();
43
- const pendingChunks = [];
44
- const finish = (code) => {
45
- if (!alive)
46
- return;
47
- alive = false;
48
- closeCode = code;
49
- cbs.close?.(code);
50
- };
51
- const append = async (data) => {
52
- const requestBody = encodeBidiAppendRequest(Buffer.from(data).toString("hex"), options.requestId, appendSeqno++);
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
- catch (error) {
91
- logPluginWarn("Cursor stream reader closed with error", {
92
- requestId: options.requestId,
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
- finish(alive ? 1 : closeCode);
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
- writeChain = writeChain
106
- .then(() => append(data))
107
- .catch((error) => {
108
- logPluginError("Cursor stream append failed", {
109
- requestId: options.requestId,
110
- ...errorDetails(error),
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
- abortController.abort();
81
+ stream.write(frameConnectMessage(data));
114
82
  }
115
- catch { }
116
- try {
117
- reader.cancel();
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
- catch { }
120
- finish(1);
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
- end() {
124
- try {
125
- abortController.abort();
126
- }
127
- catch { }
128
- try {
129
- reader.cancel();
130
- }
131
- catch { }
132
- finish(0);
133
- },
134
- onData(cb) {
135
- cbs.data = cb;
136
- while (pendingChunks.length > 0) {
137
- cb(pendingChunks.shift());
138
- }
139
- },
140
- onClose(cb) {
141
- if (!alive) {
142
- queueMicrotask(() => cb(closeCode));
143
- }
144
- else {
145
- cbs.close = cb;
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
  }
@@ -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, encodeBidiAppendRequest, type CreateCursorSessionOptions, type CursorSession, } from "./bidi-session";
4
+ export { createCursorSession, type CreateCursorSessionOptions, type CursorSession, } from "./bidi-session";
5
5
  export { callCursorUnaryRpc, type CursorUnaryRpcOptions } from "./unary-rpc";
@@ -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, encodeBidiAppendRequest, } from "./bidi-session";
4
+ export { createCursorSession, } from "./bidi-session";
5
5
  export { callCursorUnaryRpc } from "./unary-rpc";
@@ -4,7 +4,6 @@ export interface CursorUnaryRpcOptions {
4
4
  requestBody: Uint8Array;
5
5
  url?: string;
6
6
  timeoutMs?: number;
7
- transport?: "auto" | "fetch" | "http2";
8
7
  }
9
8
  export declare function callCursorUnaryRpc(options: CursorUnaryRpcOptions): Promise<{
10
9
  body: Uint8Array;
@@ -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 { toFetchBody } from "./connect-framing";
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
- const transport = options.transport ?? "auto";
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;
@@ -4,7 +4,7 @@ import { errorDetails, logPluginError } from "../logger";
4
4
  import { updateStoredConversationAfterCompletion } from "./conversation-state";
5
5
  import { startBridge } from "./bridge-session";
6
6
  import { updateConversationCheckpoint, syncStoredBlobStore, } from "./state-sync";
7
- import { computeUsage, createConnectFrameParser, createThinkingTagFilter, parseConnectEndStream, processServerMessage, scheduleBridgeEnd, } from "./stream-dispatch";
7
+ import { clearDeferredInteractionExecs, computeUsage, createConnectFrameParser, createThinkingTagFilter, parseConnectEndStream, processServerMessage, scheduleBridgeEnd, } from "./stream-dispatch";
8
8
  export async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
9
9
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
10
10
  const created = Math.floor(Date.now() / 1000);
@@ -40,6 +40,8 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
40
40
  totalTokens: 0,
41
41
  interactionToolArgsText: new Map(),
42
42
  emittedToolCallIds: new Set(),
43
+ deferredInteractionExecs: new Map(),
44
+ deferredInteractionExecTimers: new Map(),
43
45
  };
44
46
  const tagFilter = createThinkingTagFilter();
45
47
  bridge.onData(createConnectFrameParser((messageBytes) => {
@@ -98,6 +100,7 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
98
100
  }));
99
101
  bridge.onClose(() => {
100
102
  clearInterval(heartbeatTimer);
103
+ clearDeferredInteractionExecs(state);
101
104
  syncStoredBlobStore(convKey, payload.blobStore);
102
105
  const flushed = tagFilter.flush();
103
106
  fullText += flushed.content;
@@ -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
- requestId,
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
  }
@@ -6,12 +6,13 @@ import { activeBridges, updateStoredConversationAfterCompletion, } from "./conve
6
6
  import { startBridge } from "./bridge-session";
7
7
  import { updateConversationCheckpoint, syncStoredBlobStore, } from "./state-sync";
8
8
  import { SSE_HEADERS } from "./sse";
9
- import { computeUsage, createConnectFrameParser, createThinkingTagFilter, parseConnectEndStream, processServerMessage, scheduleBridgeEnd, } from "./stream-dispatch";
9
+ import { clearDeferredInteractionExecs, computeUsage, createConnectFrameParser, createThinkingTagFilter, parseConnectEndStream, processServerMessage, scheduleBridgeEnd, } from "./stream-dispatch";
10
10
  const SSE_KEEPALIVE_INTERVAL_MS = 15_000;
11
11
  function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
12
12
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
13
13
  const created = Math.floor(Date.now() / 1000);
14
14
  let keepaliveTimer;
15
+ let activeState;
15
16
  const stopKeepalive = () => {
16
17
  if (!keepaliveTimer)
17
18
  return;
@@ -29,7 +30,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
29
30
  totalTokens: 0,
30
31
  interactionToolArgsText: new Map(),
31
32
  emittedToolCallIds: new Set(),
33
+ deferredInteractionExecs: new Map(),
34
+ deferredInteractionExecTimers: new Map(),
32
35
  };
36
+ activeState = state;
33
37
  const tagFilter = createThinkingTagFilter();
34
38
  let assistantText = metadata.assistantSeedText ?? "";
35
39
  let mcpExecReceived = false;
@@ -49,6 +53,19 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
49
53
  return;
50
54
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
51
55
  };
56
+ const failStream = (message, code) => {
57
+ if (closed)
58
+ return;
59
+ sendSSE({
60
+ error: {
61
+ message,
62
+ type: "server_error",
63
+ ...(code ? { code } : {}),
64
+ },
65
+ });
66
+ sendDone();
67
+ closeController();
68
+ };
52
69
  const closeController = () => {
53
70
  if (closed)
54
71
  return;
@@ -197,13 +214,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
197
214
  bridge.onClose((code) => {
198
215
  clearInterval(heartbeatTimer);
199
216
  stopKeepalive();
217
+ clearDeferredInteractionExecs(state);
200
218
  syncStoredBlobStore(convKey, blobStore);
201
219
  if (endStreamError) {
202
220
  activeBridges.delete(bridgeKey);
203
- if (!closed) {
204
- closed = true;
205
- controller.error(endStreamError);
206
- }
221
+ failStream(endStreamError.message, "cursor_bridge_closed");
207
222
  return;
208
223
  }
209
224
  if (!mcpExecReceived) {
@@ -223,17 +238,15 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
223
238
  }
224
239
  activeBridges.delete(bridgeKey);
225
240
  if (code !== 0 && !closed) {
226
- sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
227
- sendSSE(makeChunk({}, "stop"));
228
- sendSSE(makeUsageChunk());
229
- sendDone();
230
- closeController();
241
+ failStream("Cursor bridge connection lost", "cursor_bridge_closed");
231
242
  }
232
243
  });
233
244
  },
234
245
  cancel(reason) {
235
246
  stopKeepalive();
236
247
  clearInterval(heartbeatTimer);
248
+ if (activeState)
249
+ clearDeferredInteractionExecs(activeState);
237
250
  syncStoredBlobStore(convKey, blobStore);
238
251
  const active = activeBridges.get(bridgeKey);
239
252
  if (active?.bridge === bridge) {
@@ -39,4 +39,5 @@ export declare function computeUsage(state: StreamState): {
39
39
  completion_tokens: number;
40
40
  total_tokens: number;
41
41
  };
42
+ export declare function clearDeferredInteractionExecs(state: StreamState): void;
42
43
  export declare function processServerMessage(msg: AgentServerMessage, blobStore: Map<string, Uint8Array>, mcpTools: McpToolDefinition[], sendFrame: (data: Uint8Array) => void, state: StreamState, onText: (text: string, isThinking?: boolean) => void, onMcpExec: (exec: PendingExec) => void, onCheckpoint?: (checkpointBytes: Uint8Array) => void, onTurnEnded?: () => void, onUnsupportedMessage?: (info: UnsupportedServerMessageInfo) => void, onUnhandledExec?: (info: UnhandledExecInfo) => void): void;
@@ -1,8 +1,9 @@
1
1
  import { create, toBinary } from "@bufbuild/protobuf";
2
- import { AgentClientMessageSchema, ClientHeartbeatSchema, ConversationStateStructureSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpResultSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "../proto/agent_pb";
2
+ import { AgentClientMessageSchema, AskQuestionInteractionResponseSchema, AskQuestionRejectedSchema, AskQuestionResultSchema, ClientHeartbeatSchema, ConversationStateStructureSchema, BackgroundShellSpawnResultSchema, CreatePlanErrorSchema, CreatePlanRequestResponseSchema, CreatePlanResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, ExaFetchRequestResponseSchema, ExaFetchRequestResponse_RejectedSchema, ExaSearchRequestResponseSchema, ExaSearchRequestResponse_RejectedSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, InteractionResponseSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpResultSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, SwitchModeRequestResponseSchema, SwitchModeRequestResponse_RejectedSchema, WebSearchRequestResponseSchema, WebSearchRequestResponse_RejectedSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "../proto/agent_pb";
3
3
  import { CONNECT_END_STREAM_FLAG } from "../cursor/config";
4
4
  import { logPluginError, logPluginWarn } from "../logger";
5
5
  import { decodeMcpArgsMap } from "../openai/tools";
6
+ const INTERACTION_TOOL_CALL_DEFER_MS = 75;
6
7
  export function parseConnectEndStream(data) {
7
8
  try {
8
9
  const payload = JSON.parse(new TextDecoder().decode(data));
@@ -128,6 +129,59 @@ export function computeUsage(state) {
128
129
  const prompt_tokens = Math.max(0, total_tokens - completion_tokens);
129
130
  return { prompt_tokens, completion_tokens, total_tokens };
130
131
  }
132
+ function getPendingExecKey(exec) {
133
+ return exec.toolCallId || `${exec.toolName}:${exec.decodedArgs}`;
134
+ }
135
+ function replacePendingExec(state, exec) {
136
+ const execKey = getPendingExecKey(exec);
137
+ const existingIndex = state.pendingExecs.findIndex((candidate) => getPendingExecKey(candidate) === execKey);
138
+ if (existingIndex >= 0) {
139
+ state.pendingExecs[existingIndex] = exec;
140
+ return;
141
+ }
142
+ state.pendingExecs.push(exec);
143
+ }
144
+ function emitPendingExec(exec, state, onMcpExec) {
145
+ const execKey = getPendingExecKey(exec);
146
+ if (state.emittedToolCallIds.has(execKey)) {
147
+ replacePendingExec(state, exec);
148
+ return;
149
+ }
150
+ state.emittedToolCallIds.add(execKey);
151
+ replacePendingExec(state, exec);
152
+ onMcpExec(exec);
153
+ }
154
+ function clearDeferredInteractionExec(state, execKey) {
155
+ const timer = state.deferredInteractionExecTimers.get(execKey);
156
+ if (timer)
157
+ clearTimeout(timer);
158
+ state.deferredInteractionExecTimers.delete(execKey);
159
+ state.deferredInteractionExecs.delete(execKey);
160
+ }
161
+ function queueInteractionPendingExec(exec, state, onMcpExec) {
162
+ const execKey = getPendingExecKey(exec);
163
+ if (state.emittedToolCallIds.has(execKey) ||
164
+ state.deferredInteractionExecs.has(execKey)) {
165
+ return;
166
+ }
167
+ state.deferredInteractionExecs.set(execKey, exec);
168
+ const timer = setTimeout(() => {
169
+ state.deferredInteractionExecTimers.delete(execKey);
170
+ const pendingExec = state.deferredInteractionExecs.get(execKey);
171
+ if (!pendingExec)
172
+ return;
173
+ state.deferredInteractionExecs.delete(execKey);
174
+ emitPendingExec(pendingExec, state, onMcpExec);
175
+ }, INTERACTION_TOOL_CALL_DEFER_MS);
176
+ state.deferredInteractionExecTimers.set(execKey, timer);
177
+ }
178
+ export function clearDeferredInteractionExecs(state) {
179
+ for (const timer of state.deferredInteractionExecTimers.values()) {
180
+ clearTimeout(timer);
181
+ }
182
+ state.deferredInteractionExecTimers.clear();
183
+ state.deferredInteractionExecs.clear();
184
+ }
131
185
  export function processServerMessage(msg, blobStore, mcpTools, sendFrame, state, onText, onMcpExec, onCheckpoint, onTurnEnded, onUnsupportedMessage, onUnhandledExec) {
132
186
  const msgCase = msg.message.case;
133
187
  if (msgCase === "interactionUpdate") {
@@ -137,7 +191,7 @@ export function processServerMessage(msg, blobStore, mcpTools, sendFrame, state,
137
191
  handleKvMessage(msg.message.value, blobStore, sendFrame);
138
192
  }
139
193
  else if (msgCase === "execServerMessage") {
140
- handleExecMessage(msg.message.value, mcpTools, sendFrame, onMcpExec, onUnhandledExec);
194
+ handleExecMessage(msg.message.value, mcpTools, sendFrame, state, onMcpExec, onUnhandledExec);
141
195
  }
142
196
  else if (msgCase === "execServerControlMessage") {
143
197
  onUnsupportedMessage?.({
@@ -146,10 +200,7 @@ export function processServerMessage(msg, blobStore, mcpTools, sendFrame, state,
146
200
  });
147
201
  }
148
202
  else if (msgCase === "interactionQuery") {
149
- onUnsupportedMessage?.({
150
- category: "interactionQuery",
151
- caseName: msg.message.value.query.case ?? "undefined",
152
- });
203
+ handleInteractionQuery(msg.message.value, sendFrame, onUnsupportedMessage);
153
204
  }
154
205
  else if (msgCase === "conversationCheckpointUpdate") {
155
206
  const stateStructure = msg.message.value;
@@ -185,20 +236,14 @@ function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded,
185
236
  else if (updateCase === "partialToolCall") {
186
237
  const partial = update.message.value;
187
238
  if (partial.callId && partial.argsTextDelta) {
188
- state.interactionToolArgsText.set(partial.callId, partial.argsTextDelta);
239
+ const existing = state.interactionToolArgsText.get(partial.callId) ?? "";
240
+ state.interactionToolArgsText.set(partial.callId, `${existing}${partial.argsTextDelta}`);
189
241
  }
190
242
  }
191
243
  else if (updateCase === "toolCallCompleted") {
192
244
  const exec = decodeInteractionToolCall(update.message.value, state);
193
245
  if (exec)
194
- onMcpExec(exec);
195
- else {
196
- onUnsupportedMessage?.({
197
- category: "toolCall",
198
- caseName: update.message.value?.toolCall?.tool?.case ?? "undefined",
199
- detail: "toolCallCompleted",
200
- });
201
- }
246
+ queueInteractionPendingExec(exec, state, onMcpExec);
202
247
  }
203
248
  else if (updateCase === "turnEnded") {
204
249
  onTurnEnded?.();
@@ -221,9 +266,10 @@ function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded,
221
266
  caseName: updateCase ?? "undefined",
222
267
  });
223
268
  }
224
- // toolCallStarted, partialToolCall, toolCallDelta, toolCallCompleted
225
- // are intentionally ignored. MCP tool calls flow through the exec
226
- // message path (mcpArgs mcpResult), not interaction updates.
269
+ // toolCallStarted, partialToolCall, toolCallDelta, and non-MCP
270
+ // toolCallCompleted updates are informational only. Actionable MCP tool
271
+ // calls may still appear here on some models, so we surface those, but we
272
+ // do not abort the bridge for native Cursor tool-call progress events.
227
273
  }
228
274
  function decodeInteractionToolCall(update, state) {
229
275
  const callId = update.callId ?? "";
@@ -247,7 +293,6 @@ function decodeInteractionToolCall(update, state) {
247
293
  else if (partialArgsText) {
248
294
  decodedArgs = partialArgsText;
249
295
  }
250
- state.emittedToolCallIds.add(toolCallId);
251
296
  if (callId)
252
297
  state.interactionToolArgsText.delete(callId);
253
298
  return {
@@ -258,6 +303,90 @@ function decodeInteractionToolCall(update, state) {
258
303
  decodedArgs,
259
304
  };
260
305
  }
306
+ function handleInteractionQuery(query, sendFrame, onUnsupportedMessage) {
307
+ const queryCase = query.query.case;
308
+ if (queryCase === "webSearchRequestQuery") {
309
+ const response = create(WebSearchRequestResponseSchema, {
310
+ result: {
311
+ case: "rejected",
312
+ value: create(WebSearchRequestResponse_RejectedSchema, {
313
+ reason: "Native Cursor web search is not available in this environment. Use the provided MCP tool `websearch` instead.",
314
+ }),
315
+ },
316
+ });
317
+ sendInteractionResponse(query.id, "webSearchRequestResponse", response, sendFrame);
318
+ return;
319
+ }
320
+ if (queryCase === "askQuestionInteractionQuery") {
321
+ const response = create(AskQuestionInteractionResponseSchema, {
322
+ result: create(AskQuestionResultSchema, {
323
+ result: {
324
+ case: "rejected",
325
+ value: create(AskQuestionRejectedSchema, {
326
+ reason: "Native Cursor question prompts are not available in this environment. Use the provided MCP tool `question` instead.",
327
+ }),
328
+ },
329
+ }),
330
+ });
331
+ sendInteractionResponse(query.id, "askQuestionInteractionResponse", response, sendFrame);
332
+ return;
333
+ }
334
+ if (queryCase === "switchModeRequestQuery") {
335
+ const response = create(SwitchModeRequestResponseSchema, {
336
+ result: {
337
+ case: "rejected",
338
+ value: create(SwitchModeRequestResponse_RejectedSchema, {
339
+ reason: "Cursor mode switching is not available in this environment. Continue using the current agent and the provided MCP tools.",
340
+ }),
341
+ },
342
+ });
343
+ sendInteractionResponse(query.id, "switchModeRequestResponse", response, sendFrame);
344
+ return;
345
+ }
346
+ if (queryCase === "exaSearchRequestQuery") {
347
+ const response = create(ExaSearchRequestResponseSchema, {
348
+ result: {
349
+ case: "rejected",
350
+ value: create(ExaSearchRequestResponse_RejectedSchema, {
351
+ reason: "Native Cursor Exa search is not available in this environment. Use the provided MCP tool `websearch` instead.",
352
+ }),
353
+ },
354
+ });
355
+ sendInteractionResponse(query.id, "exaSearchRequestResponse", response, sendFrame);
356
+ return;
357
+ }
358
+ if (queryCase === "exaFetchRequestQuery") {
359
+ const response = create(ExaFetchRequestResponseSchema, {
360
+ result: {
361
+ case: "rejected",
362
+ value: create(ExaFetchRequestResponse_RejectedSchema, {
363
+ reason: "Native Cursor Exa fetch is not available in this environment. Use the provided MCP tools `websearch` and `webfetch` instead.",
364
+ }),
365
+ },
366
+ });
367
+ sendInteractionResponse(query.id, "exaFetchRequestResponse", response, sendFrame);
368
+ return;
369
+ }
370
+ if (queryCase === "createPlanRequestQuery") {
371
+ const response = create(CreatePlanRequestResponseSchema, {
372
+ result: create(CreatePlanResultSchema, {
373
+ planUri: "",
374
+ result: {
375
+ case: "error",
376
+ value: create(CreatePlanErrorSchema, {
377
+ error: "Native Cursor plan creation is not available in this environment. Use the provided MCP planning tools instead.",
378
+ }),
379
+ },
380
+ }),
381
+ });
382
+ sendInteractionResponse(query.id, "createPlanRequestResponse", response, sendFrame);
383
+ return;
384
+ }
385
+ onUnsupportedMessage?.({
386
+ category: "interactionQuery",
387
+ caseName: queryCase ?? "undefined",
388
+ });
389
+ }
261
390
  /** Send a KV client response back to Cursor. */
262
391
  function sendKvResponse(kvMsg, messageCase, value, sendFrame) {
263
392
  const response = create(KvClientMessageSchema, {
@@ -269,6 +398,16 @@ function sendKvResponse(kvMsg, messageCase, value, sendFrame) {
269
398
  });
270
399
  sendFrame(toBinary(AgentClientMessageSchema, clientMsg));
271
400
  }
401
+ function sendInteractionResponse(queryId, messageCase, value, sendFrame) {
402
+ const response = create(InteractionResponseSchema, {
403
+ id: queryId,
404
+ result: { case: messageCase, value: value },
405
+ });
406
+ const clientMessage = create(AgentClientMessageSchema, {
407
+ message: { case: "interactionResponse", value: response },
408
+ });
409
+ sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
410
+ }
272
411
  function handleKvMessage(kvMsg, blobStore, sendFrame) {
273
412
  const kvCase = kvMsg.message.case;
274
413
  if (kvCase === "getBlobArgs") {
@@ -289,7 +428,7 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
289
428
  sendKvResponse(kvMsg, "setBlobResult", create(SetBlobResultSchema, {}), sendFrame);
290
429
  }
291
430
  }
292
- function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledExec) {
431
+ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnhandledExec) {
293
432
  const execCase = execMsg.message.case;
294
433
  if (execCase === "requestContextArgs") {
295
434
  const requestContext = create(RequestContextSchema, {
@@ -314,13 +453,15 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
314
453
  if (execCase === "mcpArgs") {
315
454
  const mcpArgs = execMsg.message.value;
316
455
  const decoded = decodeMcpArgsMap(mcpArgs.args ?? {});
317
- onMcpExec({
456
+ const exec = {
318
457
  execId: execMsg.execId,
319
458
  execMsgId: execMsg.id,
320
459
  toolCallId: mcpArgs.toolCallId || crypto.randomUUID(),
321
460
  toolName: mcpArgs.toolName || mcpArgs.name,
322
461
  decodedArgs: JSON.stringify(decoded),
323
- });
462
+ };
463
+ clearDeferredInteractionExec(state, getPendingExecKey(exec));
464
+ emitPendingExec(exec, state, onMcpExec);
324
465
  return;
325
466
  }
326
467
  // --- Reject native Cursor tools ---
@@ -6,4 +6,6 @@ export interface StreamState {
6
6
  totalTokens: number;
7
7
  interactionToolArgsText: Map<string, string>;
8
8
  emittedToolCallIds: Set<string>;
9
+ deferredInteractionExecs: Map<string, PendingExec>;
10
+ deferredInteractionExecTimers: Map<string, NodeJS.Timeout>;
9
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwo/opencode-cursor-oauth",
3
- "version": "0.0.0-dev.1c231591c1ee",
3
+ "version": "0.0.0-dev.240de9fcc758",
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",