@playwo/opencode-cursor-oauth 0.0.0-dev.4258a6733133 → 0.0.0-dev.628837adf8c9

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;
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
  }
@@ -38,14 +38,12 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
38
38
  pendingExecs: [],
39
39
  outputTokens: 0,
40
40
  totalTokens: 0,
41
- interactionToolArgsText: new Map(),
42
- emittedToolCallIds: new Set(),
43
41
  };
44
42
  const tagFilter = createThinkingTagFilter();
45
43
  bridge.onData(createConnectFrameParser((messageBytes) => {
46
44
  try {
47
45
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
48
- processServerMessage(serverMessage, payload.blobStore, payload.mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
46
+ processServerMessage(serverMessage, payload.blobStore, payload.rules, payload.mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
49
47
  if (isThinking)
50
48
  return;
51
49
  const { content } = tagFilter.process(text);
@@ -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
  }
@@ -2,4 +2,4 @@ import { type ToolResultInfo } from "../openai/messages";
2
2
  import type { ConversationRequestMetadata } from "./conversation-meta";
3
3
  import type { ActiveBridge, CursorRequestPayload } from "./types";
4
4
  export declare function handleStreamingResponse(payload: CursorRequestPayload, accessToken: string, modelId: string, bridgeKey: string, convKey: string, metadata: ConversationRequestMetadata): Promise<Response>;
5
- export declare function handleToolResultResume(active: ActiveBridge, toolResults: ToolResultInfo[], bridgeKey: string, convKey: string): Response;
5
+ export declare function handleToolResultResume(active: ActiveBridge, toolResults: ToolResultInfo[], bridgeKey: string, convKey: string): Promise<Response>;
@@ -1,6 +1,6 @@
1
1
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
2
2
  import { AgentClientMessageSchema, AgentServerMessageSchema, ExecClientMessageSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolResultContentItemSchema, } from "../proto/agent_pb";
3
- import { errorDetails, logPluginError, logPluginWarn } from "../logger";
3
+ import { errorDetails, logPluginError, logPluginInfo, logPluginWarn, } from "../logger";
4
4
  import { formatToolCallSummary, formatToolResultSummary, } from "../openai/messages";
5
5
  import { activeBridges, updateStoredConversationAfterCompletion, } from "./conversation-state";
6
6
  import { startBridge } from "./bridge-session";
@@ -8,7 +8,7 @@ import { updateConversationCheckpoint, syncStoredBlobStore, } from "./state-sync
8
8
  import { SSE_HEADERS } from "./sse";
9
9
  import { computeUsage, createConnectFrameParser, createThinkingTagFilter, parseConnectEndStream, processServerMessage, scheduleBridgeEnd, } from "./stream-dispatch";
10
10
  const SSE_KEEPALIVE_INTERVAL_MS = 15_000;
11
- function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
11
+ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, rules, 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;
@@ -27,8 +27,6 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
27
27
  pendingExecs: [],
28
28
  outputTokens: 0,
29
29
  totalTokens: 0,
30
- interactionToolArgsText: new Map(),
31
- emittedToolCallIds: new Set(),
32
30
  };
33
31
  const tagFilter = createThinkingTagFilter();
34
32
  let assistantText = metadata.assistantSeedText ?? "";
@@ -90,7 +88,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
90
88
  const processChunk = createConnectFrameParser((messageBytes) => {
91
89
  try {
92
90
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
93
- processServerMessage(serverMessage, blobStore, mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
91
+ processServerMessage(serverMessage, blobStore, rules, mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
94
92
  if (isThinking) {
95
93
  sendSSE(makeChunk({ reasoning_content: text }));
96
94
  return;
@@ -103,7 +101,13 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
103
101
  sendSSE(makeChunk({ content }));
104
102
  }
105
103
  }, (exec) => {
106
- state.pendingExecs.push(exec);
104
+ const existingIndex = state.pendingExecs.findIndex((candidate) => candidate.toolCallId === exec.toolCallId);
105
+ if (existingIndex >= 0) {
106
+ state.pendingExecs[existingIndex] = exec;
107
+ }
108
+ else {
109
+ state.pendingExecs.push(exec);
110
+ }
107
111
  mcpExecReceived = true;
108
112
  const flushed = tagFilter.flush();
109
113
  if (flushed.reasoning)
@@ -142,6 +146,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
142
146
  bridge,
143
147
  heartbeatTimer,
144
148
  blobStore,
149
+ rules,
145
150
  mcpTools,
146
151
  pendingExecs: state.pendingExecs,
147
152
  modelId,
@@ -206,8 +211,23 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
206
211
  stopKeepalive();
207
212
  }
208
213
  }, SSE_KEEPALIVE_INTERVAL_MS);
214
+ logPluginInfo("Opened Cursor streaming bridge", {
215
+ modelId,
216
+ bridgeKey,
217
+ convKey,
218
+ mcpToolCount: mcpTools.length,
219
+ ruleCount: rules.length,
220
+ });
209
221
  bridge.onData(processChunk);
210
222
  bridge.onClose((code) => {
223
+ logPluginInfo("Cursor streaming bridge closed", {
224
+ modelId,
225
+ bridgeKey,
226
+ convKey,
227
+ code,
228
+ mcpExecReceived,
229
+ hadEndStreamError: Boolean(endStreamError),
230
+ });
211
231
  clearInterval(heartbeatTimer);
212
232
  stopKeepalive();
213
233
  syncStoredBlobStore(convKey, blobStore);
@@ -257,11 +277,36 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
257
277
  return new Response(stream, { headers: SSE_HEADERS });
258
278
  }
259
279
  export async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
280
+ logPluginInfo("Starting Cursor streaming response", {
281
+ modelId,
282
+ bridgeKey,
283
+ convKey,
284
+ mcpToolCount: payload.mcpTools.length,
285
+ });
260
286
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
261
- return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
287
+ return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.rules, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
288
+ }
289
+ async function waitForResolvablePendingExecs(active, toolResults, timeoutMs = 2_000) {
290
+ const pendingToolCallIds = new Set(toolResults.map((result) => result.toolCallId));
291
+ const deadline = Date.now() + timeoutMs;
292
+ while (Date.now() < deadline) {
293
+ const unresolved = active.pendingExecs.filter((exec) => pendingToolCallIds.has(exec.toolCallId) && exec.execMsgId === 0);
294
+ if (unresolved.length === 0) {
295
+ return unresolved;
296
+ }
297
+ await new Promise((resolve) => setTimeout(resolve, 25));
298
+ }
299
+ const unresolved = active.pendingExecs.filter((exec) => pendingToolCallIds.has(exec.toolCallId) && exec.execMsgId === 0);
300
+ if (unresolved.length > 0) {
301
+ logPluginWarn("Cursor exec metadata did not arrive before tool-result resume", {
302
+ bridgeToolCallIds: unresolved.map((exec) => exec.toolCallId),
303
+ modelId: active.modelId,
304
+ });
305
+ }
306
+ return unresolved;
262
307
  }
263
- export function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
264
- const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata, } = active;
308
+ export async function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
309
+ const { bridge, heartbeatTimer, blobStore, rules, mcpTools, pendingExecs, modelId, metadata, } = active;
265
310
  const resumeMetadata = {
266
311
  ...metadata,
267
312
  assistantSeedText: [
@@ -271,6 +316,33 @@ export function handleToolResultResume(active, toolResults, bridgeKey, convKey)
271
316
  .filter(Boolean)
272
317
  .join("\n\n"),
273
318
  };
319
+ logPluginInfo("Preparing Cursor tool-result resume", {
320
+ bridgeKey,
321
+ convKey,
322
+ modelId,
323
+ toolResults,
324
+ pendingExecs,
325
+ });
326
+ const unresolved = await waitForResolvablePendingExecs(active, toolResults);
327
+ logPluginInfo("Resolved pending exec state before Cursor tool-result resume", {
328
+ bridgeKey,
329
+ convKey,
330
+ modelId,
331
+ toolResults,
332
+ pendingExecs,
333
+ unresolvedPendingExecs: unresolved,
334
+ });
335
+ if (unresolved.length > 0) {
336
+ clearInterval(heartbeatTimer);
337
+ bridge.end();
338
+ return new Response(JSON.stringify({
339
+ error: {
340
+ message: "Cursor requested a tool call but never provided resumable exec metadata. Aborting instead of retrying with synthetic ids.",
341
+ type: "invalid_request_error",
342
+ code: "cursor_missing_exec_metadata",
343
+ },
344
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
345
+ }
274
346
  for (const exec of pendingExecs) {
275
347
  const result = toolResults.find((toolResult) => toolResult.toolCallId === exec.toolCallId);
276
348
  const mcpResult = result
@@ -311,7 +383,20 @@ export function handleToolResultResume(active, toolResults, bridgeKey, convKey)
311
383
  const clientMessage = create(AgentClientMessageSchema, {
312
384
  message: { case: "execClientMessage", value: execClientMessage },
313
385
  });
386
+ logPluginInfo("Sending Cursor tool-result resume message", {
387
+ bridgeKey,
388
+ convKey,
389
+ modelId,
390
+ toolCallId: exec.toolCallId,
391
+ toolName: exec.toolName,
392
+ source: exec.source,
393
+ execId: exec.execId,
394
+ execMsgId: exec.execMsgId,
395
+ cursorCallId: exec.cursorCallId,
396
+ modelCallId: exec.modelCallId,
397
+ matchedToolResult: result,
398
+ });
314
399
  bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
315
400
  }
316
- return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
401
+ return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, rules, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
317
402
  }
@@ -1,4 +1,4 @@
1
- import { logPluginWarn } from "../logger";
1
+ import { logPluginInfo, logPluginWarn } from "../logger";
2
2
  import { buildInitialHandoffPrompt, buildTitleSourceText, buildToolResumePrompt, detectTitleRequest, parseMessages, } from "../openai/messages";
3
3
  import { buildMcpToolDefinitions, selectToolsForChoice } from "../openai/tools";
4
4
  import { activeBridges, conversationStates, createStoredConversation, deriveBridgeKey, deriveConversationKey, evictStaleConversations, hashString, normalizeAgentKey, resetStoredConversation, } from "./conversation-state";
@@ -10,6 +10,19 @@ export function handleChatCompletion(body, accessToken, context = {}) {
10
10
  const { systemPrompt, userText, turns, toolResults, pendingAssistantSummary, completedTurnsFingerprint, } = parsed;
11
11
  const modelId = body.model;
12
12
  const normalizedAgentKey = normalizeAgentKey(context.agentKey);
13
+ logPluginInfo("Handling Cursor chat completion request", {
14
+ modelId,
15
+ stream: body.stream !== false,
16
+ messageCount: body.messages.length,
17
+ toolCount: body.tools?.length ?? 0,
18
+ toolChoice: body.tool_choice,
19
+ sessionId: context.sessionId,
20
+ agentKey: normalizedAgentKey,
21
+ parsedUserText: userText,
22
+ parsedToolResults: toolResults,
23
+ hasPendingAssistantSummary: pendingAssistantSummary.trim().length > 0,
24
+ turnCount: turns.length,
25
+ });
13
26
  const titleDetection = detectTitleRequest(body);
14
27
  const isTitleAgent = titleDetection.matched;
15
28
  if (isTitleAgent) {
@@ -38,7 +51,23 @@ export function handleChatCompletion(body, accessToken, context = {}) {
38
51
  const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId, context.agentKey);
39
52
  const convKey = deriveConversationKey(body.messages, context.sessionId, context.agentKey);
40
53
  const activeBridge = activeBridges.get(bridgeKey);
54
+ logPluginInfo("Resolved Cursor conversation keys", {
55
+ modelId,
56
+ bridgeKey,
57
+ convKey,
58
+ hasActiveBridge: Boolean(activeBridge),
59
+ sessionId: context.sessionId,
60
+ agentKey: normalizedAgentKey,
61
+ });
41
62
  if (activeBridge && toolResults.length > 0) {
63
+ logPluginInfo("Matched OpenAI tool results to active Cursor bridge", {
64
+ bridgeKey,
65
+ convKey,
66
+ requestedModelId: modelId,
67
+ activeBridgeModelId: activeBridge.modelId,
68
+ toolResults,
69
+ pendingExecs: activeBridge.pendingExecs,
70
+ });
42
71
  activeBridges.delete(bridgeKey);
43
72
  if (activeBridge.bridge.alive) {
44
73
  if (activeBridge.modelId !== modelId) {
@@ -93,6 +122,16 @@ export function handleChatCompletion(body, accessToken, context = {}) {
93
122
  : userText;
94
123
  const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, replayTurns, stored.conversationId, stored.checkpoint, stored.blobStore);
95
124
  payload.mcpTools = mcpTools;
125
+ logPluginInfo("Built Cursor run request payload", {
126
+ modelId,
127
+ bridgeKey,
128
+ convKey,
129
+ mcpToolCount: mcpTools.length,
130
+ conversationId: stored.conversationId,
131
+ hasCheckpoint: Boolean(stored.checkpoint),
132
+ replayTurnCount: replayTurns.length,
133
+ effectiveUserText,
134
+ });
96
135
  if (body.stream === false) {
97
136
  return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
98
137
  systemPrompt,
@@ -1,13 +1,8 @@
1
1
  import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
2
- import { createHash } from "node:crypto";
3
- import { AgentClientMessageSchema, AgentRunRequestSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, ModelDetailsSchema, ResumeActionSchema, UserMessageActionSchema, UserMessageSchema, } from "../proto/agent_pb";
2
+ import { AgentClientMessageSchema, AgentRunRequestSchema, AgentConversationTurnStructureSchema, AssistantMessageSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationTurnStructureSchema, CursorRuleSchema, CursorRuleTypeAgentFetchedSchema, CursorRuleTypeSchema, ConversationStepSchema, ModelDetailsSchema, ResumeActionSchema, UserMessageActionSchema, UserMessageSchema, } from "../proto/agent_pb";
4
3
  export function buildCursorRequest(modelId, systemPrompt, userText, turns, conversationId, checkpoint, existingBlobStore) {
5
4
  const blobStore = new Map(existingBlobStore ?? []);
6
- // System prompt → blob store (Cursor requests it back via KV handshake)
7
- const systemJson = JSON.stringify({ role: "system", content: systemPrompt });
8
- const systemBytes = new TextEncoder().encode(systemJson);
9
- const systemBlobId = new Uint8Array(createHash("sha256").update(systemBytes).digest());
10
- blobStore.set(Buffer.from(systemBlobId).toString("hex"), systemBytes);
5
+ const rules = buildCursorRules(systemPrompt);
11
6
  let conversationState;
12
7
  if (checkpoint) {
13
8
  conversationState = fromBinary(ConversationStateStructureSchema, checkpoint);
@@ -40,7 +35,7 @@ export function buildCursorRequest(modelId, systemPrompt, userText, turns, conve
40
35
  turnBytes.push(toBinary(ConversationTurnStructureSchema, turnStructure));
41
36
  }
42
37
  conversationState = create(ConversationStateStructureSchema, {
43
- rootPromptMessagesJson: [systemBlobId],
38
+ rootPromptMessagesJson: [],
44
39
  turns: turnBytes,
45
40
  todos: [],
46
41
  pendingToolCalls: [],
@@ -64,14 +59,11 @@ export function buildCursorRequest(modelId, systemPrompt, userText, turns, conve
64
59
  value: create(UserMessageActionSchema, { userMessage }),
65
60
  },
66
61
  });
67
- return buildRunRequest(modelId, conversationId, conversationState, action, blobStore);
62
+ return buildRunRequest(modelId, conversationId, conversationState, action, blobStore, rules);
68
63
  }
69
64
  export function buildCursorResumeRequest(modelId, systemPrompt, conversationId, checkpoint, existingBlobStore) {
70
65
  const blobStore = new Map(existingBlobStore ?? []);
71
- const systemJson = JSON.stringify({ role: "system", content: systemPrompt });
72
- const systemBytes = new TextEncoder().encode(systemJson);
73
- const systemBlobId = new Uint8Array(createHash("sha256").update(systemBytes).digest());
74
- blobStore.set(Buffer.from(systemBlobId).toString("hex"), systemBytes);
66
+ const rules = buildCursorRules(systemPrompt);
75
67
  const conversationState = fromBinary(ConversationStateStructureSchema, checkpoint);
76
68
  const action = create(ConversationActionSchema, {
77
69
  action: {
@@ -79,9 +71,9 @@ export function buildCursorResumeRequest(modelId, systemPrompt, conversationId,
79
71
  value: create(ResumeActionSchema, {}),
80
72
  },
81
73
  });
82
- return buildRunRequest(modelId, conversationId, conversationState, action, blobStore);
74
+ return buildRunRequest(modelId, conversationId, conversationState, action, blobStore, rules);
83
75
  }
84
- function buildRunRequest(modelId, conversationId, conversationState, action, blobStore) {
76
+ function buildRunRequest(modelId, conversationId, conversationState, action, blobStore, rules) {
85
77
  const modelDetails = create(ModelDetailsSchema, {
86
78
  modelId,
87
79
  displayModelId: modelId,
@@ -99,6 +91,27 @@ function buildRunRequest(modelId, conversationId, conversationState, action, blo
99
91
  return {
100
92
  requestBytes: toBinary(AgentClientMessageSchema, clientMessage),
101
93
  blobStore,
94
+ rules,
102
95
  mcpTools: [],
103
96
  };
104
97
  }
98
+ function buildCursorRules(systemPrompt) {
99
+ const content = systemPrompt.trim();
100
+ if (!content)
101
+ return [];
102
+ return [
103
+ create(CursorRuleSchema, {
104
+ fullPath: "/opencode/system-prompt.md",
105
+ content,
106
+ type: create(CursorRuleTypeSchema, {
107
+ type: {
108
+ case: "agentFetched",
109
+ value: create(CursorRuleTypeAgentFetchedSchema, {
110
+ description: "OpenCode system prompt",
111
+ }),
112
+ },
113
+ }),
114
+ source: 0,
115
+ }),
116
+ ];
117
+ }
@@ -1,4 +1,4 @@
1
- import { type AgentServerMessage, type McpToolDefinition } from "../proto/agent_pb";
1
+ import { type AgentServerMessage, type CursorRule, type McpToolDefinition } from "../proto/agent_pb";
2
2
  import type { CursorSession } from "../cursor/bidi-session";
3
3
  import type { StreamState } from "./stream-state";
4
4
  import type { PendingExec } from "./types";
@@ -39,4 +39,4 @@ export declare function computeUsage(state: StreamState): {
39
39
  completion_tokens: number;
40
40
  total_tokens: number;
41
41
  };
42
- 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;
42
+ export declare function processServerMessage(msg: AgentServerMessage, blobStore: Map<string, Uint8Array>, rules: CursorRule[], 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,7 +1,7 @@
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
- import { logPluginError, logPluginWarn } from "../logger";
4
+ import { logPluginError, logPluginInfo, logPluginWarn } from "../logger";
5
5
  import { decodeMcpArgsMap } from "../openai/tools";
6
6
  export function parseConnectEndStream(data) {
7
7
  try {
@@ -128,16 +128,16 @@ export function computeUsage(state) {
128
128
  const prompt_tokens = Math.max(0, total_tokens - completion_tokens);
129
129
  return { prompt_tokens, completion_tokens, total_tokens };
130
130
  }
131
- export function processServerMessage(msg, blobStore, mcpTools, sendFrame, state, onText, onMcpExec, onCheckpoint, onTurnEnded, onUnsupportedMessage, onUnhandledExec) {
131
+ export function processServerMessage(msg, blobStore, rules, mcpTools, sendFrame, state, onText, onMcpExec, onCheckpoint, onTurnEnded, onUnsupportedMessage, onUnhandledExec) {
132
132
  const msgCase = msg.message.case;
133
133
  if (msgCase === "interactionUpdate") {
134
- handleInteractionUpdate(msg.message.value, state, onText, onMcpExec, onTurnEnded, onUnsupportedMessage);
134
+ handleInteractionUpdate(msg.message.value, state, onText, onTurnEnded, onUnsupportedMessage);
135
135
  }
136
136
  else if (msgCase === "kvServerMessage") {
137
137
  handleKvMessage(msg.message.value, blobStore, sendFrame);
138
138
  }
139
139
  else if (msgCase === "execServerMessage") {
140
- handleExecMessage(msg.message.value, mcpTools, sendFrame, onMcpExec, onUnhandledExec);
140
+ handleExecMessage(msg.message.value, rules, mcpTools, sendFrame, state, onMcpExec, onUnhandledExec);
141
141
  }
142
142
  else if (msgCase === "execServerControlMessage") {
143
143
  onUnsupportedMessage?.({
@@ -146,10 +146,7 @@ export function processServerMessage(msg, blobStore, mcpTools, sendFrame, state,
146
146
  });
147
147
  }
148
148
  else if (msgCase === "interactionQuery") {
149
- onUnsupportedMessage?.({
150
- category: "interactionQuery",
151
- caseName: msg.message.value.query.case ?? "undefined",
152
- });
149
+ handleInteractionQuery(msg.message.value, sendFrame, onUnsupportedMessage);
153
150
  }
154
151
  else if (msgCase === "conversationCheckpointUpdate") {
155
152
  const stateStructure = msg.message.value;
@@ -167,8 +164,19 @@ export function processServerMessage(msg, blobStore, mcpTools, sendFrame, state,
167
164
  });
168
165
  }
169
166
  }
170
- function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded, onUnsupportedMessage) {
167
+ function handleInteractionUpdate(update, state, onText, onTurnEnded, onUnsupportedMessage) {
171
168
  const updateCase = update.message?.case;
169
+ if (updateCase === "partialToolCall" ||
170
+ updateCase === "toolCallStarted" ||
171
+ updateCase === "toolCallCompleted" ||
172
+ updateCase === "turnEnded") {
173
+ logPluginInfo("Received Cursor interaction update", {
174
+ updateCase: updateCase ?? "undefined",
175
+ callId: update.message?.value?.callId,
176
+ modelCallId: update.message?.value?.modelCallId,
177
+ toolCase: update.message?.value?.toolCall?.tool?.case,
178
+ });
179
+ }
172
180
  if (updateCase === "textDelta") {
173
181
  const delta = update.message.value.text || "";
174
182
  if (delta)
@@ -183,15 +191,19 @@ function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded,
183
191
  state.outputTokens += update.message.value.tokens ?? 0;
184
192
  }
185
193
  else if (updateCase === "partialToolCall") {
186
- const partial = update.message.value;
187
- if (partial.callId && partial.argsTextDelta) {
188
- state.interactionToolArgsText.set(partial.callId, partial.argsTextDelta);
189
- }
194
+ return;
190
195
  }
191
196
  else if (updateCase === "toolCallCompleted") {
192
- const exec = decodeInteractionToolCall(update.message.value, state);
193
- if (exec)
194
- onMcpExec(exec);
197
+ const toolValue = update.message.value;
198
+ if (toolValue?.toolCall?.tool?.case === "mcpToolCall") {
199
+ logPluginInfo("Ignoring Cursor interaction MCP tool completion", {
200
+ callId: toolValue.callId,
201
+ modelCallId: toolValue.modelCallId,
202
+ toolCallId: toolValue.toolCall.tool.value?.args?.toolCallId || toolValue.callId,
203
+ toolName: toolValue.toolCall.tool.value?.args?.toolName ||
204
+ toolValue.toolCall.tool.value?.args?.name,
205
+ });
206
+ }
195
207
  }
196
208
  else if (updateCase === "turnEnded") {
197
209
  onTurnEnded?.();
@@ -214,43 +226,92 @@ function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded,
214
226
  caseName: updateCase ?? "undefined",
215
227
  });
216
228
  }
217
- // toolCallStarted, partialToolCall, toolCallDelta, and non-MCP
218
- // toolCallCompleted updates are informational only. Actionable MCP tool
219
- // calls may still appear here on some models, so we surface those, but we
220
- // do not abort the bridge for native Cursor tool-call progress events.
229
+ // Interaction tool-call updates are informational only. Resumable MCP tool
230
+ // execution comes from execServerMessage.mcpArgs.
221
231
  }
222
- function decodeInteractionToolCall(update, state) {
223
- const callId = update.callId ?? "";
224
- const toolCase = update.toolCall?.tool?.case;
225
- if (toolCase !== "mcpToolCall")
226
- return null;
227
- const mcpArgs = update.toolCall?.tool?.value?.args;
228
- if (!mcpArgs)
229
- return null;
230
- const toolCallId = mcpArgs.toolCallId || callId || crypto.randomUUID();
231
- if (state.emittedToolCallIds.has(toolCallId))
232
- return null;
233
- const decodedMap = decodeMcpArgsMap(mcpArgs.args ?? {});
234
- const partialArgsText = callId
235
- ? state.interactionToolArgsText.get(callId)?.trim()
236
- : undefined;
237
- let decodedArgs = "{}";
238
- if (Object.keys(decodedMap).length > 0) {
239
- decodedArgs = JSON.stringify(decodedMap);
240
- }
241
- else if (partialArgsText) {
242
- decodedArgs = partialArgsText;
243
- }
244
- state.emittedToolCallIds.add(toolCallId);
245
- if (callId)
246
- state.interactionToolArgsText.delete(callId);
247
- return {
248
- execId: callId || toolCallId,
249
- execMsgId: 0,
250
- toolCallId,
251
- toolName: mcpArgs.toolName || mcpArgs.name || "unknown_mcp_tool",
252
- decodedArgs,
253
- };
232
+ function handleInteractionQuery(query, sendFrame, onUnsupportedMessage) {
233
+ const queryCase = query.query.case;
234
+ if (queryCase === "webSearchRequestQuery") {
235
+ const response = create(WebSearchRequestResponseSchema, {
236
+ result: {
237
+ case: "rejected",
238
+ value: create(WebSearchRequestResponse_RejectedSchema, {
239
+ reason: "Native Cursor web search is not available in this environment. Use the provided MCP tool `websearch` instead.",
240
+ }),
241
+ },
242
+ });
243
+ sendInteractionResponse(query.id, "webSearchRequestResponse", response, sendFrame);
244
+ return;
245
+ }
246
+ if (queryCase === "askQuestionInteractionQuery") {
247
+ const response = create(AskQuestionInteractionResponseSchema, {
248
+ result: create(AskQuestionResultSchema, {
249
+ result: {
250
+ case: "rejected",
251
+ value: create(AskQuestionRejectedSchema, {
252
+ reason: "Native Cursor question prompts are not available in this environment. Use the provided MCP tool `question` instead.",
253
+ }),
254
+ },
255
+ }),
256
+ });
257
+ sendInteractionResponse(query.id, "askQuestionInteractionResponse", response, sendFrame);
258
+ return;
259
+ }
260
+ if (queryCase === "switchModeRequestQuery") {
261
+ const response = create(SwitchModeRequestResponseSchema, {
262
+ result: {
263
+ case: "rejected",
264
+ value: create(SwitchModeRequestResponse_RejectedSchema, {
265
+ reason: "Cursor mode switching is not available in this environment. Continue using the current agent and the provided MCP tools.",
266
+ }),
267
+ },
268
+ });
269
+ sendInteractionResponse(query.id, "switchModeRequestResponse", response, sendFrame);
270
+ return;
271
+ }
272
+ if (queryCase === "exaSearchRequestQuery") {
273
+ const response = create(ExaSearchRequestResponseSchema, {
274
+ result: {
275
+ case: "rejected",
276
+ value: create(ExaSearchRequestResponse_RejectedSchema, {
277
+ reason: "Native Cursor Exa search is not available in this environment. Use the provided MCP tool `websearch` instead.",
278
+ }),
279
+ },
280
+ });
281
+ sendInteractionResponse(query.id, "exaSearchRequestResponse", response, sendFrame);
282
+ return;
283
+ }
284
+ if (queryCase === "exaFetchRequestQuery") {
285
+ const response = create(ExaFetchRequestResponseSchema, {
286
+ result: {
287
+ case: "rejected",
288
+ value: create(ExaFetchRequestResponse_RejectedSchema, {
289
+ reason: "Native Cursor Exa fetch is not available in this environment. Use the provided MCP tools `websearch` and `webfetch` instead.",
290
+ }),
291
+ },
292
+ });
293
+ sendInteractionResponse(query.id, "exaFetchRequestResponse", response, sendFrame);
294
+ return;
295
+ }
296
+ if (queryCase === "createPlanRequestQuery") {
297
+ const response = create(CreatePlanRequestResponseSchema, {
298
+ result: create(CreatePlanResultSchema, {
299
+ planUri: "",
300
+ result: {
301
+ case: "error",
302
+ value: create(CreatePlanErrorSchema, {
303
+ error: "Native Cursor plan creation is not available in this environment. Use the provided MCP planning tools instead.",
304
+ }),
305
+ },
306
+ }),
307
+ });
308
+ sendInteractionResponse(query.id, "createPlanRequestResponse", response, sendFrame);
309
+ return;
310
+ }
311
+ onUnsupportedMessage?.({
312
+ category: "interactionQuery",
313
+ caseName: queryCase ?? "undefined",
314
+ });
254
315
  }
255
316
  /** Send a KV client response back to Cursor. */
256
317
  function sendKvResponse(kvMsg, messageCase, value, sendFrame) {
@@ -263,6 +324,16 @@ function sendKvResponse(kvMsg, messageCase, value, sendFrame) {
263
324
  });
264
325
  sendFrame(toBinary(AgentClientMessageSchema, clientMsg));
265
326
  }
327
+ function sendInteractionResponse(queryId, messageCase, value, sendFrame) {
328
+ const response = create(InteractionResponseSchema, {
329
+ id: queryId,
330
+ result: { case: messageCase, value: value },
331
+ });
332
+ const clientMessage = create(AgentClientMessageSchema, {
333
+ message: { case: "interactionResponse", value: response },
334
+ });
335
+ sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
336
+ }
266
337
  function handleKvMessage(kvMsg, blobStore, sendFrame) {
267
338
  const kvCase = kvMsg.message.case;
268
339
  if (kvCase === "getBlobArgs") {
@@ -283,11 +354,21 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
283
354
  sendKvResponse(kvMsg, "setBlobResult", create(SetBlobResultSchema, {}), sendFrame);
284
355
  }
285
356
  }
286
- function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledExec) {
357
+ function handleExecMessage(execMsg, rules, mcpTools, sendFrame, state, onMcpExec, onUnhandledExec) {
287
358
  const execCase = execMsg.message.case;
359
+ logPluginInfo("Received Cursor exec message", {
360
+ execCase: execCase ?? "undefined",
361
+ execId: execMsg.execId,
362
+ execMsgId: execMsg.id,
363
+ });
288
364
  if (execCase === "requestContextArgs") {
365
+ logPluginInfo("Responding to Cursor requestContextArgs", {
366
+ execId: execMsg.execId,
367
+ execMsgId: execMsg.id,
368
+ mcpToolCount: mcpTools.length,
369
+ });
289
370
  const requestContext = create(RequestContextSchema, {
290
- rules: [],
371
+ rules,
291
372
  repositoryInfo: [],
292
373
  tools: mcpTools,
293
374
  gitRepos: [],
@@ -308,13 +389,23 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
308
389
  if (execCase === "mcpArgs") {
309
390
  const mcpArgs = execMsg.message.value;
310
391
  const decoded = decodeMcpArgsMap(mcpArgs.args ?? {});
311
- onMcpExec({
392
+ const exec = {
312
393
  execId: execMsg.execId,
313
394
  execMsgId: execMsg.id,
314
395
  toolCallId: mcpArgs.toolCallId || crypto.randomUUID(),
315
396
  toolName: mcpArgs.toolName || mcpArgs.name,
316
397
  decodedArgs: JSON.stringify(decoded),
398
+ source: "exec",
399
+ };
400
+ logPluginInfo("Received Cursor exec MCP tool metadata", {
401
+ toolCallId: exec.toolCallId,
402
+ toolName: exec.toolName,
403
+ source: exec.source,
404
+ execId: exec.execId,
405
+ execMsgId: exec.execMsgId,
406
+ decodedArgs: exec.decodedArgs,
317
407
  });
408
+ onMcpExec(exec);
318
409
  return;
319
410
  }
320
411
  // --- Reject native Cursor tools ---
@@ -322,6 +413,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
322
413
  // so it falls back to our MCP tools (registered via RequestContext).
323
414
  const REJECT_REASON = "Tool not available in this environment. Use the MCP tools provided instead.";
324
415
  if (execCase === "readArgs") {
416
+ logPluginInfo("Rejecting native Cursor read tool in favor of MCP", {
417
+ execId: execMsg.execId,
418
+ execMsgId: execMsg.id,
419
+ path: execMsg.message.value.path,
420
+ });
325
421
  const args = execMsg.message.value;
326
422
  const result = create(ReadResultSchema, {
327
423
  result: {
@@ -336,6 +432,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
336
432
  return;
337
433
  }
338
434
  if (execCase === "lsArgs") {
435
+ logPluginInfo("Rejecting native Cursor ls tool in favor of MCP", {
436
+ execId: execMsg.execId,
437
+ execMsgId: execMsg.id,
438
+ path: execMsg.message.value.path,
439
+ });
339
440
  const args = execMsg.message.value;
340
441
  const result = create(LsResultSchema, {
341
442
  result: {
@@ -350,6 +451,10 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
350
451
  return;
351
452
  }
352
453
  if (execCase === "grepArgs") {
454
+ logPluginInfo("Rejecting native Cursor grep tool in favor of MCP", {
455
+ execId: execMsg.execId,
456
+ execMsgId: execMsg.id,
457
+ });
353
458
  const result = create(GrepResultSchema, {
354
459
  result: {
355
460
  case: "error",
@@ -360,6 +465,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
360
465
  return;
361
466
  }
362
467
  if (execCase === "writeArgs") {
468
+ logPluginInfo("Rejecting native Cursor write tool in favor of MCP", {
469
+ execId: execMsg.execId,
470
+ execMsgId: execMsg.id,
471
+ path: execMsg.message.value.path,
472
+ });
363
473
  const args = execMsg.message.value;
364
474
  const result = create(WriteResultSchema, {
365
475
  result: {
@@ -374,6 +484,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
374
484
  return;
375
485
  }
376
486
  if (execCase === "deleteArgs") {
487
+ logPluginInfo("Rejecting native Cursor delete tool in favor of MCP", {
488
+ execId: execMsg.execId,
489
+ execMsgId: execMsg.id,
490
+ path: execMsg.message.value.path,
491
+ });
377
492
  const args = execMsg.message.value;
378
493
  const result = create(DeleteResultSchema, {
379
494
  result: {
@@ -388,6 +503,13 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
388
503
  return;
389
504
  }
390
505
  if (execCase === "shellArgs" || execCase === "shellStreamArgs") {
506
+ logPluginInfo("Rejecting native Cursor shell tool in favor of MCP", {
507
+ execId: execMsg.execId,
508
+ execMsgId: execMsg.id,
509
+ command: execMsg.message.value.command ?? "",
510
+ workingDirectory: execMsg.message.value.workingDirectory ?? "",
511
+ execCase,
512
+ });
391
513
  const args = execMsg.message.value;
392
514
  const result = create(ShellResultSchema, {
393
515
  result: {
@@ -404,6 +526,12 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
404
526
  return;
405
527
  }
406
528
  if (execCase === "backgroundShellSpawnArgs") {
529
+ logPluginInfo("Rejecting native Cursor background shell tool in favor of MCP", {
530
+ execId: execMsg.execId,
531
+ execMsgId: execMsg.id,
532
+ command: execMsg.message.value.command ?? "",
533
+ workingDirectory: execMsg.message.value.workingDirectory ?? "",
534
+ });
407
535
  const args = execMsg.message.value;
408
536
  const result = create(BackgroundShellSpawnResultSchema, {
409
537
  result: {
@@ -420,6 +548,10 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
420
548
  return;
421
549
  }
422
550
  if (execCase === "writeShellStdinArgs") {
551
+ logPluginInfo("Rejecting native Cursor shell stdin tool in favor of MCP", {
552
+ execId: execMsg.execId,
553
+ execMsgId: execMsg.id,
554
+ });
423
555
  const result = create(WriteShellStdinResultSchema, {
424
556
  result: {
425
557
  case: "error",
@@ -430,6 +562,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
430
562
  return;
431
563
  }
432
564
  if (execCase === "fetchArgs") {
565
+ logPluginInfo("Rejecting native Cursor fetch tool in favor of MCP", {
566
+ execId: execMsg.execId,
567
+ execMsgId: execMsg.id,
568
+ url: execMsg.message.value.url,
569
+ });
433
570
  const args = execMsg.message.value;
434
571
  const result = create(FetchResultSchema, {
435
572
  result: {
@@ -444,6 +581,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
444
581
  return;
445
582
  }
446
583
  if (execCase === "diagnosticsArgs") {
584
+ logPluginInfo("Rejecting native Cursor diagnostics tool in favor of MCP", {
585
+ execId: execMsg.execId,
586
+ execMsgId: execMsg.id,
587
+ path: execMsg.message.value.path,
588
+ });
447
589
  const result = create(DiagnosticsResultSchema, {});
448
590
  sendExecResult(execMsg, "diagnosticsResult", result, sendFrame);
449
591
  return;
@@ -457,6 +599,12 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec, onUnhandledE
457
599
  };
458
600
  const resultCase = miscCaseMap[execCase];
459
601
  if (resultCase) {
602
+ logPluginInfo("Responding to miscellaneous Cursor exec message", {
603
+ execCase,
604
+ execId: execMsg.execId,
605
+ execMsgId: execMsg.id,
606
+ resultCase,
607
+ });
460
608
  sendExecResult(execMsg, resultCase, create(McpResultSchema, {}), sendFrame);
461
609
  return;
462
610
  }
@@ -4,6 +4,4 @@ export interface StreamState {
4
4
  pendingExecs: PendingExec[];
5
5
  outputTokens: number;
6
6
  totalTokens: number;
7
- interactionToolArgsText: Map<string, string>;
8
- emittedToolCallIds: Set<string>;
9
7
  }
@@ -1,9 +1,10 @@
1
1
  import type { CursorSession } from "../cursor/bidi-session";
2
+ import type { CursorRule, McpToolDefinition } from "../proto/agent_pb";
2
3
  import type { ConversationRequestMetadata } from "./conversation-meta";
3
- import type { McpToolDefinition } from "../proto/agent_pb";
4
4
  export interface CursorRequestPayload {
5
5
  requestBytes: Uint8Array;
6
6
  blobStore: Map<string, Uint8Array>;
7
+ rules: CursorRule[];
7
8
  mcpTools: McpToolDefinition[];
8
9
  }
9
10
  /** A pending tool execution waiting for results from the caller. */
@@ -14,12 +15,16 @@ export interface PendingExec {
14
15
  toolName: string;
15
16
  /** Decoded arguments JSON string for SSE tool_calls emission. */
16
17
  decodedArgs: string;
18
+ source?: "interaction" | "exec";
19
+ cursorCallId?: string;
20
+ modelCallId?: string;
17
21
  }
18
22
  /** A live Cursor session kept alive across requests for tool result continuation. */
19
23
  export interface ActiveBridge {
20
24
  bridge: CursorSession;
21
25
  heartbeatTimer: NodeJS.Timeout;
22
26
  blobStore: Map<string, Uint8Array>;
27
+ rules: CursorRule[];
23
28
  mcpTools: McpToolDefinition[];
24
29
  pendingExecs: PendingExec[];
25
30
  modelId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwo/opencode-cursor-oauth",
3
- "version": "0.0.0-dev.4258a6733133",
3
+ "version": "0.0.0-dev.628837adf8c9",
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",
@@ -19,7 +19,6 @@
19
19
  ],
20
20
  "scripts": {
21
21
  "build": "tsc -p tsconfig.json",
22
- "test": "bun test/smoke.ts",
23
22
  "prepublishOnly": "npm run build"
24
23
  },
25
24
  "repository": {