@playwo/opencode-cursor-oauth 0.0.0-dev.1b946f85e9b0 → 0.0.0-dev.9b39a4eb497b

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);
@@ -471,7 +496,11 @@ export async function startProxy(getAccessToken, models = []) {
471
496
  throw new Error("Cursor proxy access token provider not configured");
472
497
  }
473
498
  const accessToken = await proxyAccessTokenProvider();
474
- 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 });
475
504
  }
476
505
  catch (err) {
477
506
  const message = err instanceof Error ? err.message : String(err);
@@ -509,10 +538,15 @@ export function stopProxy() {
509
538
  activeBridges.clear();
510
539
  conversationStates.clear();
511
540
  }
512
- function handleChatCompletion(body, accessToken) {
513
- 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;
514
544
  const modelId = body.model;
515
- const tools = body.tools ?? [];
545
+ const normalizedAgentKey = normalizeAgentKey(context.agentKey);
546
+ const isTitleAgent = normalizedAgentKey === "title";
547
+ const tools = isTitleAgent
548
+ ? []
549
+ : selectToolsForChoice(body.tools ?? [], body.tool_choice);
516
550
  if (!userText && toolResults.length === 0) {
517
551
  return new Response(JSON.stringify({
518
552
  error: {
@@ -521,16 +555,24 @@ function handleChatCompletion(body, accessToken) {
521
555
  },
522
556
  }), { status: 400, headers: { "Content-Type": "application/json" } });
523
557
  }
524
- // bridgeKey: model-specific, for active tool-call bridges
558
+ // bridgeKey: session/agent-scoped, for active tool-call bridges
525
559
  // convKey: model-independent, for conversation state that survives model switches
526
- const bridgeKey = deriveBridgeKey(modelId, body.messages);
527
- const convKey = deriveConversationKey(body.messages);
560
+ const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId, context.agentKey);
561
+ const convKey = deriveConversationKey(body.messages, context.sessionId, context.agentKey);
528
562
  const activeBridge = activeBridges.get(bridgeKey);
529
563
  if (activeBridge && toolResults.length > 0) {
530
564
  activeBridges.delete(bridgeKey);
531
565
  if (activeBridge.bridge.alive) {
566
+ if (activeBridge.modelId !== modelId) {
567
+ logPluginWarn("Resuming pending Cursor tool call on original model after model switch", {
568
+ requestedModelId: modelId,
569
+ resumedModelId: activeBridge.modelId,
570
+ convKey,
571
+ bridgeKey,
572
+ });
573
+ }
532
574
  // Resume the live bridge with tool results
533
- return handleToolResultResume(activeBridge, toolResults, modelId, bridgeKey, convKey);
575
+ return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
534
576
  }
535
577
  // Bridge died (timeout, server disconnect, etc.).
536
578
  // Clean up and fall through to start a fresh bridge.
@@ -545,28 +587,52 @@ function handleChatCompletion(body, accessToken) {
545
587
  }
546
588
  let stored = conversationStates.get(convKey);
547
589
  if (!stored) {
548
- stored = {
549
- conversationId: deterministicConversationId(convKey),
550
- checkpoint: null,
551
- blobStore: new Map(),
552
- lastAccessMs: Date.now(),
553
- };
590
+ stored = createStoredConversation();
554
591
  conversationStates.set(convKey, stored);
555
592
  }
593
+ const systemPromptHash = hashString(systemPrompt);
594
+ if (stored.checkpoint
595
+ && (stored.systemPromptHash !== systemPromptHash
596
+ || (turns.length > 0 && stored.completedTurnsFingerprint !== completedTurnsFingerprint))) {
597
+ resetStoredConversation(stored);
598
+ }
599
+ stored.systemPromptHash = systemPromptHash;
600
+ stored.completedTurnsFingerprint = completedTurnsFingerprint;
556
601
  stored.lastAccessMs = Date.now();
557
602
  evictStaleConversations();
558
603
  // Build the request. When tool results are present but the bridge died,
559
604
  // we must still include the last user text so Cursor has context.
560
605
  const mcpTools = buildMcpToolDefinitions(tools);
561
- const effectiveUserText = userText || (toolResults.length > 0
562
- ? toolResults.map((r) => r.content).join("\n")
563
- : "");
564
- const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, turns, stored.conversationId, stored.checkpoint, stored.blobStore);
606
+ const needsInitialHandoff = !stored.checkpoint && (turns.length > 0 || pendingAssistantSummary || toolResults.length > 0);
607
+ const replayTurns = needsInitialHandoff ? [] : turns;
608
+ let effectiveUserText = needsInitialHandoff
609
+ ? buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults)
610
+ : toolResults.length > 0
611
+ ? buildToolResumePrompt(userText, pendingAssistantSummary, toolResults)
612
+ : userText;
613
+ if (isTitleAgent) {
614
+ effectiveUserText = buildTitleUserPrompt(systemPrompt, effectiveUserText);
615
+ }
616
+ const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, replayTurns, stored.conversationId, stored.checkpoint, stored.blobStore);
565
617
  payload.mcpTools = mcpTools;
