@playwo/opencode-cursor-oauth 0.1.0 → 0.2.0
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 +19 -91
- package/dist/index.js +10 -1
- package/dist/proxy.js +565 -98
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,103 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-cursor-oauth
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
5
|
+
## What it does
|
|
7
6
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
Then authenticate via the OpenCode UI (Settings → Providers → Cursor → Login).
|
|
91
23
|
|
|
92
|
-
|
|
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
|
-
-
|
|
26
|
+
- Cursor account with API access
|
|
27
|
+
- OpenCode 1.2+
|
|
98
28
|
|
|
99
|
-
##
|
|
29
|
+
## License
|
|
100
30
|
|
|
101
|
-
|
|
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
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { create, fromBinary, fromJson, toBinary, toJson } from "@bufbuild/protobuf";
|
|
16
16
|
import { ValueSchema } from "@bufbuild/protobuf/wkt";
|
|
17
|
-
import { AgentClientMessageSchema, AgentRunRequestSchema, AgentServerMessageSchema, BidiRequestIdSchema, ClientHeartbeatSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolDefinitionSchema, McpToolResultContentItemSchema, ModelDetailsSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, UserMessageSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "./proto/agent_pb";
|
|
17
|
+
import { AgentClientMessageSchema, AgentRunRequestSchema, AgentServerMessageSchema, BidiRequestIdSchema, ClientHeartbeatSchema, ConversationActionSchema, ConversationStateStructureSchema, ConversationStepSchema, AgentConversationTurnStructureSchema, ConversationTurnStructureSchema, AssistantMessageSchema, BackgroundShellSpawnResultSchema, DeleteResultSchema, DeleteRejectedSchema, DiagnosticsResultSchema, ExecClientMessageSchema, FetchErrorSchema, FetchResultSchema, GetBlobResultSchema, GrepErrorSchema, GrepResultSchema, KvClientMessageSchema, LsRejectedSchema, LsResultSchema, McpErrorSchema, McpResultSchema, McpSuccessSchema, McpTextContentSchema, McpToolDefinitionSchema, McpToolResultContentItemSchema, ModelDetailsSchema, NameAgentRequestSchema, NameAgentResponseSchema, ReadRejectedSchema, ReadResultSchema, RequestContextResultSchema, RequestContextSchema, RequestContextSuccessSchema, SetBlobResultSchema, ShellRejectedSchema, ShellResultSchema, UserMessageActionSchema, UserMessageSchema, WriteRejectedSchema, WriteResultSchema, WriteShellStdinErrorSchema, WriteShellStdinResultSchema, } from "./proto/agent_pb";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import { connect as connectHttp2 } from "node:http2";
|
|
20
20
|
import { errorDetails, logPluginError, logPluginWarn } from "./logger";
|
|
@@ -22,6 +22,7 @@ const CURSOR_API_URL = process.env.CURSOR_API_URL ?? "https://api2.cursor.sh";
|
|
|
22
22
|
const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
|
|
23
23
|
const CURSOR_CONNECT_PROTOCOL_VERSION = "1";
|
|
24
24
|
const CONNECT_END_STREAM_FLAG = 0b00000010;
|
|
25
|
+
const OPENCODE_TITLE_REQUEST_MARKER = "Generate a title for this conversation:";
|
|
25
26
|
const SSE_HEADERS = {
|
|
26
27
|
"Content-Type": "text/event-stream",
|
|
27
28
|
"Cache-Control": "no-cache",
|
|
@@ -41,6 +42,31 @@ function evictStaleConversations() {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
}
|
|
45
|
+
function normalizeAgentKey(agentKey) {
|
|
46
|
+
const trimmed = agentKey?.trim();
|
|
47
|
+
return trimmed ? trimmed : "default";
|
|
48
|
+
}
|
|
49
|
+
function hashString(value) {
|
|
50
|
+
return createHash("sha256").update(value).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
function createStoredConversation() {
|
|
53
|
+
return {
|
|
54
|
+
conversationId: crypto.randomUUID(),
|
|
55
|
+
checkpoint: null,
|
|
56
|
+
blobStore: new Map(),
|
|
57
|
+
lastAccessMs: Date.now(),
|
|
58
|
+
systemPromptHash: "",
|
|
59
|
+
completedTurnsFingerprint: "",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function resetStoredConversation(stored) {
|
|
63
|
+
stored.conversationId = crypto.randomUUID();
|
|
64
|
+
stored.checkpoint = null;
|
|
65
|
+
stored.blobStore = new Map();
|
|
66
|
+
stored.lastAccessMs = Date.now();
|
|
67
|
+
stored.systemPromptHash = "";
|
|
68
|
+
stored.completedTurnsFingerprint = "";
|
|
69
|
+
}
|
|
44
70
|
/** Connect protocol frame: [1-byte flags][4-byte BE length][payload] */
|
|
45
71
|
function frameConnectMessage(data, flags = 0) {
|
|
46
72
|
const frame = Buffer.alloc(5 + data.length);
|
|
@@ -49,6 +75,26 @@ function frameConnectMessage(data, flags = 0) {
|
|
|
49
75
|
frame.set(data, 5);
|
|
50
76
|
return frame;
|
|
51
77
|
}
|
|
78
|
+
function decodeConnectUnaryBody(payload) {
|
|
79
|
+
if (payload.length < 5)
|
|
80
|
+
return null;
|
|
81
|
+
let offset = 0;
|
|
82
|
+
while (offset + 5 <= payload.length) {
|
|
83
|
+
const flags = payload[offset];
|
|
84
|
+
const view = new DataView(payload.buffer, payload.byteOffset + offset, payload.byteLength - offset);
|
|
85
|
+
const messageLength = view.getUint32(1, false);
|
|
86
|
+
const frameEnd = offset + 5 + messageLength;
|
|
87
|
+
if (frameEnd > payload.length)
|
|
88
|
+
return null;
|
|
89
|
+
if ((flags & 0b0000_0001) !== 0)
|
|
90
|
+
return null;
|
|
91
|
+
if ((flags & CONNECT_END_STREAM_FLAG) === 0) {
|
|
92
|
+
return payload.subarray(offset + 5, frameEnd);
|
|
93
|
+
}
|
|
94
|
+
offset = frameEnd;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
52
98
|
function buildCursorHeaders(options, contentType, extra = {}) {
|
|
53
99
|
const headers = new Headers(buildCursorHeaderValues(options, contentType, extra));
|
|
54
100
|
return headers;
|
|
@@ -471,7 +517,11 @@ export async function startProxy(getAccessToken, models = []) {
|
|
|
471
517
|
throw new Error("Cursor proxy access token provider not configured");
|
|
472
518
|
}
|
|
473
519
|
const accessToken = await proxyAccessTokenProvider();
|
|
474
|
-
|
|
520
|
+
const sessionId = req.headers.get("x-opencode-session-id")
|
|
521
|
+
?? req.headers.get("x-session-id")
|
|
522
|
+
?? undefined;
|
|
523
|
+
const agentKey = req.headers.get("x-opencode-agent") ?? undefined;
|
|
524
|
+
return handleChatCompletion(body, accessToken, { sessionId, agentKey });
|
|
475
525
|
}
|
|
476
526
|
catch (err) {
|
|
477
527
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -509,10 +559,26 @@ export function stopProxy() {
|
|
|
509
559
|
activeBridges.clear();
|
|
510
560
|
conversationStates.clear();
|
|
511
561
|
}
|
|
512
|
-
function handleChatCompletion(body, accessToken) {
|
|
513
|
-
const
|
|
562
|
+
function handleChatCompletion(body, accessToken, context = {}) {
|
|
563
|
+
const parsed = parseMessages(body.messages);
|
|
564
|
+
const { systemPrompt, userText, turns, toolResults, pendingAssistantSummary, completedTurnsFingerprint, } = parsed;
|
|
514
565
|
const modelId = body.model;
|
|
515
|
-
const
|
|
566
|
+
const normalizedAgentKey = normalizeAgentKey(context.agentKey);
|
|
567
|
+
const titleDetection = detectTitleRequest(body);
|
|
568
|
+
const isTitleAgent = titleDetection.matched;
|
|
569
|
+
if (isTitleAgent) {
|
|
570
|
+
const titleSourceText = buildTitleSourceText(userText, turns, pendingAssistantSummary, toolResults);
|
|
571
|
+
if (!titleSourceText) {
|
|
572
|
+
return new Response(JSON.stringify({
|
|
573
|
+
error: {
|
|
574
|
+
message: "No title source text found",
|
|
575
|
+
type: "invalid_request_error",
|
|
576
|
+
},
|
|
577
|
+
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
578
|
+
}
|
|
579
|
+
return handleTitleGenerationRequest(titleSourceText, accessToken, modelId, body.stream !== false);
|
|
580
|
+
}
|
|
581
|
+
const tools = selectToolsForChoice(body.tools ?? [], body.tool_choice);
|
|
516
582
|
if (!userText && toolResults.length === 0) {
|
|
517
583
|
return new Response(JSON.stringify({
|
|
518
584
|
error: {
|
|
@@ -521,16 +587,24 @@ function handleChatCompletion(body, accessToken) {
|
|
|
521
587
|
},
|
|
522
588
|
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
523
589
|
}
|
|
524
|
-
// bridgeKey:
|
|
590
|
+
// bridgeKey: session/agent-scoped, for active tool-call bridges
|
|
525
591
|
// convKey: model-independent, for conversation state that survives model switches
|
|
526
|
-
const bridgeKey = deriveBridgeKey(modelId, body.messages);
|
|
527
|
-
const convKey = deriveConversationKey(body.messages);
|
|
592
|
+
const bridgeKey = deriveBridgeKey(modelId, body.messages, context.sessionId, context.agentKey);
|
|
593
|
+
const convKey = deriveConversationKey(body.messages, context.sessionId, context.agentKey);
|
|
528
594
|
const activeBridge = activeBridges.get(bridgeKey);
|
|
529
595
|
if (activeBridge && toolResults.length > 0) {
|
|
530
596
|
activeBridges.delete(bridgeKey);
|
|
531
597
|
if (activeBridge.bridge.alive) {
|
|
598
|
+
if (activeBridge.modelId !== modelId) {
|
|
599
|
+
logPluginWarn("Resuming pending Cursor tool call on original model after model switch", {
|
|
600
|
+
requestedModelId: modelId,
|
|
601
|
+
resumedModelId: activeBridge.modelId,
|
|
602
|
+
convKey,
|
|
603
|
+
bridgeKey,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
532
606
|
// Resume the live bridge with tool results
|
|
533
|
-
return handleToolResultResume(activeBridge, toolResults,
|
|
607
|
+
return handleToolResultResume(activeBridge, toolResults, bridgeKey, convKey);
|
|
534
608
|
}
|
|
535
609
|
// Bridge died (timeout, server disconnect, etc.).
|
|
536
610
|
// Clean up and fall through to start a fresh bridge.
|
|
@@ -545,28 +619,49 @@ function handleChatCompletion(body, accessToken) {
|
|
|
545
619
|
}
|
|
546
620
|
let stored = conversationStates.get(convKey);
|
|
547
621
|
if (!stored) {
|
|
548
|
-
stored =
|
|
549
|
-
conversationId: deterministicConversationId(convKey),
|
|
550
|
-
checkpoint: null,
|
|
551
|
-
blobStore: new Map(),
|
|
552
|
-
lastAccessMs: Date.now(),
|
|
553
|
-
};
|
|
622
|
+
stored = createStoredConversation();
|
|
554
623
|
conversationStates.set(convKey, stored);
|
|
555
624
|
}
|
|
625
|
+
const systemPromptHash = hashString(systemPrompt);
|
|
626
|
+
if (stored.checkpoint
|
|
627
|
+
&& (stored.systemPromptHash !== systemPromptHash
|
|
628
|
+
|| (turns.length > 0 && stored.completedTurnsFingerprint !== completedTurnsFingerprint))) {
|
|
629
|
+
resetStoredConversation(stored);
|
|
630
|
+
}
|
|
631
|
+
stored.systemPromptHash = systemPromptHash;
|
|
632
|
+
stored.completedTurnsFingerprint = completedTurnsFingerprint;
|
|
556
633
|
stored.lastAccessMs = Date.now();
|
|
557
634
|
evictStaleConversations();
|
|
558
635
|
// Build the request. When tool results are present but the bridge died,
|
|
559
636
|
// we must still include the last user text so Cursor has context.
|
|
560
637
|
const mcpTools = buildMcpToolDefinitions(tools);
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
638
|
+
const needsInitialHandoff = !stored.checkpoint && (turns.length > 0 || pendingAssistantSummary || toolResults.length > 0);
|
|
639
|
+
const replayTurns = needsInitialHandoff ? [] : turns;
|
|
640
|
+
let effectiveUserText = needsInitialHandoff
|
|
641
|
+
? buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults)
|
|
642
|
+
: toolResults.length > 0
|
|
643
|
+
? buildToolResumePrompt(userText, pendingAssistantSummary, toolResults)
|
|
644
|
+
: userText;
|
|
645
|
+
const payload = buildCursorRequest(modelId, systemPrompt, effectiveUserText, replayTurns, stored.conversationId, stored.checkpoint, stored.blobStore);
|
|
565
646
|
payload.mcpTools = mcpTools;
|
|
566
647
|
if (body.stream === false) {
|
|
567
|
-
return handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
648
|
+
return handleNonStreamingResponse(payload, accessToken, modelId, convKey, {
|
|
649
|
+
systemPrompt,
|
|
650
|
+
systemPromptHash,
|
|
651
|
+
completedTurnsFingerprint,
|
|
652
|
+
turns,
|
|
653
|
+
userText,
|
|
654
|
+
agentKey: normalizedAgentKey,
|
|
655
|
+
});
|
|
568
656
|
}
|
|
569
|
-
return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey
|
|
657
|
+
return handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, {
|
|
658
|
+
systemPrompt,
|
|
659
|
+
systemPromptHash,
|
|
660
|
+
completedTurnsFingerprint,
|
|
661
|
+
turns,
|
|
662
|
+
userText,
|
|
663
|
+
agentKey: normalizedAgentKey,
|
|
664
|
+
});
|
|
570
665
|
}
|
|
571
666
|
/** Normalize OpenAI message content to a plain string. */
|
|
572
667
|
function textContent(content) {
|
|
@@ -581,8 +676,6 @@ function textContent(content) {
|
|
|
581
676
|
}
|
|
582
677
|
function parseMessages(messages) {
|
|
583
678
|
let systemPrompt = "You are a helpful assistant.";
|
|
584
|
-
const pairs = [];
|
|
585
|
-
const toolResults = [];
|
|
586
679
|
// Collect system messages
|
|
587
680
|
const systemParts = messages
|
|
588
681
|
.filter((m) => m.role === "system")
|
|
@@ -590,40 +683,212 @@ function parseMessages(messages) {
|
|
|
590
683
|
if (systemParts.length > 0) {
|
|
591
684
|
systemPrompt = systemParts.join("\n");
|
|
592
685
|
}
|
|
593
|
-
// Separate tool results from conversation turns
|
|
594
686
|
const nonSystem = messages.filter((m) => m.role !== "system");
|
|
595
|
-
|
|
687
|
+
const parsedTurns = [];
|
|
688
|
+
let currentTurn;
|
|
596
689
|
for (const msg of nonSystem) {
|
|
597
|
-
if (msg.role === "
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
690
|
+
if (msg.role === "user") {
|
|
691
|
+
if (currentTurn)
|
|
692
|
+
parsedTurns.push(currentTurn);
|
|
693
|
+
currentTurn = {
|
|
694
|
+
userText: textContent(msg.content),
|
|
695
|
+
segments: [],
|
|
696
|
+
};
|
|
697
|
+
continue;
|
|
602
698
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
pairs.push({ userText: pendingUser, assistantText: "" });
|
|
606
|
-
}
|
|
607
|
-
pendingUser = textContent(msg.content);
|
|
699
|
+
if (!currentTurn) {
|
|
700
|
+
currentTurn = { userText: "", segments: [] };
|
|
608
701
|
}
|
|
609
|
-
|
|
610
|
-
// Skip assistant messages that are just tool_calls with no text
|
|
702
|
+
if (msg.role === "assistant") {
|
|
611
703
|
const text = textContent(msg.content);
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
704
|
+
if (text) {
|
|
705
|
+
currentTurn.segments.push({ kind: "assistantText", text });
|
|
706
|
+
}
|
|
707
|
+
if (msg.tool_calls?.length) {
|
|
708
|
+
currentTurn.segments.push({
|
|
709
|
+
kind: "assistantToolCalls",
|
|
710
|
+
toolCalls: msg.tool_calls,
|
|
711
|
+
});
|
|
615
712
|
}
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (msg.role === "tool") {
|
|
716
|
+
currentTurn.segments.push({
|
|
717
|
+
kind: "toolResult",
|
|
718
|
+
result: {
|
|
719
|
+
toolCallId: msg.tool_call_id ?? "",
|
|
720
|
+
content: textContent(msg.content),
|
|
721
|
+
},
|
|
722
|
+
});
|
|
616
723
|
}
|
|
617
724
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
725
|
+
if (currentTurn)
|
|
726
|
+
parsedTurns.push(currentTurn);
|
|
727
|
+
let userText = "";
|
|
728
|
+
let toolResults = [];
|
|
729
|
+
let pendingAssistantSummary = "";
|
|
730
|
+
let completedTurnStates = parsedTurns;
|
|
731
|
+
const lastTurn = parsedTurns.at(-1);
|
|
732
|
+
if (lastTurn) {
|
|
733
|
+
const trailingSegments = splitTrailingToolResults(lastTurn.segments);
|
|
734
|
+
const hasAssistantSummary = trailingSegments.base.length > 0;
|
|
735
|
+
if (trailingSegments.trailing.length > 0 && hasAssistantSummary) {
|
|
736
|
+
completedTurnStates = parsedTurns.slice(0, -1);
|
|
737
|
+
userText = lastTurn.userText;
|
|
738
|
+
toolResults = trailingSegments.trailing.map((segment) => segment.result);
|
|
739
|
+
pendingAssistantSummary = summarizeTurnSegments(trailingSegments.base);
|
|
740
|
+
}
|
|
741
|
+
else if (lastTurn.userText && lastTurn.segments.length === 0) {
|
|
742
|
+
completedTurnStates = parsedTurns.slice(0, -1);
|
|
743
|
+
userText = lastTurn.userText;
|
|
744
|
+
}
|
|
621
745
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
746
|
+
const turns = completedTurnStates
|
|
747
|
+
.map((turn) => ({
|
|
748
|
+
userText: turn.userText,
|
|
749
|
+
assistantText: summarizeTurnSegments(turn.segments),
|
|
750
|
+
}))
|
|
751
|
+
.filter((turn) => turn.userText || turn.assistantText);
|
|
752
|
+
return {
|
|
753
|
+
systemPrompt,
|
|
754
|
+
userText,
|
|
755
|
+
turns,
|
|
756
|
+
toolResults,
|
|
757
|
+
pendingAssistantSummary,
|
|
758
|
+
completedTurnsFingerprint: buildCompletedTurnsFingerprint(systemPrompt, turns),
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function splitTrailingToolResults(segments) {
|
|
762
|
+
let index = segments.length;
|
|
763
|
+
while (index > 0 && segments[index - 1]?.kind === "toolResult") {
|
|
764
|
+
index -= 1;
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
base: segments.slice(0, index),
|
|
768
|
+
trailing: segments.slice(index).filter((segment) => segment.kind === "toolResult"),
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
function summarizeTurnSegments(segments) {
|
|
772
|
+
const parts = [];
|
|
773
|
+
for (const segment of segments) {
|
|
774
|
+
if (segment.kind === "assistantText") {
|
|
775
|
+
const trimmed = segment.text.trim();
|
|
776
|
+
if (trimmed)
|
|
777
|
+
parts.push(trimmed);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (segment.kind === "assistantToolCalls") {
|
|
781
|
+
const summary = segment.toolCalls.map(formatToolCallSummary).join("\n\n");
|
|
782
|
+
if (summary)
|
|
783
|
+
parts.push(summary);
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
parts.push(formatToolResultSummary(segment.result));
|
|
787
|
+
}
|
|
788
|
+
return parts.join("\n\n").trim();
|
|
789
|
+
}
|
|
790
|
+
function formatToolCallSummary(call) {
|
|
791
|
+
const args = call.function.arguments?.trim();
|
|
792
|
+
return args
|
|
793
|
+
? `[assistant requested tool ${call.function.name} id=${call.id}]\n${args}`
|
|
794
|
+
: `[assistant requested tool ${call.function.name} id=${call.id}]`;
|
|
795
|
+
}
|
|
796
|
+
function formatToolResultSummary(result) {
|
|
797
|
+
const label = result.toolCallId
|
|
798
|
+
? `[tool result id=${result.toolCallId}]`
|
|
799
|
+
: "[tool result]";
|
|
800
|
+
const content = result.content.trim();
|
|
801
|
+
return content ? `${label}\n${content}` : label;
|
|
802
|
+
}
|
|
803
|
+
function buildCompletedTurnsFingerprint(systemPrompt, turns) {
|
|
804
|
+
return hashString(JSON.stringify({ systemPrompt, turns }));
|
|
805
|
+
}
|
|
806
|
+
function buildToolResumePrompt(userText, pendingAssistantSummary, toolResults) {
|
|
807
|
+
const parts = [userText.trim()];
|
|
808
|
+
if (pendingAssistantSummary.trim()) {
|
|
809
|
+
parts.push(`[previous assistant tool activity]\n${pendingAssistantSummary.trim()}`);
|
|
810
|
+
}
|
|
811
|
+
if (toolResults.length > 0) {
|
|
812
|
+
parts.push(toolResults.map(formatToolResultSummary).join("\n\n"));
|
|
813
|
+
}
|
|
814
|
+
return parts.filter(Boolean).join("\n\n");
|
|
815
|
+
}
|
|
816
|
+
function buildInitialHandoffPrompt(userText, turns, pendingAssistantSummary, toolResults) {
|
|
817
|
+
const transcript = turns.map((turn, index) => {
|
|
818
|
+
const sections = [`Turn ${index + 1}`];
|
|
819
|
+
if (turn.userText.trim())
|
|
820
|
+
sections.push(`User: ${turn.userText.trim()}`);
|
|
821
|
+
if (turn.assistantText.trim())
|
|
822
|
+
sections.push(`Assistant: ${turn.assistantText.trim()}`);
|
|
823
|
+
return sections.join("\n");
|
|
824
|
+
});
|
|
825
|
+
const inProgress = buildToolResumePrompt("", pendingAssistantSummary, toolResults).trim();
|
|
826
|
+
const history = [
|
|
827
|
+
...transcript,
|
|
828
|
+
...(inProgress ? [`In-progress turn\n${inProgress}`] : []),
|
|
829
|
+
].join("\n\n").trim();
|
|
830
|
+
if (!history)
|
|
831
|
+
return userText;
|
|
832
|
+
return [
|
|
833
|
+
"[OpenCode session handoff]",
|
|
834
|
+
"You are continuing an existing session that previously ran on another provider/model.",
|
|
835
|
+
"Treat the transcript below as prior conversation history before answering the latest user message.",
|
|
836
|
+
"",
|
|
837
|
+
"<previous-session-transcript>",
|
|
838
|
+
history,
|
|
839
|
+
"</previous-session-transcript>",
|
|
840
|
+
"",
|
|
841
|
+
"Latest user message:",
|
|
842
|
+
userText.trim(),
|
|
843
|
+
].filter(Boolean).join("\n");
|
|
844
|
+
}
|
|
845
|
+
function buildTitleSourceText(userText, turns, pendingAssistantSummary, toolResults) {
|
|
846
|
+
const history = turns
|
|
847
|
+
.map((turn) => [
|
|
848
|
+
isTitleRequestMarker(turn.userText) ? "" : turn.userText.trim(),
|
|
849
|
+
turn.assistantText.trim(),
|
|
850
|
+
].filter(Boolean).join("\n"))
|
|
851
|
+
.filter(Boolean);
|
|
852
|
+
if (pendingAssistantSummary.trim()) {
|
|
853
|
+
history.push(pendingAssistantSummary.trim());
|
|
854
|
+
}
|
|
855
|
+
if (toolResults.length > 0) {
|
|
856
|
+
history.push(toolResults.map(formatToolResultSummary).join("\n\n"));
|
|
625
857
|
}
|
|
626
|
-
|
|
858
|
+
if (userText.trim() && !isTitleRequestMarker(userText)) {
|
|
859
|
+
history.push(userText.trim());
|
|
860
|
+
}
|
|
861
|
+
return history.join("\n\n").trim();
|
|
862
|
+
}
|
|
863
|
+
function detectTitleRequest(body) {
|
|
864
|
+
if ((body.tools?.length ?? 0) > 0) {
|
|
865
|
+
return { matched: false, reason: "tools-present" };
|
|
866
|
+
}
|
|
867
|
+
const firstNonSystem = body.messages.find((message) => message.role !== "system");
|
|
868
|
+
if (firstNonSystem?.role === "user" && isTitleRequestMarker(textContent(firstNonSystem.content))) {
|
|
869
|
+
return { matched: true, reason: "opencode-title-marker" };
|
|
870
|
+
}
|
|
871
|
+
return { matched: false, reason: "no-title-marker" };
|
|
872
|
+
}
|
|
873
|
+
function isTitleRequestMarker(text) {
|
|
874
|
+
return text.trim() === OPENCODE_TITLE_REQUEST_MARKER;
|
|
875
|
+
}
|
|
876
|
+
function selectToolsForChoice(tools, toolChoice) {
|
|
877
|
+
if (!tools.length)
|
|
878
|
+
return [];
|
|
879
|
+
if (toolChoice === undefined || toolChoice === null || toolChoice === "auto" || toolChoice === "required") {
|
|
880
|
+
return tools;
|
|
881
|
+
}
|
|
882
|
+
if (toolChoice === "none") {
|
|
883
|
+
return [];
|
|
884
|
+
}
|
|
885
|
+
if (typeof toolChoice === "object") {
|
|
886
|
+
const choice = toolChoice;
|
|
887
|
+
if (choice.type === "function" && typeof choice.function?.name === "string") {
|
|
888
|
+
return tools.filter((tool) => tool.function.name === choice.function.name);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return tools;
|
|
627
892
|
}
|
|
628
893
|
/** Convert OpenAI tool definitions to Cursor's MCP tool protobuf format. */
|
|
629
894
|
function buildMcpToolDefinitions(tools) {
|
|
@@ -766,6 +1031,12 @@ function makeHeartbeatBytes() {
|
|
|
766
1031
|
});
|
|
767
1032
|
return toBinary(AgentClientMessageSchema, heartbeat);
|
|
768
1033
|
}
|
|
1034
|
+
function scheduleBridgeEnd(bridge) {
|
|
1035
|
+
queueMicrotask(() => {
|
|
1036
|
+
if (bridge.alive)
|
|
1037
|
+
bridge.end();
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
769
1040
|
/**
|
|
770
1041
|
* Create a stateful parser for Connect protocol frames.
|
|
771
1042
|
* Handles buffering partial data across chunks.
|
|
@@ -908,6 +1179,12 @@ function handleKvMessage(kvMsg, blobStore, sendFrame) {
|
|
|
908
1179
|
const blobId = kvMsg.message.value.blobId;
|
|
909
1180
|
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
910
1181
|
const blobData = blobStore.get(blobIdKey);
|
|
1182
|
+
if (!blobData) {
|
|
1183
|
+
logPluginWarn("Cursor requested missing blob", {
|
|
1184
|
+
blobId: blobIdKey,
|
|
1185
|
+
knownBlobCount: blobStore.size,
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
911
1188
|
sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
|
|
912
1189
|
}
|
|
913
1190
|
else if (kvCase === "setBlobArgs") {
|
|
@@ -1072,42 +1349,151 @@ function sendExecResult(execMsg, messageCase, value, sendFrame) {
|
|
|
1072
1349
|
});
|
|
1073
1350
|
sendFrame(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1074
1351
|
}
|
|
1075
|
-
/** Derive a key for active bridge lookup (tool-call continuations).
|
|
1076
|
-
function deriveBridgeKey(modelId, messages) {
|
|
1352
|
+
/** Derive a key for active bridge lookup (tool-call continuations). */
|
|
1353
|
+
function deriveBridgeKey(modelId, messages, sessionId, agentKey) {
|
|
1354
|
+
if (sessionId) {
|
|
1355
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1356
|
+
return createHash("sha256")
|
|
1357
|
+
.update(`bridge:${sessionId}:${normalizedAgent}`)
|
|
1358
|
+
.digest("hex")
|
|
1359
|
+
.slice(0, 16);
|
|
1360
|
+
}
|
|
1077
1361
|
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1078
1362
|
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1363
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1079
1364
|
return createHash("sha256")
|
|
1080
|
-
.update(`bridge:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1365
|
+
.update(`bridge:${normalizedAgent}:${modelId}:${firstUserText.slice(0, 200)}`)
|
|
1081
1366
|
.digest("hex")
|
|
1082
1367
|
.slice(0, 16);
|
|
1083
1368
|
}
|
|
1084
1369
|
/** Derive a key for conversation state. Model-independent so context survives model switches. */
|
|
1085
|
-
function deriveConversationKey(messages) {
|
|
1086
|
-
|
|
1087
|
-
|
|
1370
|
+
function deriveConversationKey(messages, sessionId, agentKey) {
|
|
1371
|
+
if (sessionId) {
|
|
1372
|
+
const normalizedAgent = normalizeAgentKey(agentKey);
|
|
1373
|
+
return createHash("sha256")
|
|
1374
|
+
.update(`session:${sessionId}:${normalizedAgent}`)
|
|
1375
|
+
.digest("hex")
|
|
1376
|
+
.slice(0, 16);
|
|
1377
|
+
}
|
|
1088
1378
|
return createHash("sha256")
|
|
1089
|
-
.update(
|
|
1379
|
+
.update(`${normalizeAgentKey(agentKey)}:${buildConversationFingerprint(messages)}`)
|
|
1090
1380
|
.digest("hex")
|
|
1091
1381
|
.slice(0, 16);
|
|
1092
1382
|
}
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1383
|
+
function buildConversationFingerprint(messages) {
|
|
1384
|
+
return messages.map((message) => {
|
|
1385
|
+
const toolCallIDs = (message.tool_calls ?? []).map((call) => call.id).join(",");
|
|
1386
|
+
return `${message.role}:${textContent(message.content)}:${message.tool_call_id ?? ""}:${toolCallIDs}`;
|
|
1387
|
+
}).join("\n---\n");
|
|
1388
|
+
}
|
|
1389
|
+
function updateStoredConversationAfterCompletion(convKey, metadata, assistantText) {
|
|
1390
|
+
const stored = conversationStates.get(convKey);
|
|
1391
|
+
if (!stored)
|
|
1392
|
+
return;
|
|
1393
|
+
const nextTurns = metadata.userText
|
|
1394
|
+
? [...metadata.turns, { userText: metadata.userText, assistantText: assistantText.trim() }]
|
|
1395
|
+
: metadata.turns;
|
|
1396
|
+
stored.systemPromptHash = metadata.systemPromptHash;
|
|
1397
|
+
stored.completedTurnsFingerprint = buildCompletedTurnsFingerprint(metadata.systemPrompt, nextTurns);
|
|
1398
|
+
stored.lastAccessMs = Date.now();
|
|
1399
|
+
}
|
|
1400
|
+
function deriveFallbackTitle(text) {
|
|
1401
|
+
const cleaned = text
|
|
1402
|
+
.replace(/<[^>]+>/g, " ")
|
|
1403
|
+
.replace(/\[[^\]]+\]/g, " ")
|
|
1404
|
+
.replace(/[^\p{L}\p{N}'’\-\s]+/gu, " ")
|
|
1405
|
+
.replace(/\s+/g, " ")
|
|
1406
|
+
.trim();
|
|
1407
|
+
if (!cleaned)
|
|
1408
|
+
return "";
|
|
1409
|
+
const words = cleaned.split(" ").filter(Boolean).slice(0, 6);
|
|
1410
|
+
return finalizeTitle(words.map(titleCaseWord).join(" "));
|
|
1411
|
+
}
|
|
1412
|
+
function titleCaseWord(word) {
|
|
1413
|
+
if (!word)
|
|
1414
|
+
return word;
|
|
1415
|
+
return word[0].toUpperCase() + word.slice(1);
|
|
1416
|
+
}
|
|
1417
|
+
function finalizeTitle(value) {
|
|
1418
|
+
return value
|
|
1419
|
+
.replace(/^#{1,6}\s*/, "")
|
|
1420
|
+
.replace(/[.!?,:;]+$/g, "")
|
|
1421
|
+
.replace(/\s+/g, " ")
|
|
1422
|
+
.trim()
|
|
1423
|
+
.slice(0, 80)
|
|
1424
|
+
.trim();
|
|
1425
|
+
}
|
|
1426
|
+
function createBufferedSSETextResponse(modelId, text, usage) {
|
|
1427
|
+
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1428
|
+
const created = Math.floor(Date.now() / 1000);
|
|
1429
|
+
const payload = [
|
|
1430
|
+
{
|
|
1431
|
+
id: completionId,
|
|
1432
|
+
object: "chat.completion.chunk",
|
|
1433
|
+
created,
|
|
1434
|
+
model: modelId,
|
|
1435
|
+
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
id: completionId,
|
|
1439
|
+
object: "chat.completion.chunk",
|
|
1440
|
+
created,
|
|
1441
|
+
model: modelId,
|
|
1442
|
+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
|
1443
|
+
},
|
|
1444
|
+
{
|
|
1445
|
+
id: completionId,
|
|
1446
|
+
object: "chat.completion.chunk",
|
|
1447
|
+
created,
|
|
1448
|
+
model: modelId,
|
|
1449
|
+
choices: [],
|
|
1450
|
+
usage,
|
|
1451
|
+
},
|
|
1452
|
+
].map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n";
|
|
1453
|
+
return new Response(payload, { headers: SSE_HEADERS });
|
|
1454
|
+
}
|
|
1455
|
+
async function handleTitleGenerationRequest(sourceText, accessToken, modelId, stream) {
|
|
1456
|
+
const requestBody = toBinary(NameAgentRequestSchema, create(NameAgentRequestSchema, {
|
|
1457
|
+
userMessage: sourceText,
|
|
1458
|
+
}));
|
|
1459
|
+
const response = await callCursorUnaryRpc({
|
|
1460
|
+
accessToken,
|
|
1461
|
+
rpcPath: "/agent.v1.AgentService/NameAgent",
|
|
1462
|
+
requestBody,
|
|
1463
|
+
timeoutMs: 5_000,
|
|
1464
|
+
});
|
|
1465
|
+
if (response.timedOut) {
|
|
1466
|
+
throw new Error("Cursor title generation timed out");
|
|
1467
|
+
}
|
|
1468
|
+
if (response.exitCode !== 0) {
|
|
1469
|
+
throw new Error(`Cursor title generation failed with HTTP ${response.exitCode}`);
|
|
1470
|
+
}
|
|
1471
|
+
const payload = decodeConnectUnaryBody(response.body) ?? response.body;
|
|
1472
|
+
const decoded = fromBinary(NameAgentResponseSchema, payload);
|
|
1473
|
+
const title = finalizeTitle(decoded.name) || deriveFallbackTitle(sourceText) || "Untitled Session";
|
|
1474
|
+
const usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
|
|
1475
|
+
if (stream) {
|
|
1476
|
+
return createBufferedSSETextResponse(modelId, title, usage);
|
|
1477
|
+
}
|
|
1478
|
+
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1479
|
+
const created = Math.floor(Date.now() / 1000);
|
|
1480
|
+
return new Response(JSON.stringify({
|
|
1481
|
+
id: completionId,
|
|
1482
|
+
object: "chat.completion",
|
|
1483
|
+
created,
|
|
1484
|
+
model: modelId,
|
|
1485
|
+
choices: [
|
|
1486
|
+
{
|
|
1487
|
+
index: 0,
|
|
1488
|
+
message: { role: "assistant", content: title },
|
|
1489
|
+
finish_reason: "stop",
|
|
1490
|
+
},
|
|
1491
|
+
],
|
|
1492
|
+
usage,
|
|
1493
|
+
}), { headers: { "Content-Type": "application/json" } });
|
|
1108
1494
|
}
|
|
1109
1495
|
/** Create an SSE streaming Response that reads from a live bridge. */
|
|
1110
|
-
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey) {
|
|
1496
|
+
function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, metadata) {
|
|
1111
1497
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1112
1498
|
const created = Math.floor(Date.now() / 1000);
|
|
1113
1499
|
const stream = new ReadableStream({
|
|
@@ -1155,7 +1541,9 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1155
1541
|
totalTokens: 0,
|
|
1156
1542
|
};
|
|
1157
1543
|
const tagFilter = createThinkingTagFilter();
|
|
1544
|
+
let assistantText = metadata.assistantSeedText ?? "";
|
|
1158
1545
|
let mcpExecReceived = false;
|
|
1546
|
+
let endStreamError = null;
|
|
1159
1547
|
const processChunk = createConnectFrameParser((messageBytes) => {
|
|
1160
1548
|
try {
|
|
1161
1549
|
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
@@ -1167,8 +1555,10 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1167
1555
|
const { content, reasoning } = tagFilter.process(text);
|
|
1168
1556
|
if (reasoning)
|
|
1169
1557
|
sendSSE(makeChunk({ reasoning_content: reasoning }));
|
|
1170
|
-
if (content)
|
|
1558
|
+
if (content) {
|
|
1559
|
+
assistantText += content;
|
|
1171
1560
|
sendSSE(makeChunk({ content }));
|
|
1561
|
+
}
|
|
1172
1562
|
}
|
|
1173
1563
|
},
|
|
1174
1564
|
// onMcpExec — the model wants to execute a tool.
|
|
@@ -1178,8 +1568,21 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1178
1568
|
const flushed = tagFilter.flush();
|
|
1179
1569
|
if (flushed.reasoning)
|
|
1180
1570
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1181
|
-
if (flushed.content)
|
|
1571
|
+
if (flushed.content) {
|
|
1572
|
+
assistantText += flushed.content;
|
|
1182
1573
|
sendSSE(makeChunk({ content: flushed.content }));
|
|
1574
|
+
}
|
|
1575
|
+
const assistantSeedText = [
|
|
1576
|
+
assistantText.trim(),
|
|
1577
|
+
formatToolCallSummary({
|
|
1578
|
+
id: exec.toolCallId,
|
|
1579
|
+
type: "function",
|
|
1580
|
+
function: {
|
|
1581
|
+
name: exec.toolName,
|
|
1582
|
+
arguments: exec.decodedArgs,
|
|
1583
|
+
},
|
|
1584
|
+
}),
|
|
1585
|
+
].filter(Boolean).join("\n\n");
|
|
1183
1586
|
const toolCallIndex = state.toolCallIndex++;
|
|
1184
1587
|
sendSSE(makeChunk({
|
|
1185
1588
|
tool_calls: [{
|
|
@@ -1199,6 +1602,11 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1199
1602
|
blobStore,
|
|
1200
1603
|
mcpTools,
|
|
1201
1604
|
pendingExecs: state.pendingExecs,
|
|
1605
|
+
modelId,
|
|
1606
|
+
metadata: {
|
|
1607
|
+
...metadata,
|
|
1608
|
+
assistantSeedText,
|
|
1609
|
+
},
|
|
1202
1610
|
});
|
|
1203
1611
|
sendSSE(makeChunk({}, "tool_calls"));
|
|
1204
1612
|
sendDone();
|
|
@@ -1215,10 +1623,16 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1215
1623
|
// Skip unparseable messages
|
|
1216
1624
|
}
|
|
1217
1625
|
}, (endStreamBytes) => {
|
|
1218
|
-
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1626
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1627
|
+
if (endStreamError) {
|
|
1628
|
+
logPluginError("Cursor stream returned Connect end-stream error", {
|
|
1629
|
+
modelId,
|
|
1630
|
+
bridgeKey,
|
|
1631
|
+
convKey,
|
|
1632
|
+
...errorDetails(endStreamError),
|
|
1633
|
+
});
|
|
1221
1634
|
}
|
|
1635
|
+
scheduleBridgeEnd(bridge);
|
|
1222
1636
|
});
|
|
1223
1637
|
bridge.onData(processChunk);
|
|
1224
1638
|
bridge.onClose((code) => {
|
|
@@ -1229,27 +1643,39 @@ function createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools,
|
|
|
1229
1643
|
stored.blobStore.set(k, v);
|
|
1230
1644
|
stored.lastAccessMs = Date.now();
|
|
1231
1645
|
}
|
|
1646
|
+
if (endStreamError) {
|
|
1647
|
+
activeBridges.delete(bridgeKey);
|
|
1648
|
+
if (!closed) {
|
|
1649
|
+
closed = true;
|
|
1650
|
+
controller.error(endStreamError);
|
|
1651
|
+
}
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1232
1654
|
if (!mcpExecReceived) {
|
|
1233
1655
|
const flushed = tagFilter.flush();
|
|
1234
1656
|
if (flushed.reasoning)
|
|
1235
1657
|
sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
1236
|
-
if (flushed.content)
|
|
1658
|
+
if (flushed.content) {
|
|
1659
|
+
assistantText += flushed.content;
|
|
1237
1660
|
sendSSE(makeChunk({ content: flushed.content }));
|
|
1661
|
+
}
|
|
1662
|
+
updateStoredConversationAfterCompletion(convKey, metadata, assistantText);
|
|
1238
1663
|
sendSSE(makeChunk({}, "stop"));
|
|
1239
1664
|
sendSSE(makeUsageChunk());
|
|
1240
1665
|
sendDone();
|
|
1241
1666
|
closeController();
|
|
1242
1667
|
}
|
|
1243
|
-
else
|
|
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.
|
|
1668
|
+
else {
|
|
1252
1669
|
activeBridges.delete(bridgeKey);
|
|
1670
|
+
if (code !== 0 && !closed) {
|
|
1671
|
+
// Bridge died while tool calls are pending (timeout, crash, etc.).
|
|
1672
|
+
// Close the SSE stream so the client doesn't hang forever.
|
|
1673
|
+
sendSSE(makeChunk({ content: "\n[Error: bridge connection lost]" }));
|
|
1674
|
+
sendSSE(makeChunk({}, "stop"));
|
|
1675
|
+
sendSSE(makeUsageChunk());
|
|
1676
|
+
sendDone();
|
|
1677
|
+
closeController();
|
|
1678
|
+
}
|
|
1253
1679
|
}
|
|
1254
1680
|
});
|
|
1255
1681
|
},
|
|
@@ -1267,13 +1693,20 @@ async function startBridge(accessToken, requestBytes) {
|
|
|
1267
1693
|
const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
|
|
1268
1694
|
return { bridge, heartbeatTimer };
|
|
1269
1695
|
}
|
|
1270
|
-
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey) {
|
|
1696
|
+
async function handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, metadata) {
|
|
1271
1697
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1272
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey);
|
|
1698
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, payload.blobStore, payload.mcpTools, modelId, bridgeKey, convKey, metadata);
|
|
1273
1699
|
}
|
|
1274
1700
|
/** Resume a paused bridge by sending MCP results and continuing to stream. */
|
|
1275
|
-
function handleToolResultResume(active, toolResults,
|
|
1276
|
-
const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs } = active;
|
|
1701
|
+
function handleToolResultResume(active, toolResults, bridgeKey, convKey) {
|
|
1702
|
+
const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, modelId, metadata } = active;
|
|
1703
|
+
const resumeMetadata = {
|
|
1704
|
+
...metadata,
|
|
1705
|
+
assistantSeedText: [
|
|
1706
|
+
metadata.assistantSeedText?.trim() ?? "",
|
|
1707
|
+
toolResults.map(formatToolResultSummary).join("\n\n"),
|
|
1708
|
+
].filter(Boolean).join("\n\n"),
|
|
1709
|
+
};
|
|
1277
1710
|
// Send mcpResult for each pending exec that has a matching tool result
|
|
1278
1711
|
for (const exec of pendingExecs) {
|
|
1279
1712
|
const result = toolResults.find((r) => r.toolCallId === exec.toolCallId);
|
|
@@ -1313,12 +1746,15 @@ function handleToolResultResume(active, toolResults, modelId, bridgeKey, convKey
|
|
|
1313
1746
|
});
|
|
1314
1747
|
bridge.write(toBinary(AgentClientMessageSchema, clientMessage));
|
|
1315
1748
|
}
|
|
1316
|
-
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey);
|
|
1749
|
+
return createBridgeStreamResponse(bridge, heartbeatTimer, blobStore, mcpTools, modelId, bridgeKey, convKey, resumeMetadata);
|
|
1317
1750
|
}
|
|
1318
|
-
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey) {
|
|
1751
|
+
async function handleNonStreamingResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1319
1752
|
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1320
1753
|
const created = Math.floor(Date.now() / 1000);
|
|
1321
|
-
const { text, usage } = await collectFullResponse(payload, accessToken, convKey);
|
|
1754
|
+
const { text, usage, finishReason, toolCalls } = await collectFullResponse(payload, accessToken, modelId, convKey, metadata);
|
|
1755
|
+
const message = finishReason === "tool_calls"
|
|
1756
|
+
? { role: "assistant", content: null, tool_calls: toolCalls }
|
|
1757
|
+
: { role: "assistant", content: text };
|
|
1322
1758
|
return new Response(JSON.stringify({
|
|
1323
1759
|
id: completionId,
|
|
1324
1760
|
object: "chat.completion",
|
|
@@ -1327,16 +1763,18 @@ async function handleNonStreamingResponse(payload, accessToken, modelId, convKey
|
|
|
1327
1763
|
choices: [
|
|
1328
1764
|
{
|
|
1329
1765
|
index: 0,
|
|
1330
|
-
message
|
|
1331
|
-
finish_reason:
|
|
1766
|
+
message,
|
|
1767
|
+
finish_reason: finishReason,
|
|
1332
1768
|
},
|
|
1333
1769
|
],
|
|
1334
1770
|
usage,
|
|
1335
1771
|
}), { headers: { "Content-Type": "application/json" } });
|
|
1336
1772
|
}
|
|
1337
|
-
async function collectFullResponse(payload, accessToken, convKey) {
|
|
1338
|
-
const { promise, resolve } = Promise.withResolvers();
|
|
1773
|
+
async function collectFullResponse(payload, accessToken, modelId, convKey, metadata) {
|
|
1774
|
+
const { promise, resolve, reject } = Promise.withResolvers();
|
|
1339
1775
|
let fullText = "";
|
|
1776
|
+
let endStreamError = null;
|
|
1777
|
+
const pendingToolCalls = [];
|
|
1340
1778
|
const { bridge, heartbeatTimer } = await startBridge(accessToken, payload.requestBytes);
|
|
1341
1779
|
const state = {
|
|
1342
1780
|
toolCallIndex: 0,
|
|
@@ -1353,7 +1791,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1353
1791
|
return;
|
|
1354
1792
|
const { content } = tagFilter.process(text);
|
|
1355
1793
|
fullText += content;
|
|
1356
|
-
}, () => {
|
|
1794
|
+
}, (exec) => {
|
|
1795
|
+
pendingToolCalls.push({
|
|
1796
|
+
id: exec.toolCallId,
|
|
1797
|
+
type: "function",
|
|
1798
|
+
function: {
|
|
1799
|
+
name: exec.toolName,
|
|
1800
|
+
arguments: exec.decodedArgs,
|
|
1801
|
+
},
|
|
1802
|
+
});
|
|
1803
|
+
scheduleBridgeEnd(bridge);
|
|
1804
|
+
}, (checkpointBytes) => {
|
|
1357
1805
|
const stored = conversationStates.get(convKey);
|
|
1358
1806
|
if (stored) {
|
|
1359
1807
|
stored.checkpoint = checkpointBytes;
|
|
@@ -1364,7 +1812,17 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1364
1812
|
catch {
|
|
1365
1813
|
// Skip
|
|
1366
1814
|
}
|
|
1367
|
-
}, () => {
|
|
1815
|
+
}, (endStreamBytes) => {
|
|
1816
|
+
endStreamError = parseConnectEndStream(endStreamBytes);
|
|
1817
|
+
if (endStreamError) {
|
|
1818
|
+
logPluginError("Cursor non-streaming response returned Connect end-stream error", {
|
|
1819
|
+
modelId,
|
|
1820
|
+
convKey,
|
|
1821
|
+
...errorDetails(endStreamError),
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
scheduleBridgeEnd(bridge);
|
|
1825
|
+
}));
|
|
1368
1826
|
bridge.onClose(() => {
|
|
1369
1827
|
clearInterval(heartbeatTimer);
|
|
1370
1828
|
const stored = conversationStates.get(convKey);
|
|
@@ -1375,10 +1833,19 @@ async function collectFullResponse(payload, accessToken, convKey) {
|
|
|
1375
1833
|
}
|
|
1376
1834
|
const flushed = tagFilter.flush();
|
|
1377
1835
|
fullText += flushed.content;
|
|
1836
|
+
if (endStreamError) {
|
|
1837
|
+
reject(endStreamError);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
if (pendingToolCalls.length === 0) {
|
|
1841
|
+
updateStoredConversationAfterCompletion(convKey, metadata, fullText);
|
|
1842
|
+
}
|
|
1378
1843
|
const usage = computeUsage(state);
|
|
1379
1844
|
resolve({
|
|
1380
1845
|
text: fullText,
|
|
1381
1846
|
usage,
|
|
1847
|
+
finishReason: pendingToolCalls.length > 0 ? "tool_calls" : "stop",
|
|
1848
|
+
toolCalls: pendingToolCalls,
|
|
1382
1849
|
});
|
|
1383
1850
|
});
|
|
1384
1851
|
return promise;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playwo/opencode-cursor-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|