@playwo/opencode-cursor-oauth 0.0.0-dev.f7099c3761b9 → 0.0.0-dev.fc97acd8b777

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,103 +1,31 @@
1
- # @playwo/opencode-cursor-oauth
1
+ # opencode-cursor-oauth
2
2
 
3
- OpenCode plugin that connects to Cursor's API, giving you access to Cursor
4
- models inside OpenCode with full tool-calling support.
3
+ Use Cursor models (Claude, GPT, Gemini, etc.) inside [OpenCode](https://opencode.ai).
5
4
 
6
- ## Install in OpenCode
5
+ ## What it does
7
6
 
8
- Add this to `~/.config/opencode/opencode.json`:
7
+ - **OAuth login** to Cursor via browser
8
+ - **Model discovery** — automatically fetches your available Cursor models
9
+ - **Local proxy** — runs an OpenAI-compatible endpoint that translates to Cursor's gRPC protocol
10
+ - **Auto-refresh** — handles token expiration automatically
9
11
 
10
- ```jsonc
11
- {
12
- "$schema": "https://opencode.ai/config.json",
13
- "plugin": [
14
- "@playwo/opencode-cursor-oauth"
15
- ],
16
- "provider": {
17
- "cursor": {
18
- "name": "Cursor"
19
- }
20
- }
21
- }
22
- ```
23
-
24
- The `cursor` provider stub is required because OpenCode drops providers that do
25
- not already exist in its bundled provider catalog.
26
-
27
- OpenCode installs npm plugins automatically at startup, so users do not need to
28
- clone this repository.
29
-
30
- ## Authenticate
31
-
32
- ```sh
33
- opencode auth login --provider cursor
34
- ```
35
-
36
- This opens Cursor OAuth in the browser. Tokens are stored in
37
- `~/.local/share/opencode/auth.json` and refreshed automatically.
38
-
39
- ## Use
40
-
41
- Start OpenCode and select any Cursor model. The plugin starts a local
42
- OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API.
43
-
44
- ## How it works
45
-
46
- 1. OAuth — browser-based login to Cursor via PKCE.
47
- 2. Model discovery — queries Cursor's gRPC API for all available models; if discovery fails, the plugin disables the Cursor provider for that load and shows a visible error toast instead of crashing OpenCode.
48
- 3. Local proxy — translates `POST /v1/chat/completions` into Cursor's
49
- protobuf/Connect protocol.
50
- 4. Native tool routing — rejects Cursor's built-in filesystem/shell tools and
51
- exposes OpenCode's tool surface via Cursor MCP instead.
52
-
53
- Cursor agent streaming uses Cursor's `RunSSE` + `BidiAppend` transport, so the
54
- plugin runs entirely inside OpenCode without a Node sidecar.
12
+ ## Install
55
13
 
56
- ## Architecture
14
+ Add to your `opencode.json`:
57
15
 
16
+ ```json
17
+ {
18
+ "plugin": ["@playwo/opencode-cursor-oauth"]
19
+ }
58
20
  ```
59
- OpenCode --> /v1/chat/completions --> Bun.serve (proxy)
60
- |
61
- RunSSE stream + BidiAppend writes
62
- |
63
- Cursor Connect/SSE transport
64
- |
65
- api2.cursor.sh gRPC
66
- ```
67
-
68
- ### Tool call flow
69
-
70
- ```
71
- 1. Cursor model receives OpenAI tools via RequestContext (as MCP tool defs)
72
- 2. Model tries native tools (readArgs, shellArgs, etc.)
73
- 3. Proxy rejects each with typed error (ReadRejected, ShellRejected, etc.)
74
- 4. Model falls back to MCP tool -> mcpArgs exec message
75
- 5. Proxy emits OpenAI tool_calls SSE chunk, pauses the Cursor stream
76
- 6. OpenCode executes tool, sends result in follow-up request
77
- 7. Proxy resumes the Cursor stream with mcpResult and continues streaming
78
- ```
79
-
80
- ## Develop locally
81
-
82
- ```sh
83
- bun install
84
- bun run build
85
- bun test/smoke.ts
86
- ```
87
-
88
- ## Publish
89
21
 
90
- GitHub Actions publishes this package with `.github/workflows/publish-npm.yml`.
22
+ Then authenticate via the OpenCode UI (Settings → Providers → Cursor → Login).
91
23
 
92
- - branch pushes publish a `dev` build as `0.0.0-dev.<sha>`
93
- - versioned releases publish `latest` using the `package.json` version and upload the packed `.tgz` to the GitHub release
94
-
95
- Repository secrets required:
24
+ ## Requirements
96
25
 
97
- - `NPM_TOKEN` for npm publish access
26
+ - Cursor account with API access
27
+ - OpenCode 1.2+
98
28
 
99
- ## Requirements
29
+ ## License
100
30
 
101
- - [OpenCode](https://opencode.ai)
102
- - [Bun](https://bun.sh)
103
- - Active [Cursor](https://cursor.com) subscription
31
+ MIT
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { generateCursorAuthParams, getTokenExpiry, pollCursorAuth, refreshCursorToken, } from "./auth";
2
2
  import { configurePluginLogger, errorDetails, logPluginError, logPluginWarn } from "./logger";
3
3
  import { getCursorModels } from "./models";
4
- import { startProxy, stopProxy } from "./proxy";
4
+ import { startProxy, stopProxy, } from "./proxy";
5
5
  const CURSOR_PROVIDER_ID = "cursor";
6
6
  let lastModelDiscoveryError = null;
7
7
  /**
@@ -128,6 +128,15 @@ export const CursorAuthPlugin = async (input) => {
128
128
  },
129
129
  ],
130
130
  },
131
+ async "chat.headers"(incoming, output) {
132
+ if (incoming.model.providerID !== CURSOR_PROVIDER_ID)
133
+ return;
134
+ output.headers["x-opencode-session-id"] = incoming.sessionID;
135
+ output.headers["x-session-id"] = incoming.sessionID;
136
+ if (incoming.agent) {
137
+ output.headers["x-opencode-agent"] = incoming.agent;
138
+ }
139
+ },
131
140
  };
132
141
  };
133
142
  function buildCursorProviderModels(models, port) {
package/dist/proxy.js CHANGED
@@ -41,6 +41,31 @@ function evictStaleConversations() {
41
41
  }
42
42
  }
43
43
  }
44
+ function normalizeAgentKey(agentKey) {
45
+ const trimmed = agentKey?.trim();
46
+ return trimmed ? trimmed : "default";
47
+ }
48
+ function hashString(value) {
49
+ return createHash("sha256").update(value).digest("hex");
50
+ }
51
+ function createStoredConversation() {
52
+ return {
53
+ conversationId: crypto.randomUUID(),
54
+ checkpoint: null,
55
+ blobStore: new Map(),
56
+ lastAccessMs: Date.now(),
57
+ systemPromptHash: "",
58
+ completedTurnsFingerprint: "",
59
+ };
60
+ }
61
+ function resetStoredConversation(stored) {
62
+ stored.conversationId = crypto.randomUUID();
63
+ stored.checkpoint = null;
64
+ stored.blobStore = new Map();
65
+ stored.lastAccessMs = Date.now();
66
+ stored.systemPromptHash = "";
67
+ stored.completedTurnsFingerprint = "";
68
+ }
44
69
  /** Connect protocol frame: [1-byte flags][4-byte BE length][payload] */
45
70
  function frameConnectMessage(data, flags = 0) {
46
71
  const frame = Buffer.alloc(5 + data.length);
@@ -406,7 +431,14 @@ async function callCursorUnaryRpcOverHttp2(options, target) {
406
431
  timedOut,
407
432
  });
408
433
  });
409
- stream.end(Buffer.from(options.requestBody));
434
+ // Bun's node:http2 client currently breaks on end(Buffer.alloc(0)) against
435
+ // Cursor's HTTPS endpoint, but a header-only end() succeeds for empty unary bodies.
436
+ if (options.requestBody.length > 0) {
437
+ stream.end(Buffer.from(options.requestBody));
438
+ }
439
+ else {
440
+ stream.end();
441
+ }
410
442
  }
411
443
  catch (error) {
412
444
  logPluginError("Cursor unary HTTP/2 setup failed", {
@@ -464,7 +496,11 @@ export async function startProxy(getAccessToken, models = []) {
464
496
  throw new Error("Cursor proxy access token provider not configured");
465
497
  }
466
498
  const accessToken = await proxyAccessTokenProvider();
467
- return handleChatCompletion(body, accessToken);
499
+ const sessionId = req.headers.get("x-opencode-session-id")
500
+ ?? req.headers.get("x-session-id")
501
+ ?? undefined;
502
+ const agentKey = req.headers.get("x-opencode-agent") ?? undefined;
503
+ return handleChatCompletion(body, accessToken, { sessionId, agentKey });
468
504
  }
469
505
  catch (err) {
470
506
  const message = err instanceof Error ? err.message : String(err);
@@ -502,10 +538,11 @@ export function stopProxy() {
502
538
  activeBridges.clear();
503
539
  conversationStates.clear();
504
540
  }
505
- function handleChatCompletion(body, accessToken) {
506
- const { systemPrompt, userText, turns, toolResults } = parseMessages(body.messages);
541
+ function handleChatCompletion(body, accessToken, context = {}) {
542
+ const parsed = parseMessages(body.messages);
543
+ const { systemPrompt, userText, turns, toolResults, pendingAssistantSummary, completedTurnsFingerprint, } = parsed;
507
544
  const modelId = body.model;
508
- const tools = body.tools ?? [];
545
+ const tools = selectToolsForChoice(body.tools ?? [], body.tool_choice);
509
546
  if (!userText && toolResults.length === 0) {
510
547
  return new Response(JSON.stringify({
511
548
  error: {
@@ -514,16 +551,24 @@ function handleChatCompletion(body, accessToken) {
514
551
  },
515
552
  }), { status: 400, headers: { "Content-Type": "application/json" } });
516
553
  }
517
- // bridgeKey: model-specific, for active tool-call bridges
554
+ // bridgeKey: session/agent-scoped, for active tool-call bridges
518
555
  // convKey: model-independent, for conversation state that survives model switches
519
- const bridgeKey = deriveBridgeKey(modelId, body.messages);
520
- const convKey = deriveConversationKey(body.messages);
556
+ const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId, context.agentKey);
557
+ const convKey = deriveConversationKey(body.messages, context.sessionId, context.agentKey);
521
558
  const activeBridge = activeBridges.get(bridgeKey);
522
559
  if (activeBridge && toolResults.length > 0) {
523
560
  activeBridges.delete(bridgeKey);
524
561
  if (activeBridge.bridge.alive) {
562
+ if (activeBridge.modelId !== modelId) {
563
+ logPluginWarn("Resuming pending Cursor tool call on original model after model switch", {
564
+ requestedModelId: modelId,
565
+ resumedModelId: activeBridge.modelId,
566
+ convKey,
567
+ bridgeKey,
568
+ });
569
+ }
525
570
  // Resume the live bridge with tool results
526
- return handleToolResultResume(activeBridge, toolResults, modelId, bridgeKey, convKey);
571
+ return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
527
572
  }
528
573
  // Bridge died (timeout, server disconnect, etc.).
529
574
  // Clean up and fall through to start a fresh bridge.
@@ -538,28 +583,43 @@ function handleChatCompletion(body, accessToken) {
538
583
  }
539
584
  let stored = conversationStates.get(convKey);
540
585
  if (!stored) {
541
- stored = {
542
- conversationId: deterministicConversationId(convKey),
543
- checkpoint: null,
544
- blobStore: new Map(),
545
- lastAccessMs: Date.now(),
546
- };
586
+ stored = createStoredConversation();
547
587
  conversationStates.set(convKey, stored);
548
588
  }
589
+ const systemPromptHash = hashString(systemPrompt);
590
+ if (stored.checkpoint
591
+ && (stored.systemPromptHash !== systemPromptHash
592
+ || (turns.length > 0 && stored.completedTurnsFingerprint !== completedTurnsFingerprint))) {
593
+ resetStoredConversation(stored);
594
+ }
595
+ stored.systemPromptHash = systemPromptHash;
596
+ stored.completedTurnsFingerprint = completedTurnsFingerprint;
549
597
  stored.lastAccessMs = Date.now();
550
598
  evictStaleConversations();
551
599
  // Build the request. When tool results are present but the bridge died,
552
600
  // we must still include the last user text so Cursor has context.
553
601
  const mcpTools = buildMcpToolDefinitions(tools);
554
- const effectiveUserText = userText || (toolResults.length > 0
555
- ? toolResults.map((r) => r.content).join("\n")
556
- : "");
602
+ const effectiveUserText = toolResults.length > 0
603
+ ? buildToolResumePrompt(userText, pendingAssistantSummary, toolResults)
604
+ : userText;
557
605
  const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, turns, stored.conversationId, stored.checkpoint, stored.blobStore);
558
606
  payload.mcpTools = mcpTools;
559
607
  if (body.stream === false) {
560
- return handleNonStreamingResponse(payload, accessToken, modelId, convKey);
608
+ return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
609
+ systemPrompt,
610
+ systemPromptHash,
611
+ completedTurnsFingerprint,
612
+ turns,
613
+ userText,
614
+ });
561
615
  }
562
- return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey);
616
+ return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, {
617
+ systemPrompt,
618
+ systemPromptHash,
619
+ completedTurnsFingerprint,
620
+ turns,
621
+ userText,
622
+ });
563
623
  }
564
624
  /** Normalize OpenAI message content to a plain string. */
565
625
  function textContent(content) {
@@ -574,8 +634,6 @@ function textContent(content) {
574
634
  }
575
635
  function parseMessages(messages) {
576
636
  let systemPrompt = "You are a helpful assistant.";
577
- const pairs = [];
578
- const toolResults = [];
579
637
  // Collect system messages
580
638
  const systemParts = messages
581
639
  .filter((m) => m.role === "system")
@@ -583,40 +641,152 @@ function parseMessages(messages) {
583
641
  if (systemParts.length > 0) {
584
642
  systemPrompt = systemParts.join("\n");
585
643
  }
586
- // Separate tool results from conversation turns
587
644
  const nonSystem = messages.filter((m) => m.role !== "system");
588
- let pendingUser = "";
645
+ const parsedTurns = [];
646
+ let currentTurn;
589
647
  for (const msg of nonSystem) {
590
- if (msg.role === "tool") {
591
- toolResults.push({
592
- toolCallId: msg.tool_call_id ?? "",
593
- content: textContent(msg.content),
594
- });
648
+ if (msg.role === "user") {
649
+ if (currentTurn)
650
+ parsedTurns.push(currentTurn);
651
+ currentTurn = {
652
+ userText: textContent(msg.content),
653
+ segments: [],
654
+ };
655
+ continue;
595
656
  }
596
- else if (msg.role === "user") {
597
- if (pendingUser) {
598
- pairs.push({ userText: pendingUser, assistantText: "" });
599
- }
600
- pendingUser = textContent(msg.content);
657
+ if (!currentTurn) {
658
+ currentTurn = { userText: "", segments: [] };
601
659
  }
602
- else if (msg.role === "assistant") {
603
- // Skip assistant messages that are just tool_calls with no text
660
+ if (msg.role === "assistant") {
604
661
  const text = textContent(msg.content);
605
- if (pendingUser) {
606
- pairs.push({ userText: pendingUser, assistantText: text });
607
- pendingUser = "";
662
+ if (text) {
663
+ currentTurn.segments.push({ kind: "assistantText", text });
664
+ }
665
+ if (msg.tool_calls?.length) {
666
+ currentTurn.segments.push({
667
+ kind: "assistantToolCalls",
668
+ toolCalls: msg.tool_calls,
669
+ });
608
670
  }
671
+ continue;
609
672
  }
673
+ if (msg.role === "tool") {
674
+ currentTurn.segments.push({
675
+ kind: "toolResult",
676
+ result: {
677
+ toolCallId: msg.tool_call_id ?? "",
678
+ content: textContent(msg.content),
679
+ },
680
+ });
681
+ }
682
+ }
683
+ if (currentTurn)
684
+ parsedTurns.push(currentTurn);
685
+ let userText = "";
686
+ let toolResults = [];
687
+ let pendingAssistantSummary = "";
688
+ let completedTurnStates = parsedTurns;
689
+ const lastTurn = parsedTurns.at(-1);
690
+ if (lastTurn) {
691
+ const trailingSegments = splitTrailingToolResults(lastTurn.segments);
692
+ const hasAssistantSummary = trailingSegments.base.length > 0;
693
+ if (trailingSegments.trailing.length > 0 && hasAssistantSummary) {
694
+ completedTurnStates = parsedTurns.slice(0, -1);
695
+ userText = lastTurn.userText;
696
+ toolResults = trailingSegments.trailing.map((segment) => segment.result);
697
+ pendingAssistantSummary = summarizeTurnSegments(trailingSegments.base);
698
+ }
699
+ else if (lastTurn.userText && lastTurn.segments.length === 0) {
700
+ completedTurnStates = parsedTurns.slice(0, -1);
701
+ userText = lastTurn.userText;
702
+ }
703
+ }
704
+ const turns = completedTurnStates
705
+ .map((turn) => ({
706
+ userText: turn.userText,
707
+ assistantText: summarizeTurnSegments(turn.segments),
708
+ }))
709
+ .filter((turn) => turn.userText || turn.assistantText);
710
+ return {
711
+ systemPrompt,
712
+ userText,
713
+ turns,
714
+ toolResults,
715
+ pendingAssistantSummary,
716
+ completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
717
+ };
718
+ }
719
+ function splitTrailingToolResults(segments) {
720
+ let index = segments.length;
721
+ while (index > 0 && segments[index - 1]?.kind === "toolResult") {
722
+ index -= 1;
723
+ }
724
+ return {
725
+ base: segments.slice(0, index),
726
+ trailing: segments.slice(index).filter((segment) => segment.kind === "toolResult"),
727
+ };
728
+ }
729
+ function summarizeTurnSegments(segments) {
730
+ const parts = [];
731
+ for (const segment of segments) {
732
+ if (segment.kind === "assistantText") {
733
+ const trimmed = segment.text.trim();
734
+ if (trimmed)
735
+ parts.push(trimmed);
736
+ continue;
737
+ }
738
+ if (segment.kind === "assistantToolCalls") {
739
+ const summary = segment.toolCalls.map(formatToolCallSummary).join("\n\n");
740
+ if (summary)
741
+ parts.push(summary);
742
+ continue;
743
+ }
744
+ parts.push(formatToolResultSummary(segment.result));
745
+ }
746
+ return parts.join("\n\n").trim();
747
+ }
748
+ function formatToolCallSummary(call) {
749
+ const args = call.function.arguments?.trim();
750
+ return args
751
+ ? `[assistant requested tool ${call.function.name} id=${call.id}]\n${args}`
752
+ : `[assistant requested tool ${call.function.name} id=${call.id}]`;
753
+ }
754
+ function formatToolResultSummary(result) {
755
+ const label = result.toolCallId
756
+ ? `[tool result id=${result.toolCallId}]`
757
+ : "[tool result]";
758
+ const content = result.content.trim();
759
+ return content ? `${label}\n${content}` : label;
760
+ }
761
+ function buildCompletedTurnsFingerprint(systemPrompt, turns) {
762
+ return hashString(JSON.stringify({ systemPrompt, turns }));
763
+ }
764
+ function buildToolResumePrompt(userText, pendingAssistantSummary, toolResults) {
765
+ const parts = [userText.trim()];
766
+ if (pendingAssistantSummary.trim()) {
767
+ parts.push(`[previous assistant tool activity]\n${pendingAssistantSummary.trim()}`);
768
+ }
769
+ if (toolResults.length > 0) {
770
+ parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
610
771
  }
611
- let lastUserText = "";
612
- if (pendingUser) {
613
- lastUserText = pendingUser;
772
+ return parts.filter(Boolean).join("\n\n");
773
+ }
774
+ function selectToolsForChoice(tools, toolChoice) {
775
+ if (!tools.length)
776
+ return [];
777
+ if (toolChoice === undefined || toolChoice === null || toolChoice === "auto" || toolChoice === "required") {
778
+ return tools;
779
+ }
780
+ if (toolChoice === "none") {
781
+ return [];
614
782
  }
615
- else if (pairs.length > 0 && toolResults.length === 0) {
616
- const last = pairs.pop();
617
- lastUserText = last.userText;
783
+ if (typeof toolChoice === "object") {
784
+ const choice = toolChoice;
785
+ if (choice.type === "function" && typeof choice.function?.name === "string") {
786
+ return tools.filter((tool) => tool.function.name === choice.function.name);
787
+ }
618
788
  }
619
- return { systemPrompt, userText: lastUserText, turns: pairs, toolResults };
789
+ return tools;
620
790
  }
621
791
  /** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
622
792
  function buildMcpToolDefinitions(tools) {
@@ -759,6 +929,12 @@ function makeHeartbeatBytes() {
759
929
  });
760
930
  return toBinary(AgentClientMessageSchema, heartbeat);
761
931
  }
932
+ function scheduleBridgeEnd(bridge) {
933
+ queueMicrotask(() => {
934
+ if (bridge.alive)
935
+ bridge.end();
936
+ });
937
+ }
762
938
  /**
763
939
  * Create a stateful parser for Connect protocol frames.
764
940
  * Handles buffering partial data across chunks.
@@ -901,6 +1077,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
901
1077
  const blobId = kvMsg.message.value.blobId;
902
1078
  const blobIdKey = Buffer.from(blobId).toString("hex");
903
1079
  const blobData = blobStore.get(blobIdKey);
1080
+ if (!blobData) {
1081
+ logPluginWarn("Cursor requested missing blob", {
1082
+ blobId: blobIdKey,
1083
+ knownBlobCount: blobStore.size,
1084
+ });
1085
+ }
904
1086
  sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
905
1087
  }
906
1088
  else if (kvCase === "setBlobArgs") {
@@ -1065,42 +1247,56 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
1065
1247
  });
1066
1248
  sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
1067
1249
  }
1068
- /** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */
1069
- function deriveBridgeKey(modelId, messages) {
1250
+ /** Derive a key for active bridge lookup (tool-call continuations). */
1251
+ function deriveBridgeKey(modelId, messages, sessionId, agentKey) {
1252
+ if (sessionId) {
1253
+ const normalizedAgent = normalizeAgentKey(agentKey);
1254
+ return createHash("sha256")
1255
+ .update(`bridge:${sessionId}:${normalizedAgent}`)
1256
+ .digest("hex")
1257
+ .slice(0, 16);
1258
+ }
1070
1259
  const firstUserMsg = messages.find((m) => m.role === "user");
1071
1260
  const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1261
+ const normalizedAgent = normalizeAgentKey(agentKey);
1072
1262
  return createHash("sha256")
1073
- .update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
1263
+ .update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
1074
1264
  .digest("hex")
1075
1265
  .slice(0, 16);
1076
1266
  }
1077
1267
  /** Derive a key for conversation state. Model-independent so context survives model switches. */
1078
- function deriveConversationKey(messages) {
1079
- const firstUserMsg = messages.find((m) => m.role === "user");
1080
- const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1268
+ function deriveConversationKey(messages, sessionId, agentKey) {
1269
+ if (sessionId) {
1270
+ const normalizedAgent = normalizeAgentKey(agentKey);
1271
+ return createHash("sha256")
1272
+ .update(`session:${sessionId}:${normalizedAgent}`)
1273
+ .digest("hex")
1274
+ .slice(0, 16);
1275
+ }
1081
1276
  return createHash("sha256")
1082
- .update(`conv:${firstUserText.slice(0, 200)}`)
1277
+ .update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
1083
1278
  .digest("hex")
1084
1279
  .slice(0, 16);
1085
1280
  }
1086
- /** Deterministic UUID derived from convKey so Cursor's server-side conversation
1087
- * persists across proxy restarts. Formats 16 bytes of SHA-256 as a v4-shaped UUID. */
1088
- function deterministicConversationId(convKey) {
1089
- const hex = createHash("sha256")
1090
- .update(`cursor-conv-id:${convKey}`)
1091
- .digest("hex")
1092
- .slice(0, 32);
1093
- // Format as UUID: xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx
1094
- return [
1095
- hex.slice(0, 8),
1096
- hex.slice(8, 12),
1097
- `4${hex.slice(13, 16)}`,
1098
- `${(0x8 | (parseInt(hex[16], 16) & 0x3)).toString(16)}${hex.slice(17, 20)}`,
1099
- hex.slice(20, 32),
1100
- ].join("-");
1281
+ function buildConversationFingerprint(messages) {
1282
+ return messages.map((message) => {
1283
+ const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
1284
+ return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
1285
+ }).join("\n---\n");
1286
+ }
1287
+ function updateStoredConversationAfterCompletion(convKey, metadata, assistantText) {
1288
+ const stored = conversationStates.get(convKey);
1289
+ if (!stored)
1290
+ return;
1291
+ const nextTurns = metadata.userText
1292
+ ? [...metadata.turns, { userText: metadata.userText, assistantText: assistantText.trim() }]
1293
+ : metadata.turns;
1294
+ stored.systemPromptHash = metadata.systemPromptHash;
1295
+ stored.completedTurnsFingerprint = buildCompletedTurnsFingerprint(metadata.systemPrompt, nextTurns);
1296
+ stored.lastAccessMs = Date.now();
1101
1297
  }
1102
1298
  /** Create an SSE streaming Response that reads from a live bridge. */
1103
- function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
1299
+ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
1104
1300
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1105
1301
  const created = Math.floor(Date.now() / 1000);
1106
1302
  const stream = new ReadableStream({
@@ -1148,7 +1344,9 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1148
1344
  totalTokens: 0,
1149
1345
  };
1150
1346
  const tagFilter = createThinkingTagFilter();
1347
+ let assistantText = metadata.assistantSeedText ?? "";
1151
1348
  let mcpExecReceived = false;
1349
+ let endStreamError = null;
1152
1350
  const processChunk = createConnectFrameParser((messageBytes) => {
1153
1351
  try {
1154
1352
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
@@ -1160,8 +1358,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1160
1358
  const { content, reasoning } = tagFilter.process(text);
1161
1359
  if (reasoning)
1162
1360
  sendSSE(makeChunk({ reasoning_content: reasoning }));
1163
- if (content)
1361
+ if (content) {
1362
+ assistantText += content;
1164
1363
  sendSSE(makeChunk({ content }));
1364
+ }
1165
1365
  }
1166
1366
  },
1167
1367
  // onMcpExec — the model wants to execute a tool.
@@ -1171,8 +1371,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1171
1371
  const flushed = tagFilter.flush();
1172
1372
  if (flushed.reasoning)
1173
1373
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1174
- if (flushed.content)
1374
+ if (flushed.content) {
1375
+ assistantText += flushed.content;
1175
1376
  sendSSE(makeChunk({ content: flushed.content }));
1377
+ }
1378
+ const assistantSeedText = [
1379
+ assistantText.trim(),
1380
+ formatToolCallSummary({
1381
+ id: exec.toolCallId,
1382
+ type: "function",
1383
+ function: {
1384
+ name: exec.toolName,
1385
+ arguments: exec.decodedArgs,
1386
+ },
1387
+ }),
1388
+ ].filter(Boolean).join("\n\n");
1176
1389
  const toolCallIndex = state.toolCallIndex++;
1177
1390
  sendSSE(makeChunk({
1178
1391
  tool_calls: [{
@@ -1192,6 +1405,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1192
1405
  blobStore,
1193
1406
  mcpTools,
1194
1407
  pendingExecs: state.pendingExecs,
1408
+ modelId,
1409
+ metadata: {
1410
+ ...metadata,
1411
+ assistantSeedText,
1412
+ },
1195
1413
  });
1196
1414
  sendSSE(makeChunk({}, "tool_calls"));
1197
1415
  sendDone();
@@ -1208,10 +1426,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1208
1426
  // Skip unparseable messages
1209
1427
  }
1210
1428
  }, (endStreamBytes) => {
1211
- const endError = parseConnectEndStream(endStreamBytes);
1212
- if (endError) {
1213
- sendSSE(makeChunk({ content: `\n[Error: ${endError.message}]` }));
1429
+ endStreamError = parseConnectEndStream(endStreamBytes);
1430
+ if (endStreamError) {
1431
+ logPluginError("Cursor stream returned Connect end-stream error", {
1432
+ modelId,
1433
+ bridgeKey,
1434
+ convKey,
1435
+ ...errorDetails(endStreamError),
1436
+ });
1214
1437
  }
1438
+ scheduleBridgeEnd(bridge);
1215
1439
  });
1216
1440
  bridge.onData(processChunk);
1217
1441
  bridge.onClose((code) => {
@@ -1222,27 +1446,39 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1222
1446
  stored.blobStore.set(k, v);
1223
1447
  stored.lastAccessMs = Date.now();
1224
1448
  }
1449
+ if (endStreamError) {
1450
+ activeBridges.delete(bridgeKey);
1451
+ if (!closed) {
1452
+ closed = true;
1453
+ controller.error(endStreamError);
1454
+ }
1455
+ return;
1456
+ }
1225
1457
  if (!mcpExecReceived) {
1226
1458
  const flushed = tagFilter.flush();
1227
1459
  if (flushed.reasoning)
1228
1460
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1229
- if (flushed.content)
1461
+ if (flushed.content) {
1462
+ assistantText += flushed.content;
1230
1463
  sendSSE(makeChunk({ content: flushed.content }));
1464
+ }
1465
+ updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
1231
1466
  sendSSE(makeChunk({}, "stop"));
1232
1467
  sendSSE(makeUsageChunk());
1233
1468
  sendDone();
1234
1469
  closeController();
1235
1470
  }
1236
- else if (code !== 0) {
1237
- // Bridge died while tool calls are pending (timeout, crash, etc.).
1238
- // Close the SSE stream so the client doesn't hang forever.
1239
- sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
1240
- sendSSE(makeChunk({}, "stop"));
1241
- sendSSE(makeUsageChunk());
1242
- sendDone();
1243
- closeController();
1244
- // Remove stale entry so the next request doesn't try to resume it.
1471
+ else {
1245
1472
  activeBridges.delete(bridgeKey);
1473
+ if (code !== 0 && !closed) {
1474
+ // Bridge died while tool calls are pending (timeout, crash, etc.).
1475
+ // Close the SSE stream so the client doesn't hang forever.
1476
+ sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
1477
+ sendSSE(makeChunk({}, "stop"));
1478
+ sendSSE(makeUsageChunk());
1479
+ sendDone();
1480
+ closeController();
1481
+ }
1246
1482
  }
1247
1483
  });
1248
1484
  },
@@ -1260,13 +1496,20 @@ async function startBridge(accessToken, requestBytes) {
1260
1496
  const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
1261
1497
  return { bridge, heartbeatTimer };
1262
1498
  }
1263
- async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
1499
+ async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
1264
1500
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1265
- return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
1501
+ return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
1266
1502
  }
1267
1503
  /** Resume a paused bridge by sending MCP results and continuing to stream. */
1268
- function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey) {
1269
- const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs } = active;
1504
+ function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
1505
+ const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata } = active;
1506
+ const resumeMetadata = {
1507
+ ...metadata,
1508
+ assistantSeedText: [
1509
+ metadata.assistantSeedText?.trim() ?? "",
1510
+ toolResults.map(formatToolResultSummary).join("\n\n"),
1511
+ ].filter(Boolean).join("\n\n"),
1512
+ };
1270
1513
  // Send mcpResult for each pending exec that has a matching tool result
1271
1514
  for (const exec of pendingExecs) {
1272
1515
  const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
@@ -1306,12 +1549,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
1306
1549
  });
1307
1550
  bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
1308
1551
  }
1309
- return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
1552
+ return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
1310
1553
  }
1311
- async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
1554
+ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
1312
1555
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1313
1556
  const created = Math.floor(Date.now() / 1000);
1314
- const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
1557
+ const { text, usage, finishReason, toolCalls } = await collectFullResponse(payload, accessToken, modelId, convKey, metadata);
1558
+ const message = finishReason === "tool_calls"
1559
+ ? { role: "assistant", content: null, tool_calls: toolCalls }
1560
+ : { role: "assistant", content: text };
1315
1561
  return new Response(JSON.stringify({
1316
1562
  id: completionId,
1317
1563
  object: "chat.completion",
@@ -1320,16 +1566,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
1320
1566
  choices: [
1321
1567
  {
1322
1568
  index: 0,
1323
- message: { role: "assistant", content: text },
1324
- finish_reason: "stop",
1569
+ message,
1570
+ finish_reason: finishReason,
1325
1571
  },
1326
1572
  ],
1327
1573
  usage,
1328
1574
  }), { headers: { "Content-Type": "application/json" } });
1329
1575
  }
1330
- async function collectFullResponse(payload, accessToken, convKey) {
1331
- const { promise, resolve } = Promise.withResolvers();
1576
+ async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
1577
+ const { promise, resolve, reject } = Promise.withResolvers();
1332
1578
  let fullText = "";
1579
+ let endStreamError = null;
1580
+ const pendingToolCalls = [];
1333
1581
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1334
1582
  const state = {
1335
1583
  toolCallIndex: 0,
@@ -1346,7 +1594,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
1346
1594
  return;
1347
1595
  const { content } = tagFilter.process(text);
1348
1596
  fullText += content;
1349
- }, () => { }, (checkpointBytes) => {
1597
+ }, (exec) => {
1598
+ pendingToolCalls.push({
1599
+ id: exec.toolCallId,
1600
+ type: "function",
1601
+ function: {
1602
+ name: exec.toolName,
1603
+ arguments: exec.decodedArgs,
1604
+ },
1605
+ });
1606
+ scheduleBridgeEnd(bridge);
1607
+ }, (checkpointBytes) => {
1350
1608
  const stored = conversationStates.get(convKey);
1351
1609
  if (stored) {
1352
1610
  stored.checkpoint = checkpointBytes;
@@ -1357,7 +1615,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
1357
1615
  catch {
1358
1616
  // Skip
1359
1617
  }
1360
- }, () => { }));
1618
+ }, (endStreamBytes) => {
1619
+ endStreamError = parseConnectEndStream(endStreamBytes);
1620
+ if (endStreamError) {
1621
+ logPluginError("Cursor non-streaming response returned Connect end-stream error", {
1622
+ modelId,
1623
+ convKey,
1624
+ ...errorDetails(endStreamError),
1625
+ });
1626
+ }
1627
+ scheduleBridgeEnd(bridge);
1628
+ }));
1361
1629
  bridge.onClose(() => {
1362
1630
  clearInterval(heartbeatTimer);
1363
1631
  const stored = conversationStates.get(convKey);
@@ -1368,10 +1636,19 @@ async function collectFullResponse(payload, accessToken, convKey) {
1368
1636
  }
1369
1637
  const flushed = tagFilter.flush();
1370
1638
  fullText += flushed.content;
1639
+ if (endStreamError) {
1640
+ reject(endStreamError);
1641
+ return;
1642
+ }
1643
+ if (pendingToolCalls.length === 0) {
1644
+ updateStoredConversationAfterCompletion(convKey, metadata, fullText);
1645
+ }
1371
1646
  const usage = computeUsage(state);
1372
1647
  resolve({
1373
1648
  text: fullText,
1374
1649
  usage,
1650
+ finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
1651
+ toolCalls: pendingToolCalls,
1375
1652
  });
1376
1653
  });
1377
1654
  return promise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playwo/opencode-cursor-oauth",
3
- "version": "0.0.0-dev.f7099c3761b9",
3
+ "version": "0.0.0-dev.fc97acd8b777",
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",