@playwo/opencode-cursor-oauth 0.0.0-dev.0e8f5d6c8379 → 0.0.0-dev.14c6316643ec

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
  }
@@ -0,0 +1,6 @@
1
+ import type { CursorSession } from "../cursor/bidi-session";
2
+ export declare function createBridgeCloseController(bridge: CursorSession): {
3
+ noteTurnEnded: () => void;
4
+ noteCheckpoint: () => void;
5
+ dispose: () => void;
6
+ };
@@ -0,0 +1,37 @@
1
+ import { scheduleBridgeEnd } from "./stream-dispatch";
2
+ const TURN_END_GRACE_MS = 750;
3
+ export function createBridgeCloseController(bridge) {
4
+ let turnEnded = false;
5
+ let checkpointSeen = false;
6
+ let closeTimer;
7
+ const clearCloseTimer = () => {
8
+ if (!closeTimer)
9
+ return;
10
+ clearTimeout(closeTimer);
11
+ closeTimer = undefined;
12
+ };
13
+ const closeBridge = () => {
14
+ clearCloseTimer();
15
+ scheduleBridgeEnd(bridge);
16
+ };
17
+ return {
18
+ noteTurnEnded() {
19
+ turnEnded = true;
20
+ if (checkpointSeen) {
21
+ closeBridge();
22
+ return;
23
+ }
24
+ clearCloseTimer();
25
+ closeTimer = setTimeout(closeBridge, TURN_END_GRACE_MS);
26
+ },
27
+ noteCheckpoint() {
28
+ checkpointSeen = true;
29
+ if (turnEnded) {
30
+ closeBridge();
31
+ }
32
+ },
33
+ dispose() {
34
+ clearCloseTimer();
35
+ },
36
+ };
37
+ }
@@ -5,6 +5,7 @@ import { updateStoredConversationAfterCompletion } from "./conversation-state";
5
5
  import { startBridge } from "./bridge-session";
6
6
  import { updateConversationCheckpoint, syncStoredBlobStore, } from "./state-sync";
7
7
  import { computeUsage, createConnectFrameParser, createThinkingTagFilter, parseConnectEndStream, processServerMessage, scheduleBridgeEnd, } from "./stream-dispatch";
8
+ import { createBridgeCloseController } from "./bridge-close-controller";
8
9
  export async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
9
10
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
10
11
  const created = Math.floor(Date.now() / 1000);
@@ -33,19 +34,18 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
33
34
  let endStreamError = null;
34
35
  const pendingToolCalls = [];
35
36
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
37
+ const bridgeCloseController = createBridgeCloseController(bridge);
36
38
  const state = {
37
39
  toolCallIndex: 0,
38
40
  pendingExecs: [],
39
41
  outputTokens: 0,
40
42
  totalTokens: 0,
41
- interactionToolArgsText: new Map(),
42
- emittedToolCallIds: new Set(),
43
43
  };
44
44
  const tagFilter = createThinkingTagFilter();