566
618
  if (body.stream === false) {
567
- return handleNonStreamingResponse(payload, accessToken, modelId, convKey);
619
+ return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
620
+ systemPrompt,
621
+ systemPromptHash,
622
+ completedTurnsFingerprint,
623
+ turns,
624
+ userText,
625
+ agentKey: normalizedAgentKey,
626
+ });
568
627
  }
569
- return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey);
628
+ return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, {
629
+ systemPrompt,
630
+ systemPromptHash,
631
+ completedTurnsFingerprint,
632
+ turns,
633
+ userText,
634
+ agentKey: normalizedAgentKey,
635
+ });
570
636
  }
571
637
  /** Normalize OpenAI message content to a plain string. */
572
638
  function textContent(content) {
@@ -581,8 +647,6 @@ function textContent(content) {
581
647
  }
582
648
  function parseMessages(messages) {
583
649
  let systemPrompt = "You are a helpful assistant.";
584
- const pairs = [];
585
- const toolResults = [];
586
650
  // Collect system messages
587
651
  const systemParts = messages
588
652
  .filter((m) => m.role === "system")
@@ -590,40 +654,184 @@ function parseMessages(messages) {
590
654
  if (systemParts.length > 0) {
591
655
  systemPrompt = systemParts.join("\n");
592
656
  }
593
- // Separate tool results from conversation turns
594
657
  const nonSystem = messages.filter((m) => m.role !== "system");
595
- let pendingUser = "";
658
+ const parsedTurns = [];
659
+ let currentTurn;
596
660
  for (const msg of nonSystem) {
597
- if (msg.role === "tool") {
598
- toolResults.push({
599
- toolCallId: msg.tool_call_id ?? "",
600
- content: textContent(msg.content),
601
- });
661
+ if (msg.role === "user") {
662
+ if (currentTurn)
663
+ parsedTurns.push(currentTurn);
664
+ currentTurn = {
665
+ userText: textContent(msg.content),
666
+ segments: [],
667
+ };
668
+ continue;
602
669
  }
603
- else if (msg.role === "user") {
604
- if (pendingUser) {
605
- pairs.push({ userText: pendingUser, assistantText: "" });
606
- }
607
- pendingUser = textContent(msg.content);
670
+ if (!currentTurn) {
671
+ currentTurn = { userText: "", segments: [] };
608
672
  }
609
- else if (msg.role === "assistant") {
610
- // Skip assistant messages that are just tool_calls with no text
673
+ if (msg.role === "assistant") {
611
674
  const text = textContent(msg.content);
612
- if (pendingUser) {
613
- pairs.push({ userText: pendingUser, assistantText: text });
614
- pendingUser = "";
675
+ if (text) {
676
+ currentTurn.segments.push({ kind: "assistantText", text });
615
677
  }
678
+ if (msg.tool_calls?.length) {
679
+ currentTurn.segments.push({
680
+ kind: "assistantToolCalls",
681
+ toolCalls: msg.tool_calls,
682
+ });
683
+ }
684
+ continue;
616
685
  }
686
+ if (msg.role === "tool") {
687
+ currentTurn.segments.push({
688
+ kind: "toolResult",
689
+ result: {
690
+ toolCallId: msg.tool_call_id ?? "",
691
+ content: textContent(msg.content),
692
+ },
693
+ });
694
+ }
695
+ }
696
+ if (currentTurn)
697
+ parsedTurns.push(currentTurn);
698
+ let userText = "";
699
+ let toolResults = [];
700
+ let pendingAssistantSummary = "";
701
+ let completedTurnStates = parsedTurns;
702
+ const lastTurn = parsedTurns.at(-1);
703
+ if (lastTurn) {
704
+ const trailingSegments = splitTrailingToolResults(lastTurn.segments);
705
+ const hasAssistantSummary = trailingSegments.base.length > 0;
706
+ if (trailingSegments.trailing.length > 0 && hasAssistantSummary) {
707
+ completedTurnStates = parsedTurns.slice(0, -1);
708
+ userText = lastTurn.userText;
709
+ toolResults = trailingSegments.trailing.map((segment) => segment.result);
710
+ pendingAssistantSummary = summarizeTurnSegments(trailingSegments.base);
711
+ }
712
+ else if (lastTurn.userText && lastTurn.segments.length === 0) {
713
+ completedTurnStates = parsedTurns.slice(0, -1);
714
+ userText = lastTurn.userText;
715
+ }
716
+ }
717
+ const turns = completedTurnStates
718
+ .map((turn) => ({
719
+ userText: turn.userText,
720
+ assistantText: summarizeTurnSegments(turn.segments),
721
+ }))
722
+ .filter((turn) => turn.userText || turn.assistantText);
723
+ return {
724
+ systemPrompt,
725
+ userText,
726
+ turns,
727
+ toolResults,
728
+ pendingAssistantSummary,
729
+ completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
730
+ };
731
+ }
732
+ function splitTrailingToolResults(segments) {
733
+ let index = segments.length;
734
+ while (index > 0 && segments[index - 1]?.kind === "toolResult") {
735
+ index -= 1;
736
+ }
737
+ return {
738
+ base: segments.slice(0, index),
739
+ trailing: segments.slice(index).filter((segment) => segment.kind === "toolResult"),
740
+ };
741
+ }
742
+ function summarizeTurnSegments(segments) {
743
+ const parts = [];
744
+ for (const segment of segments) {
745
+ if (segment.kind === "assistantText") {
746
+ const trimmed = segment.text.trim();
747
+ if (trimmed)
748
+ parts.push(trimmed);
749
+ continue;
750
+ }
751
+ if (segment.kind === "assistantToolCalls") {
752
+ const summary = segment.toolCalls.map(formatToolCallSummary).join("\n\n");
753
+ if (summary)
754
+ parts.push(summary);
755
+ continue;
756
+ }
757
+ parts.push(formatToolResultSummary(segment.result));
617
758
  }
618
- let lastUserText = "";
619
- if (pendingUser) {
620
- lastUserText = pendingUser;
759
+ return parts.join("\n\n").trim();
760
+ }
761
+ function formatToolCallSummary(call) {
762
+ const args = call.function.arguments?.trim();
763
+ return args
764
+ ? `[assistant requested tool ${call.function.name} id=${call.id}]\n${args}`
765
+ : `[assistant requested tool ${call.function.name} id=${call.id}]`;
766
+ }
767
+ function formatToolResultSummary(result) {
768
+ const label = result.toolCallId
769
+ ? `[tool result id=${result.toolCallId}]`
770
+ : "[tool result]";
771
+ const content = result.content.trim();
772
+ return content ? `${label}\n${content}` : label;
773
+ }
774
+ function buildCompletedTurnsFingerprint(systemPrompt, turns) {
775
+ return hashString(JSON.stringify({ systemPrompt, turns }));
776
+ }
777
+ function buildToolResumePrompt(userText, pendingAssistantSummary, toolResults) {
778
+ const parts = [userText.trim()];
779
+ if (pendingAssistantSummary.trim()) {
780
+ parts.push(`[previous assistant tool activity]\n${pendingAssistantSummary.trim()}`);
621
781
  }
622
- else if (pairs.length > 0 && toolResults.length === 0) {
623
- const last = pairs.pop();
624
- lastUserText = last.userText;
782
+ if (toolResults.length > 0) {
783
+ parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
625
784
  }
626
- return { systemPrompt, userText: lastUserText, turns: pairs, toolResults };
785
+ return parts.filter(Boolean).join("\n\n");
786
+ }
787
+ function buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults) {
788
+ const transcript = turns.map((turn, index) => {
789
+ const sections = [`Turn ${index + 1}`];
790
+ if (turn.userText.trim())
791
+ sections.push(`User: ${turn.userText.trim()}`);
792
+ if (turn.assistantText.trim())
793
+ sections.push(`Assistant: ${turn.assistantText.trim()}`);
794
+ return sections.join("\n");
795
+ });
796
+ const inProgress = buildToolResumePrompt("", pendingAssistantSummary, toolResults).trim();
797
+ const history = [
798
+ ...transcript,
799
+ ...(inProgress ? [`In-progress turn\n${inProgress}`] : []),
800
+ ].join("\n\n").trim();
801
+ if (!history)
802
+ return userText;
803
+ return [
804
+ "[OpenCode session handoff]",
805
+ "You are continuing an existing session that previously ran on another provider/model.",
806
+ "Treat the transcript below as prior conversation history before answering the latest user message.",
807
+ "",
808
+ "<previous-session-transcript>",
809
+ history,
810
+ "</previous-session-transcript>",
811
+ "",
812
+ "Latest user message:",
813
+ userText.trim(),
814
+ ].filter(Boolean).join("\n");
815
+ }
816
+ function buildTitleUserPrompt(systemPrompt, content) {
817
+ return [systemPrompt.trim(), content.trim()].filter(Boolean).join("\n\n");
818
+ }
819
+ function selectToolsForChoice(tools, toolChoice) {
820
+ if (!tools.length)
821
+ return [];
822
+ if (toolChoice === undefined || toolChoice === null || toolChoice === "auto" || toolChoice === "required") {
823
+ return tools;
824
+ }
825
+ if (toolChoice === "none") {
826
+ return [];
827
+ }
828
+ if (typeof toolChoice === "object") {
829
+ const choice = toolChoice;
830
+ if (choice.type === "function" && typeof choice.function?.name === "string") {
831
+ return tools.filter((tool) => tool.function.name === choice.function.name);
832
+ }
833
+ }
834
+ return tools;
627
835
  }
628
836
  /** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
629
837
  function buildMcpToolDefinitions(tools) {
@@ -766,6 +974,12 @@ function makeHeartbeatBytes() {
766
974
  });
767
975
  return toBinary(AgentClientMessageSchema, heartbeat);
768
976
  }
977
+ function scheduleBridgeEnd(bridge) {
978
+ queueMicrotask(() => {
979
+ if (bridge.alive)
980
+ bridge.end();
981
+ });
982
+ }
769
983
  /**
770
984
  * Create a stateful parser for Connect protocol frames.
771
985
  * Handles buffering partial data across chunks.
@@ -908,6 +1122,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
908
1122
  const blobId = kvMsg.message.value.blobId;
909
1123
  const blobIdKey = Buffer.from(blobId).toString("hex");
910
1124
  const blobData = blobStore.get(blobIdKey);
1125
+ if (!blobData) {
1126
+ logPluginWarn("Cursor requested missing blob", {
1127
+ blobId: blobIdKey,
1128
+ knownBlobCount: blobStore.size,
1129
+ });
1130
+ }
911
1131
  sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
912
1132
  }
913
1133
  else if (kvCase === "setBlobArgs") {
@@ -1072,42 +1292,56 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
1072
1292
  });
1073
1293
  sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
1074
1294
  }
1075
- /** Derive a key for active bridge lookup (tool-call continuations). Model-specific. */
1076
- function deriveBridgeKey(modelId, messages) {
1295
+ /** Derive a key for active bridge lookup (tool-call continuations). */
1296
+ function deriveBridgeKey(modelId, messages, sessionId, agentKey) {
1297
+ if (sessionId) {
1298
+ const normalizedAgent = normalizeAgentKey(agentKey);
1299
+ return createHash("sha256")
1300
+ .update(`bridge:${sessionId}:${normalizedAgent}`)
1301
+ .digest("hex")
1302
+ .slice(0, 16);
1303
+ }
1077
1304
  const firstUserMsg = messages.find((m) => m.role === "user");
1078
1305
  const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1306
+ const normalizedAgent = normalizeAgentKey(agentKey);
1079
1307
  return createHash("sha256")
1080
- .update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
1308
+ .update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
1081
1309
  .digest("hex")
1082
1310
  .slice(0, 16);
1083
1311
  }
1084
1312
  /** Derive a key for conversation state. Model-independent so context survives model switches. */
1085
- function deriveConversationKey(messages) {
1086
- const firstUserMsg = messages.find((m) => m.role === "user");
1087
- const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1313
+ function deriveConversationKey(messages, sessionId, agentKey) {
1314
+ if (sessionId) {
1315
+ const normalizedAgent = normalizeAgentKey(agentKey);
1316
+ return createHash("sha256")
1317
+ .update(`session:${sessionId}:${normalizedAgent}`)
1318
+ .digest("hex")
1319
+ .slice(0, 16);
1320
+ }
1088
1321
  return createHash("sha256")
1089
- .update(`conv:${firstUserText.slice(0, 200)}`)
1322
+ .update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
1090
1323
  .digest("hex")
1091
1324
  .slice(0, 16);
1092
1325
  }
1093
- /** Deterministic UUID derived from convKey so Cursor's server-side conversation
1094
- * persists across proxy restarts. Formats 16 bytes of SHA-256 as a v4-shaped UUID. */
1095
- function deterministicConversationId(convKey) {
1096
- const hex = createHash("sha256")
1097
- .update(`cursor-conv-id:${convKey}`)
1098
- .digest("hex")
1099
- .slice(0, 32);
1100
- // Format as UUID: xxxxxxxx-xxxx-4xxx-Nxxx-xxxxxxxxxxxx
1101
- return [
1102
- hex.slice(0, 8),
1103
- hex.slice(8, 12),
1104
- `4${hex.slice(13, 16)}`,
1105
- `${(0x8 | (parseInt(hex[16], 16) & 0x3)).toString(16)}${hex.slice(17, 20)}`,
1106
- hex.slice(20, 32),
1107
- ].join("-");
1326
+ function buildConversationFingerprint(messages) {
1327
+ return messages.map((message) => {
1328
+ const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
1329
+ return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
1330
+ }).join("\n---\n");
1331
+ }
1332
+ function updateStoredConversationAfterCompletion(convKey, metadata, assistantText) {
1333
+ const stored = conversationStates.get(convKey);
1334
+ if (!stored)
1335
+ return;
1336
+ const nextTurns = metadata.userText
1337
+ ? [...metadata.turns, { userText: metadata.userText, assistantText: assistantText.trim() }]
1338
+ : metadata.turns;
1339
+ stored.systemPromptHash = metadata.systemPromptHash;
1340
+ stored.completedTurnsFingerprint = buildCompletedTurnsFingerprint(metadata.systemPrompt, nextTurns);
1341
+ stored.lastAccessMs = Date.now();
1108
1342
  }
1109
1343
  /** Create an SSE streaming Response that reads from a live bridge. */
1110
- function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
1344
+ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
1111
1345
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1112
1346
  const created = Math.floor(Date.now() / 1000);
1113
1347
  const stream = new ReadableStream({
@@ -1155,7 +1389,9 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1155
1389
  totalTokens: 0,
1156
1390
  };
1157
1391
  const tagFilter = createThinkingTagFilter();
1392
+ let assistantText = metadata.assistantSeedText ?? "";
1158
1393
  let mcpExecReceived = false;
1394
+ let endStreamError = null;
1159
1395
  const processChunk = createConnectFrameParser((messageBytes) => {
1160
1396
  try {
1161
1397
  const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
@@ -1167,8 +1403,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1167
1403
  const { content, reasoning } = tagFilter.process(text);
1168
1404
  if (reasoning)
1169
1405
  sendSSE(makeChunk({ reasoning_content: reasoning }));
1170
- if (content)
1406
+ if (content) {
1407
+ assistantText += content;
1171
1408
  sendSSE(makeChunk({ content }));
1409
+ }
1172
1410
  }
1173
1411
  },
1174
1412
  // onMcpExec — the model wants to execute a tool.
@@ -1178,8 +1416,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1178
1416
  const flushed = tagFilter.flush();
1179
1417
  if (flushed.reasoning)
1180
1418
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1181
- if (flushed.content)
1419
+ if (flushed.content) {
1420
+ assistantText += flushed.content;
1182
1421
  sendSSE(makeChunk({ content: flushed.content }));
1422
+ }
1423
+ const assistantSeedText = [
1424
+ assistantText.trim(),
1425
+ formatToolCallSummary({
1426
+ id: exec.toolCallId,
1427
+ type: "function",
1428
+ function: {
1429
+ name: exec.toolName,
1430
+ arguments: exec.decodedArgs,
1431
+ },
1432
+ }),
1433
+ ].filter(Boolean).join("\n\n");
1183
1434
  const toolCallIndex = state.toolCallIndex++;
1184
1435
  sendSSE(makeChunk({
1185
1436
  tool_calls: [{
@@ -1199,6 +1450,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1199
1450
  blobStore,
1200
1451
  mcpTools,
1201
1452
  pendingExecs: state.pendingExecs,
1453
+ modelId,
1454
+ metadata: {
1455
+ ...metadata,
1456
+ assistantSeedText,
1457
+ },
1202
1458
  });
1203
1459
  sendSSE(makeChunk({}, "tool_calls"));
1204
1460
  sendDone();
@@ -1215,10 +1471,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1215
1471
  // Skip unparseable messages
1216
1472
  }
1217
1473
  }, (endStreamBytes) => {
1218
- const endError = parseConnectEndStream(endStreamBytes);
1219
- if (endError) {
1220
- sendSSE(makeChunk({ content: `\n[Error: ${endError.message}]` }));
1474
+ endStreamError = parseConnectEndStream(endStreamBytes);
1475
+ if (endStreamError) {
1476
+ logPluginError("Cursor stream returned Connect end-stream error", {
1477
+ modelId,
1478
+ bridgeKey,
1479
+ convKey,
1480
+ ...errorDetails(endStreamError),
1481
+ });
1221
1482
  }
1483
+ scheduleBridgeEnd(bridge);
1222
1484
  });
1223
1485
  bridge.onData(processChunk);
1224
1486
  bridge.onClose((code) => {
@@ -1229,27 +1491,39 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
1229
1491
  stored.blobStore.set(k, v);
1230
1492
  stored.lastAccessMs = Date.now();
1231
1493
  }
1494
+ if (endStreamError) {
1495
+ activeBridges.delete(bridgeKey);
1496
+ if (!closed) {
1497
+ closed = true;
1498
+ controller.error(endStreamError);
1499
+ }
1500
+ return;
1501
+ }
1232
1502
  if (!mcpExecReceived) {
1233
1503
  const flushed = tagFilter.flush();
1234
1504
  if (flushed.reasoning)
1235
1505
  sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
1236
- if (flushed.content)
1506
+ if (flushed.content) {
1507
+ assistantText += flushed.content;
1237
1508
  sendSSE(makeChunk({ content: flushed.content }));
1509
+ }
1510
+ updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
1238
1511
  sendSSE(makeChunk({}, "stop"));
1239
1512
  sendSSE(makeUsageChunk());
1240
1513
  sendDone();
1241
1514
  closeController();
1242
1515
  }
1243
- else if (code !== 0) {
1244
- // Bridge died while tool calls are pending (timeout, crash, etc.).
1245
- // Close the SSE stream so the client doesn't hang forever.
1246
- sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
1247
- sendSSE(makeChunk({}, "stop"));
1248
- sendSSE(makeUsageChunk());
1249
- sendDone();
1250
- closeController();
1251
- // Remove stale entry so the next request doesn't try to resume it.
1516
+ else {
1252
1517
  activeBridges.delete(bridgeKey);
1518
+ if (code !== 0 && !closed) {
1519
+ // Bridge died while tool calls are pending (timeout, crash, etc.).
1520
+ // Close the SSE stream so the client doesn't hang forever.
1521
+ sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
1522
+ sendSSE(makeChunk({}, "stop"));
1523
+ sendSSE(makeUsageChunk());
1524
+ sendDone();
1525
+ closeController();
1526
+ }
1253
1527
  }
1254
1528
  });
1255
1529
  },
@@ -1267,13 +1541,20 @@ async function startBridge(accessToken, requestBytes) {
1267
1541
  const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
1268
1542
  return { bridge, heartbeatTimer };
1269
1543
  }
1270
- async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
1544
+ async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
1271
1545
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1272
- return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
1546
+ return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
1273
1547
  }
1274
1548
  /** Resume a paused bridge by sending MCP results and continuing to stream. */
1275
- function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey) {
1276
- const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs } = active;
1549
+ function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
1550
+ const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata } = active;
1551
+ const resumeMetadata = {
1552
+ ...metadata,
1553
+ assistantSeedText: [
1554
+ metadata.assistantSeedText?.trim() ?? "",
1555
+ toolResults.map(formatToolResultSummary).join("\n\n"),
1556
+ ].filter(Boolean).join("\n\n"),
1557
+ };
1277
1558
  // Send mcpResult for each pending exec that has a matching tool result
1278
1559
  for (const exec of pendingExecs) {
1279
1560
  const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
@@ -1313,12 +1594,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
1313
1594
  });
1314
1595
  bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
1315
1596
  }
1316
- return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
1597
+ return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
1317
1598
  }
1318
- async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
1599
+ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
1319
1600
  const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1320
1601
  const created = Math.floor(Date.now() / 1000);
1321
- const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
1602
+ const { text, usage, finishReason, toolCalls } = await collectFullResponse(payload, accessToken, modelId, convKey, metadata);
1603
+ const message = finishReason === "tool_calls"
1604
+ ? { role: "assistant", content: null, tool_calls: toolCalls }
1605
+ : { role: "assistant", content: text };
1322
1606
  return new Response(JSON.stringify({
1323
1607
  id: completionId,
1324
1608
  object: "chat.completion",
@@ -1327,16 +1611,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
1327
1611
  choices: [
1328
1612
  {
1329
1613
  index: 0,
1330
- message: { role: "assistant", content: text },
1331
- finish_reason: "stop",
1614
+ message,
1615
+ finish_reason: finishReason,
1332
1616
  },
1333
1617
  ],
1334
1618
  usage,
1335
1619
  }), { headers: { "Content-Type": "application/json" } });
1336
1620
  }
1337
- async function collectFullResponse(payload, accessToken, convKey) {
1338
- const { promise, resolve } = Promise.withResolvers();
1621
+ async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
1622
+ const { promise, resolve, reject } = Promise.withResolvers();
1339
1623
  let fullText = "";
1624
+ let endStreamError = null;
1625
+ const pendingToolCalls = [];
1340
1626
  const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
1341
1627
  const state = {
1342
1628
  toolCallIndex: 0,
@@ -1353,7 +1639,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
1353
1639
  return;
1354
1640
  const { content } = tagFilter.process(text);
1355
1641
  fullText += content;
1356
- }, () => { }, (checkpointBytes) => {
1642
+ }, (exec) => {
1643
+ pendingToolCalls.push({
1644
+ id: exec.toolCallId,
1645
+ type: "function",
1646
+ function: {
1647
+ name: exec.toolName,
1648
+ arguments: exec.decodedArgs,
1649
+ },
1650
+ });
1651
+ scheduleBridgeEnd(bridge);
1652
+ }, (checkpointBytes) => {
1357
1653
  const stored = conversationStates.get(convKey);
1358
1654
  if (stored) {
1359
1655
  stored.checkpoint = checkpointBytes;
@@ -1364,7 +1660,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
1364
1660
  catch {
1365
1661
  // Skip
1366
1662
  }
1367
- }, () => { }));
1663
+ }, (endStreamBytes) => {
1664
+ endStreamError = parseConnectEndStream(endStreamBytes);
1665
+ if (endStreamError) {
1666
+ logPluginError("Cursor non-streaming response returned Connect end-stream error", {
1667
+ modelId,
1668
+ convKey,
1669
+ ...errorDetails(endStreamError),
1670
+ });
1671
+ }
1672
+ scheduleBridgeEnd(bridge);
1673
+ }));
1368
1674
  bridge.onClose(() => {
1369
1675
  clearInterval(heartbeatTimer);
1370
1676
  const stored = conversationStates.get(convKey);
@@ -1375,10 +1681,19 @@ async function collectFullResponse(payload, accessToken, convKey) {
1375
1681
  }
1376
1682
  const flushed = tagFilter.flush();
1377
1683
  fullText += flushed.content;
1684
+ if (endStreamError) {
1685
+ reject(endStreamError);
1686
+ return;
1687
+ }
1688
+ if (pendingToolCalls.length === 0) {
1689
+ updateStoredConversationAfterCompletion(convKey, metadata, fullText);
1690
+ }
1378
1691
  const usage = computeUsage(state);
1379
1692
  resolve({
1380
1693
  text: fullText,
1381
1694
  usage,
1695
+ finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
1696
+ toolCalls: pendingToolCalls,
1382
1697
  });
1383
1698
  });
1384
1699
  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.1b946f85e9b0",
3
+ "version": "0.0.0-dev.9b39a4eb497b",
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",