45
45
  bridge.onData(createConnectFrameParser((messageBytes) => {
46
46
  try {
47
47
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
48
- processServerMessage(serverMessage, payload.blobStore, payload.mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
48
+ processServerMessage(serverMessage, payload.blobStore, payload.cloudRule, payload.mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
49
49
  if (isThinking)
50
50
  return;
51
51
  const { content } = tagFilter.process(text);
@@ -60,7 +60,10 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
60
60
  },
61
61
  });
62
62
  scheduleBridgeEnd(bridge);
63
- }, (checkpointBytes) => updateConversationCheckpoint(convKey, checkpointBytes), () => scheduleBridgeEnd(bridge), (info) => {
63
+ }, (checkpointBytes) => {
64
+ updateConversationCheckpoint(convKey, checkpointBytes);
65
+ bridgeCloseController.noteCheckpoint();
66
+ }, () => bridgeCloseController.noteTurnEnded(), (info) => {
64
67
  endStreamError = new Error(`Cursor returned unsupported ${info.category}: ${info.caseName}${info.detail ? ` (${info.detail})` : ""}`);
65
68
  logPluginError("Closing non-streaming Cursor bridge after unsupported message", {
66
69
  modelId,
@@ -97,6 +100,7 @@ async function collectFullResponse(payload, accessToken, modelId, convKey, metad
97
100
  scheduleBridgeEnd(bridge);
98
101
  }));
99
102
  bridge.onClose(() => {
103
+ bridgeCloseController.dispose();
100
104
  clearInterval(heartbeatTimer);
101
105
  syncStoredBlobStore(convKey, payload.blobStore);
102
106
  const flushed = tagFilter.flush();
@@ -2,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,17 +1,19 @@
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";
7
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
+ import { createBridgeCloseController } from "./bridge-close-controller";
10
11
  const SSE_KEEPALIVE_INTERVAL_MS = 15_000;
11
- function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
12
+ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, cloudRule, mcpTools, modelId, bridgeKey, convKey, metadata) {
12
13
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
13
14
  const created = Math.floor(Date.now() / 1000);
14
15
  let keepaliveTimer;
16
+ const bridgeCloseController = createBridgeCloseController(bridge);
15
17
  const stopKeepalive = () => {
16
18
  if (!keepaliveTimer)
17
19
  return;
@@ -27,8 +29,6 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
27
29
  pendingExecs: [],
28
30
  outputTokens: 0,
29
31
  totalTokens: 0,
30
- interactionToolArgsText: new Map(),
31
- emittedToolCallIds: new Set(),
32
32
  };
33
33
  const tagFilter = createThinkingTagFilter();
34
34
  let assistantText = metadata.assistantSeedText ?? "";
@@ -90,7 +90,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
90
90
  const processChunk = createConnectFrameParser((messageBytes) => {
91
91
  try {
92
92
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
93
- processServerMessage(serverMessage, blobStore, mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
93
+ processServerMessage(serverMessage, blobStore, cloudRule, mcpTools, (data) => bridge.write(data), state, (text, isThinking) => {
94
94
  if (isThinking) {
95
95
  sendSSE(makeChunk({ reasoning_content: text }));
96
96
  return;
@@ -148,6 +148,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
148
148
  bridge,
149
149
  heartbeatTimer,
150
150
  blobStore,
151
+ cloudRule,
151
152
  mcpTools,
152
153
  pendingExecs: state.pendingExecs,
153
154
  modelId,
@@ -159,7 +160,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
159
160
  sendSSE(makeChunk({}, "tool_calls"));
160
161
  sendDone();
161
162
  closeController();
162
- }, (checkpointBytes) => updateConversationCheckpoint(convKey, checkpointBytes), () => scheduleBridgeEnd(bridge), (info) => {
163
+ }, (checkpointBytes) => {
164
+ updateConversationCheckpoint(convKey, checkpointBytes);
165
+ bridgeCloseController.noteCheckpoint();
166
+ }, () => bridgeCloseController.noteTurnEnded(), (info) => {
163
167
  endStreamError = new Error(`Cursor returned unsupported ${info.category}: ${info.caseName}${info.detail ? ` (${info.detail})` : ""}`);
164
168
  logPluginError("Closing Cursor bridge after unsupported message", {
165
169
  modelId,
@@ -212,8 +216,24 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
212
216
  stopKeepalive();
213
217
  }
214
218
  }, SSE_KEEPALIVE_INTERVAL_MS);
219
+ logPluginInfo("Opened Cursor streaming bridge", {
220
+ modelId,
221
+ bridgeKey,
222
+ convKey,
223
+ mcpToolCount: mcpTools.length,
224
+ hasCloudRule: Boolean(cloudRule),
225
+ });
215
226
  bridge.onData(processChunk);
216
227
  bridge.onClose((code) => {
228
+ logPluginInfo("Cursor streaming bridge closed", {
229
+ modelId,
230
+ bridgeKey,
231
+ convKey,
232
+ code,
233
+ mcpExecReceived,
234
+ hadEndStreamError: Boolean(endStreamError),
235
+ });
236
+ bridgeCloseController.dispose();
217
237
  clearInterval(heartbeatTimer);
218
238
  stopKeepalive();
219
239
  syncStoredBlobStore(convKey, blobStore);
@@ -244,6 +264,7 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
244
264
  });
245
265
  },
246
266
  cancel(reason) {
267
+ bridgeCloseController.dispose();
247
268
  stopKeepalive();
248
269
  clearInterval(heartbeatTimer);
249
270
  syncStoredBlobStore(convKey, blobStore);
@@ -263,11 +284,36 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
263
284
  return new Response(stream, { headers: SSE_HEADERS });
264
285
  }
265
286
  export async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
287
+ logPluginInfo("Starting Cursor streaming response", {
288
+ modelId,
289
+ bridgeKey,
290
+ convKey,
291
+ mcpToolCount: payload.mcpTools.length,
292
+ });
266
293
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
267
- return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
294
+ return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.cloudRule, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
295
+ }
296
+ async function waitForResolvablePendingExecs(active, toolResults, timeoutMs = 2_000) {
297
+ const pendingToolCallIds = new Set(toolResults.map((result) => result.toolCallId));
298
+ const deadline = Date.now() + timeoutMs;
299
+ while (Date.now() < deadline) {
300
+ const unresolved = active.pendingExecs.filter((exec) => pendingToolCallIds.has(exec.toolCallId) && exec.execMsgId === 0);
301
+ if (unresolved.length === 0) {
302
+ return unresolved;
303
+ }
304
+ await new Promise((resolve) => setTimeout(resolve, 25));
305
+ }
306
+ const unresolved = active.pendingExecs.filter((exec) => pendingToolCallIds.has(exec.toolCallId) && exec.execMsgId === 0);
307
+ if (unresolved.length > 0) {
308
+ logPluginWarn("Cursor exec metadata did not arrive before tool-result resume", {
309
+ bridgeToolCallIds: unresolved.map((exec) => exec.toolCallId),
310
+ modelId: active.modelId,
311
+ });
312
+ }
313
+ return unresolved;
268
314
  }
269
- export function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
270
- const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata, } = active;
315
+ export async function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
316
+ const { bridge, heartbeatTimer, blobStore, cloudRule, mcpTools, pendingExecs, modelId, metadata, } = active;
271
317
  const resumeMetadata = {
272
318
  ...metadata,
273
319
  assistantSeedText: [
@@ -277,6 +323,33 @@ export function handleToolResultResume(active, toolResults, bridgeKey, convKey)
277
323
  .filter(Boolean)
278
324
  .join("\n\n"),
279
325
  };
326
+ logPluginInfo("Preparing Cursor tool-result resume", {
327
+ bridgeKey,
328
+ convKey,
329
+ modelId,
330
+ toolResults,
331
+ pendingExecs,
332
+ });
333
+ const unresolved = await waitForResolvablePendingExecs(active, toolResults);
334
+ logPluginInfo("Resolved pending exec state before Cursor tool-result resume", {
335
+ bridgeKey,
336
+ convKey,
337
+ modelId,
338
+ toolResults,
339
+ pendingExecs,
340
+ unresolvedPendingExecs: unresolved,
341
+ });
342
+ if (unresolved.length > 0) {
343
+ clearInterval(heartbeatTimer);
344
+ bridge.end();
345
+ return new Response(JSON.stringify({
346
+ error: {
347
+ message: "Cursor requested a tool call but never provided resumable exec metadata. Aborting instead of retrying with synthetic ids.",
348
+ type: "invalid_request_error",
349
+ code: "cursor_missing_exec_metadata",
350
+ },
351
+ }), { status: 400, headers: { "Content-Type": "application/json" } });
352
+ }
280
353
  for (const exec of pendingExecs) {
281
354
  const result = toolResults.find((toolResult) => toolResult.toolCallId === exec.toolCallId);
282
355
  const mcpResult = result
@@ -317,7 +390,20 @@ export function handleToolResultResume(active, toolResults, bridgeKey, convKey)
317
390
  const clientMessage = create(AgentClientMessageSchema, {
318
391
  message: { case: "execClientMessage", value: execClientMessage },
319
392
  });
393
+ logPluginInfo("Sending Cursor tool-result resume message", {
394
+ bridgeKey,
395
+ convKey,
396
+ modelId,
397
+ toolCallId: exec.toolCallId,
398
+ toolName: exec.toolName,
399
+ source: exec.source,
400
+ execId: exec.execId,
401
+ execMsgId: exec.execMsgId,
402
+ cursorCallId: exec.cursorCallId,
403
+ modelCallId: exec.modelCallId,
404
+ matchedToolResult: result,
405
+ });
320
406
  bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
321
407
  }
322
- return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
408
+ return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, cloudRule, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
323
409
  }
@@ -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, 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 cloudRule = buildCloudRule(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, cloudRule);
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 cloudRule = buildCloudRule(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, cloudRule);
83
75
  }
84
- function buildRunRequest(modelId, conversationId, conversationState, action, blobStore) {
76
+ function buildRunRequest(modelId, conversationId, conversationState, action, blobStore, cloudRule) {
85
77
  const modelDetails = create(ModelDetailsSchema, {
86
78
  modelId,
87
79
  displayModelId: modelId,
@@ -99,6 +91,11 @@ function buildRunRequest(modelId, conversationId, conversationState, action, blo
99
91
  return {
100
92
  requestBytes: toBinary(AgentClientMessageSchema, clientMessage),
101
93
  blobStore,
94
+ cloudRule,
102
95
  mcpTools: [],
103
96
  };
104
97
  }
98
+ function buildCloudRule(systemPrompt) {
99
+ const content = systemPrompt.trim();
100
+ return content || undefined;
101
+ }
@@ -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>, cloudRule: string | undefined, 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
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, cloudRule, 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, state, onMcpExec, onUnhandledExec);
140
+ handleExecMessage(msg.message.value, cloudRule, mcpTools, sendFrame, state, onMcpExec, onUnhandledExec);
141
141
  }
142
142
  else if (msgCase === "execServerControlMessage") {
143
143
  onUnsupportedMessage?.({
@@ -164,8 +164,19 @@ export function processServerMessage(msg, blobStore, mcpTools, sendFrame, state,
164
164
  });
165
165
  }
166
166
  }
167
- function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded, onUnsupportedMessage) {
167
+ function handleInteractionUpdate(update, state, onText, onTurnEnded, onUnsupportedMessage) {
168
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
+ }
169
180
  if (updateCase === "textDelta") {
170
181
  const delta = update.message.value.text || "";
171
182
  if (delta)
@@ -180,16 +191,19 @@ function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded,
180
191
  state.outputTokens += update.message.value.tokens ?? 0;
181
192
  }
182
193
  else if (updateCase === "partialToolCall") {
183
- const partial = update.message.value;
184
- if (partial.callId && partial.argsTextDelta) {
185
- const existing = state.interactionToolArgsText.get(partial.callId) ?? "";
186
- state.interactionToolArgsText.set(partial.callId, `${existing}${partial.argsTextDelta}`);
187
- }
194
+ return;
188
195
  }
189
196
  else if (updateCase === "toolCallCompleted") {
190
- const callId = update.message.value?.callId;
191
- if (callId)
192
- state.interactionToolArgsText.delete(callId);
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
+ }
193
207
  }
194
208
  else if (updateCase === "turnEnded") {
195
209
  onTurnEnded?.();
@@ -212,10 +226,8 @@ function handleInteractionUpdate(update, state, onText, onMcpExec, onTurnEnded,
212
226
  caseName: updateCase ?? "undefined",
213
227
  });
214
228
  }
215
- // toolCallStarted, partialToolCall, toolCallDelta, and toolCallCompleted are
216
- // informational only. Actionable MCP tool execution must come from
217
- // execServerMessage.mcpArgs so tool results can be resumed with the correct
218
- // exec envelope.
229
+ // Interaction tool-call updates are informational only. Resumable MCP tool
230
+ // execution comes from execServerMessage.mcpArgs.
219
231
  }
220
232
  function handleInteractionQuery(query, sendFrame, onUnsupportedMessage) {
221
233
  const queryCase = query.query.case;
@@ -342,9 +354,19 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
342
354
  sendKvResponse(kvMsg, "setBlobResult", create(SetBlobResultSchema, {}), sendFrame);
343
355
  }
344
356
  }
345
- function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnhandledExec) {
357
+ function handleExecMessage(execMsg, cloudRule, mcpTools, sendFrame, state, onMcpExec, onUnhandledExec) {
346
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
+ });
347
364
  if (execCase === "requestContextArgs") {
365
+ logPluginInfo("Responding to Cursor requestContextArgs", {
366
+ execId: execMsg.execId,
367
+ execMsgId: execMsg.id,
368
+ mcpToolCount: mcpTools.length,
369
+ });
348
370
  const requestContext = create(RequestContextSchema, {
349
371
  rules: [],
350
372
  repositoryInfo: [],
@@ -352,6 +374,7 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
352
374
  gitRepos: [],
353
375
  projectLayouts: [],
354
376
  mcpInstructions: [],
377
+ cloudRule,
355
378
  fileContents: {},
356
379
  customSubagents: [],
357
380
  });
@@ -367,18 +390,23 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
367
390
  if (execCase === "mcpArgs") {
368
391
  const mcpArgs = execMsg.message.value;
369
392
  const decoded = decodeMcpArgsMap(mcpArgs.args ?? {});
370
- const toolCallId = mcpArgs.toolCallId || crypto.randomUUID();
371
- if (state.emittedToolCallIds.has(toolCallId)) {
372
- return;
373
- }
374
- state.emittedToolCallIds.add(toolCallId);
375
- onMcpExec({
393
+ const exec = {
376
394
  execId: execMsg.execId,
377
395
  execMsgId: execMsg.id,
378
- toolCallId,
396
+ toolCallId: mcpArgs.toolCallId || crypto.randomUUID(),
379
397
  toolName: mcpArgs.toolName || mcpArgs.name,
380
398
  decodedArgs: JSON.stringify(decoded),
399
+ source: "exec",
400
+ };
401
+ logPluginInfo("Received Cursor exec MCP tool metadata", {
402
+ toolCallId: exec.toolCallId,
403
+ toolName: exec.toolName,
404
+ source: exec.source,
405
+ execId: exec.execId,
406
+ execMsgId: exec.execMsgId,
407
+ decodedArgs: exec.decodedArgs,
381
408
  });
409
+ onMcpExec(exec);
382
410
  return;
383
411
  }
384
412
  // --- Reject native Cursor tools ---
@@ -386,6 +414,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
386
414
  // so it falls back to our MCP tools (registered via RequestContext).
387
415
  const REJECT_REASON = "Tool not available in this environment. Use the MCP tools provided instead.";
388
416
  if (execCase === "readArgs") {
417
+ logPluginInfo("Rejecting native Cursor read tool in favor of MCP", {
418
+ execId: execMsg.execId,
419
+ execMsgId: execMsg.id,
420
+ path: execMsg.message.value.path,
421
+ });
389
422
  const args = execMsg.message.value;
390
423
  const result = create(ReadResultSchema, {
391
424
  result: {
@@ -400,6 +433,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
400
433
  return;
401
434
  }
402
435
  if (execCase === "lsArgs") {
436
+ logPluginInfo("Rejecting native Cursor ls tool in favor of MCP", {
437
+ execId: execMsg.execId,
438
+ execMsgId: execMsg.id,
439
+ path: execMsg.message.value.path,
440
+ });
403
441
  const args = execMsg.message.value;
404
442
  const result = create(LsResultSchema, {
405
443
  result: {
@@ -414,6 +452,10 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
414
452
  return;
415
453
  }
416
454
  if (execCase === "grepArgs") {
455
+ logPluginInfo("Rejecting native Cursor grep tool in favor of MCP", {
456
+ execId: execMsg.execId,
457
+ execMsgId: execMsg.id,
458
+ });
417
459
  const result = create(GrepResultSchema, {
418
460
  result: {
419
461
  case: "error",
@@ -424,6 +466,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
424
466
  return;
425
467
  }
426
468
  if (execCase === "writeArgs") {
469
+ logPluginInfo("Rejecting native Cursor write tool in favor of MCP", {
470
+ execId: execMsg.execId,
471
+ execMsgId: execMsg.id,
472
+ path: execMsg.message.value.path,
473
+ });
427
474
  const args = execMsg.message.value;
428
475
  const result = create(WriteResultSchema, {
429
476
  result: {
@@ -438,6 +485,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
438
485
  return;
439
486
  }
440
487
  if (execCase === "deleteArgs") {
488
+ logPluginInfo("Rejecting native Cursor delete tool in favor of MCP", {
489
+ execId: execMsg.execId,
490
+ execMsgId: execMsg.id,
491
+ path: execMsg.message.value.path,
492
+ });
441
493
  const args = execMsg.message.value;
442
494
  const result = create(DeleteResultSchema, {
443
495
  result: {
@@ -452,6 +504,13 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
452
504
  return;
453
505
  }
454
506
  if (execCase === "shellArgs" || execCase === "shellStreamArgs") {
507
+ logPluginInfo("Rejecting native Cursor shell tool in favor of MCP", {
508
+ execId: execMsg.execId,
509
+ execMsgId: execMsg.id,
510
+ command: execMsg.message.value.command ?? "",
511
+ workingDirectory: execMsg.message.value.workingDirectory ?? "",
512
+ execCase,
513
+ });
455
514
  const args = execMsg.message.value;
456
515
  const result = create(ShellResultSchema, {
457
516
  result: {
@@ -468,6 +527,12 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
468
527
  return;
469
528
  }
470
529
  if (execCase === "backgroundShellSpawnArgs") {
530
+ logPluginInfo("Rejecting native Cursor background shell tool in favor of MCP", {
531
+ execId: execMsg.execId,
532
+ execMsgId: execMsg.id,
533
+ command: execMsg.message.value.command ?? "",
534
+ workingDirectory: execMsg.message.value.workingDirectory ?? "",
535
+ });
471
536
  const args = execMsg.message.value;
472
537
  const result = create(BackgroundShellSpawnResultSchema, {
473
538
  result: {
@@ -484,6 +549,10 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
484
549
  return;
485
550
  }
486
551
  if (execCase === "writeShellStdinArgs") {
552
+ logPluginInfo("Rejecting native Cursor shell stdin tool in favor of MCP", {
553
+ execId: execMsg.execId,
554
+ execMsgId: execMsg.id,
555
+ });
487
556
  const result = create(WriteShellStdinResultSchema, {
488
557
  result: {
489
558
  case: "error",
@@ -494,6 +563,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
494
563
  return;
495
564
  }
496
565
  if (execCase === "fetchArgs") {
566
+ logPluginInfo("Rejecting native Cursor fetch tool in favor of MCP", {
567
+ execId: execMsg.execId,
568
+ execMsgId: execMsg.id,
569
+ url: execMsg.message.value.url,
570
+ });
497
571
  const args = execMsg.message.value;
498
572
  const result = create(FetchResultSchema, {
499
573
  result: {
@@ -508,6 +582,11 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
508
582
  return;
509
583
  }
510
584
  if (execCase === "diagnosticsArgs") {
585
+ logPluginInfo("Rejecting native Cursor diagnostics tool in favor of MCP", {
586
+ execId: execMsg.execId,
587
+ execMsgId: execMsg.id,
588
+ path: execMsg.message.value.path,
589
+ });
511
590
  const result = create(DiagnosticsResultSchema, {});
512
591
  sendExecResult(execMsg, "diagnosticsResult", result, sendFrame);
513
592
  return;
@@ -521,6 +600,12 @@ function handleExecMessage(execMsg, mcpTools, sendFrame, state, onMcpExec, onUnh
521
600
  };
522
601
  const resultCase = miscCaseMap[execCase];
523
602
  if (resultCase) {
603
+ logPluginInfo("Responding to miscellaneous Cursor exec message", {
604
+ execCase,
605
+ execId: execMsg.execId,
606
+ execMsgId: execMsg.id,
607
+ resultCase,
608
+ });
524
609
  sendExecResult(execMsg, resultCase, create(McpResultSchema, {}), sendFrame);
525
610
  return;
526
611
  }
@@ -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 { ConversationRequestMetadata } from "./conversation-meta";
3
2
  import type { McpToolDefinition } from "../proto/agent_pb";
3
+ import type { ConversationRequestMetadata } from "./conversation-meta";
4
4
  export interface CursorRequestPayload {
5
5
  requestBytes: Uint8Array;
6
6
  blobStore: Map<string, Uint8Array>;
7
+ cloudRule?: string;
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
+ cloudRule?: string;
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.0e8f5d6c8379",
3
+ "version": "0.0.0-dev.14c6316643ec",
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": {