@herbertgao/pi-extensions 2026.9.9 → 2026.9.11
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 +1 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/node_modules/@herbertgao/pi-bark/package.json +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
- package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -2
- package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +12 -0
- package/node_modules/@herbertgao/pi-subagents/README.md +427 -120
- package/node_modules/@herbertgao/pi-subagents/docs/rpc.md +184 -0
- package/node_modules/@herbertgao/pi-subagents/docs/workflows.md +466 -0
- package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +6 -6
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/compose.js +52 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/fan-out-audit.js +56 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/gated-fix.js +60 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/lib/count-child.js +30 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/review-panel.js +68 -0
- package/node_modules/@herbertgao/pi-subagents/examples/workflows/structured-findings.js +81 -0
- package/node_modules/@herbertgao/pi-subagents/package.json +12 -10
- package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +52 -12
- package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +837 -146
- package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +213 -39
- package/node_modules/@herbertgao/pi-subagents/src/cross-extension-rpc.ts +73 -14
- package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +101 -47
- package/node_modules/@herbertgao/pi-subagents/src/index.ts +2249 -914
- package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +13 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention-clone.ts +215 -0
- package/node_modules/@herbertgao/pi-subagents/src/mention.ts +147 -0
- package/node_modules/@herbertgao/pi-subagents/src/model-resolver.ts +9 -1
- package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +40 -26
- package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +18 -8
- package/node_modules/@herbertgao/pi-subagents/src/prompts.ts +46 -9
- package/node_modules/@herbertgao/pi-subagents/src/schedule.ts +21 -16
- package/node_modules/@herbertgao/pi-subagents/src/settings.ts +137 -7
- package/node_modules/@herbertgao/pi-subagents/src/structured-output.ts +136 -0
- package/node_modules/@herbertgao/pi-subagents/src/types.ts +126 -8
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-mention.ts +274 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +20 -5
- package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +10 -4
- package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +167 -22
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-card.ts +555 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-dialog.ts +1304 -0
- package/node_modules/@herbertgao/pi-subagents/src/ui/workflow-menu.ts +226 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/collisions.ts +122 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/entry.ts +47 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/host.ts +463 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/journal.ts +164 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/json-schema.ts +142 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/meta.ts +401 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/progress.ts +622 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/runtime.ts +1399 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/saved.ts +230 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/task.ts +333 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/tool-description.ts +200 -0
- package/node_modules/@herbertgao/pi-subagents/src/workflow/worker-source.ts +781 -0
- package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +97 -95
- package/node_modules/@herbertgao/pi-subagents/src/xml.ts +13 -0
- package/node_modules/@herbertgao/resume-from/package.json +1 -1
- package/node_modules/@herbertgao/sol-pi/README.md +3 -3
- package/node_modules/@herbertgao/sol-pi/THIRD_PARTY_NOTICES.md +4 -4
- package/node_modules/@herbertgao/sol-pi/agents-install.md +4 -4
- package/node_modules/@herbertgao/sol-pi/docs/compatibility.md +6 -6
- package/node_modules/@herbertgao/sol-pi/package.json +2 -2
- package/node_modules/@narumitw/pi-btw/dist/index.ts +39 -89
- package/node_modules/@narumitw/pi-btw/dist/index.ts.map +3 -3
- package/node_modules/@narumitw/pi-btw/package.json +4 -4
- package/node_modules/@narumitw/pi-btw/src/btw.ts +28 -87
- package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +8 -0
- package/node_modules/@narumitw/pi-btw/src/side-thread.ts +40 -37
- package/node_modules/@narumitw/pi-caffeinate/README.md +21 -66
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts +10 -41
- package/node_modules/@narumitw/pi-caffeinate/dist/index.ts.map +2 -2
- package/node_modules/@narumitw/pi-caffeinate/package.json +50 -51
- package/node_modules/@narumitw/pi-caffeinate/src/caffeinate.ts +637 -663
- package/node_modules/@narumitw/pi-caffeinate/src/dbus-inhibit.ts +114 -120
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitor-process.ts +29 -29
- package/node_modules/@narumitw/pi-caffeinate/src/inhibitors.ts +108 -126
- package/node_modules/@narumitw/pi-caffeinate/src/settings.ts +124 -128
- package/node_modules/pi-multi-account/CHANGELOG.md +1209 -0
- package/node_modules/pi-multi-account/CONTRIBUTING.md +61 -0
- package/node_modules/pi-multi-account/LICENSE +21 -0
- package/node_modules/pi-multi-account/README.md +197 -0
- package/node_modules/pi-multi-account/SECURITY.md +27 -0
- package/node_modules/pi-multi-account/auth-file-transaction.ts +56 -0
- package/node_modules/pi-multi-account/child-usability.ts +233 -0
- package/node_modules/pi-multi-account/compaction-summary.ts +32 -0
- package/node_modules/pi-multi-account/completion-route-planner.ts +224 -0
- package/node_modules/pi-multi-account/context-guard.ts +420 -0
- package/node_modules/pi-multi-account/cursor/LICENSE +21 -0
- package/node_modules/pi-multi-account/cursor/NOTICE +2 -0
- package/node_modules/pi-multi-account/cursor/auth.ts +165 -0
- package/node_modules/pi-multi-account/cursor/bridge-handle.ts +155 -0
- package/node_modules/pi-multi-account/cursor/conversation-registry.ts +104 -0
- package/node_modules/pi-multi-account/cursor/cursor-models-raw.json +611 -0
- package/node_modules/pi-multi-account/cursor/cursor-shared.ts +192 -0
- package/node_modules/pi-multi-account/cursor/h2-bridge.mjs +175 -0
- package/node_modules/pi-multi-account/cursor/index.ts +572 -0
- package/node_modules/pi-multi-account/cursor/message-parsing.ts +323 -0
- package/node_modules/pi-multi-account/cursor/prompt-usage.ts +53 -0
- package/node_modules/pi-multi-account/cursor/proto/agent_pb.ts +15294 -0
- package/node_modules/pi-multi-account/cursor/proxy.ts +2510 -0
- package/node_modules/pi-multi-account/cursor/session-lifecycle.ts +40 -0
- package/node_modules/pi-multi-account/cursor/sse-keepalive.ts +24 -0
- package/node_modules/pi-multi-account/cursor/stream-lifecycle.ts +193 -0
- package/node_modules/pi-multi-account/cursor/upstream-watchdog.ts +88 -0
- package/node_modules/pi-multi-account/cursor-bridge.ts +240 -0
- package/node_modules/pi-multi-account/cursor-model-name.ts +12 -0
- package/node_modules/pi-multi-account/index.ts +11825 -0
- package/node_modules/pi-multi-account/model-catalog.ts +354 -0
- package/node_modules/pi-multi-account/package.json +101 -0
- package/node_modules/pi-multi-account/pi-contract.ts +281 -0
- package/node_modules/pi-multi-account/provider-payload-stream.ts +44 -0
- package/node_modules/pi-multi-account/provider-priority.ts +189 -0
- package/node_modules/pi-multi-account/slot-proxy-auth.ts +167 -0
- package/node_modules/pi-multi-account/slot-proxy.ts +344 -0
- package/node_modules/pi-multi-account/state-file-transaction.ts +67 -0
- package/node_modules/pi-multi-account/usage.ts +1099 -0
- package/node_modules/pi-typesafe/README.md +6 -2
- package/node_modules/pi-typesafe/dist/client.d.ts +11 -0
- package/node_modules/pi-typesafe/dist/client.js +45 -10
- package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
- package/node_modules/pi-typesafe/dist/index.js +1 -1
- package/node_modules/pi-typesafe/package.json +2 -2
- package/node_modules/pi-web-access/CHANGELOG.md +36 -0
- package/node_modules/pi-web-access/README.md +75 -18
- package/node_modules/pi-web-access/anysearch.ts +4 -15
- package/node_modules/pi-web-access/bocha.ts +3 -22
- package/node_modules/pi-web-access/brave.ts +3 -21
- package/node_modules/pi-web-access/brightdata.ts +5 -32
- package/node_modules/pi-web-access/content-find.ts +168 -53
- package/node_modules/pi-web-access/curator-page.ts +4 -1
- package/node_modules/pi-web-access/curator-run.ts +2 -1
- package/node_modules/pi-web-access/curator-server.ts +1 -0
- package/node_modules/pi-web-access/dist/index.js +24620 -0
- package/node_modules/pi-web-access/domain-filter-normalization.ts +14 -0
- package/node_modules/pi-web-access/duckduckgo.ts +3 -21
- package/node_modules/pi-web-access/extract.ts +3 -1
- package/node_modules/pi-web-access/firecrawl.ts +5 -29
- package/node_modules/pi-web-access/gemini-search.ts +81 -32
- package/node_modules/pi-web-access/index.ts +149 -148
- package/node_modules/pi-web-access/jina-search.ts +4 -15
- package/node_modules/pi-web-access/kagi.ts +4 -13
- package/node_modules/pi-web-access/kimi-search.ts +5 -30
- package/node_modules/pi-web-access/mistral-search.ts +1 -15
- package/node_modules/pi-web-access/ollama.ts +2 -7
- package/node_modules/pi-web-access/openai-search.ts +174 -36
- package/node_modules/pi-web-access/opencode-session-headers.ts +24 -0
- package/node_modules/pi-web-access/package.json +10 -4
- package/node_modules/pi-web-access/page-query.ts +10 -2
- package/node_modules/pi-web-access/parallel.ts +1 -15
- package/node_modules/pi-web-access/pdf-extract.ts +3 -0
- package/node_modules/pi-web-access/querit.ts +5 -29
- package/node_modules/pi-web-access/search-answer-formatting.ts +11 -0
- package/node_modules/pi-web-access/search-result-count-normalization.ts +4 -0
- package/node_modules/pi-web-access/search1api.ts +5 -29
- package/node_modules/pi-web-access/searchinfinity.ts +5 -29
- package/node_modules/pi-web-access/searxng.ts +3 -21
- package/node_modules/pi-web-access/serpapi.ts +5 -28
- package/node_modules/pi-web-access/serpbase.ts +3 -22
- package/node_modules/pi-web-access/serpdive.ts +3 -21
- package/node_modules/pi-web-access/serper.ts +5 -28
- package/node_modules/pi-web-access/serply.ts +197 -0
- package/node_modules/pi-web-access/source-check.ts +11 -47
- package/node_modules/pi-web-access/summary-review.ts +7 -3
- package/node_modules/pi-web-access/tavily.ts +3 -21
- package/node_modules/pi-web-access/tinyfish.ts +5 -29
- package/node_modules/pi-web-access/utils.ts +9 -1
- package/node_modules/pi-web-access/valyu.ts +5 -28
- package/node_modules/pi-web-access/xai-search.ts +1 -15
- package/node_modules/pi-web-access/xcrawl.ts +5 -32
- package/package.json +17 -11
|
@@ -0,0 +1,2510 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local OpenAI-compatible proxy: translates /v1/chat/completions to Cursor's gRPC protocol.
|
|
3
|
+
*
|
|
4
|
+
* Based on https://github.com/ephraimduncan/opencode-cursor by Ephraim Duncan.
|
|
5
|
+
* Uses Node's http2 via a child process bridge (h2-bridge.mjs).
|
|
6
|
+
*/
|
|
7
|
+
import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
|
|
8
|
+
import { ValueSchema } from "@bufbuild/protobuf/wkt";
|
|
9
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
10
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { appendFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { resolve as pathResolve, dirname, join as pathJoin } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { estimatePromptTokens, resolveCursorUsage } from "./prompt-usage.ts";
|
|
17
|
+
import { type BridgeHandle, type BridgeStreams, createBridgeHandle } from "./bridge-handle.ts";
|
|
18
|
+
import { startSSEResponse } from "./sse-keepalive.ts";
|
|
19
|
+
import {
|
|
20
|
+
formatStallDuration,
|
|
21
|
+
resolveTransportStallTimeoutMs,
|
|
22
|
+
resolveUpstreamStallTimeoutMs,
|
|
23
|
+
startUpstreamWatchdog,
|
|
24
|
+
} from "./upstream-watchdog.ts";
|
|
25
|
+
import {
|
|
26
|
+
classifyCursorFrame,
|
|
27
|
+
mapNativeExecToPiTool,
|
|
28
|
+
NATIVE_TOOL_UNAVAILABLE,
|
|
29
|
+
resolveToolCallCoalesceMs,
|
|
30
|
+
type CursorFrameClass,
|
|
31
|
+
} from "./stream-lifecycle.ts";
|
|
32
|
+
import {
|
|
33
|
+
requestActionText,
|
|
34
|
+
historyForRebuild,
|
|
35
|
+
isToolCallStep,
|
|
36
|
+
parseMessages as parseMessagesPure,
|
|
37
|
+
parseToolCallArguments,
|
|
38
|
+
systemPromptForRebuild,
|
|
39
|
+
textContent,
|
|
40
|
+
type ContentPart,
|
|
41
|
+
type OpenAIMessage,
|
|
42
|
+
type OpenAIToolCall,
|
|
43
|
+
type ParsedAssistantTextStep,
|
|
44
|
+
type ParsedMessages,
|
|
45
|
+
type ParsedToolCallStep,
|
|
46
|
+
type ParsedToolResult,
|
|
47
|
+
type ParsedTurn,
|
|
48
|
+
type ParsedTurnStep,
|
|
49
|
+
type ToolResultInfo,
|
|
50
|
+
} from "./message-parsing.ts";
|
|
51
|
+
import {
|
|
52
|
+
clearConversationRegistry,
|
|
53
|
+
conversationStates,
|
|
54
|
+
isStaleForTranscript,
|
|
55
|
+
deriveBridgeKeyFromSessionId,
|
|
56
|
+
deriveConversationKeyFromSessionId,
|
|
57
|
+
deterministicConversationId,
|
|
58
|
+
dropConversation,
|
|
59
|
+
forgetConversation,
|
|
60
|
+
type StoredConversation,
|
|
61
|
+
} from "./conversation-registry.ts";
|
|
62
|
+
import {
|
|
63
|
+
AgentClientMessageSchema,
|
|
64
|
+
AgentRunRequestSchema,
|
|
65
|
+
AgentServerMessageSchema,
|
|
66
|
+
AskQuestionInteractionResponseSchema,
|
|
67
|
+
AskQuestionRejectedSchema,
|
|
68
|
+
AskQuestionResultSchema,
|
|
69
|
+
CancelActionSchema,
|
|
70
|
+
ClientHeartbeatSchema,
|
|
71
|
+
ConversationActionSchema,
|
|
72
|
+
ConversationStateStructureSchema,
|
|
73
|
+
ConversationStepSchema,
|
|
74
|
+
AgentConversationTurnStructureSchema,
|
|
75
|
+
ConversationTurnStructureSchema,
|
|
76
|
+
AssistantMessageSchema,
|
|
77
|
+
CreatePlanErrorSchema,
|
|
78
|
+
CreatePlanRequestResponseSchema,
|
|
79
|
+
CreatePlanResultSchema,
|
|
80
|
+
|
|
81
|
+
BackgroundShellSpawnResultSchema,
|
|
82
|
+
DeleteErrorSchema,
|
|
83
|
+
DeleteResultSchema,
|
|
84
|
+
DeleteRejectedSchema,
|
|
85
|
+
DeleteSuccessSchema,
|
|
86
|
+
DiagnosticsResultSchema,
|
|
87
|
+
ExecClientControlMessageSchema,
|
|
88
|
+
ExecClientMessageSchema,
|
|
89
|
+
ExecClientStreamCloseSchema,
|
|
90
|
+
ExecClientThrowSchema,
|
|
91
|
+
ExaFetchRequestResponseSchema,
|
|
92
|
+
ExaFetchRequestResponse_RejectedSchema,
|
|
93
|
+
ExaSearchRequestResponseSchema,
|
|
94
|
+
ExaSearchRequestResponse_RejectedSchema,
|
|
95
|
+
FetchErrorSchema,
|
|
96
|
+
FetchResultSchema,
|
|
97
|
+
GetBlobResultSchema,
|
|
98
|
+
GrepContentMatchSchema,
|
|
99
|
+
GrepContentResultSchema,
|
|
100
|
+
GrepErrorSchema,
|
|
101
|
+
GrepFileMatchSchema,
|
|
102
|
+
GrepResultSchema,
|
|
103
|
+
GrepSuccessSchema,
|
|
104
|
+
GrepUnionResultSchema,
|
|
105
|
+
InteractionResponseSchema,
|
|
106
|
+
KvClientMessageSchema,
|
|
107
|
+
LsDirectoryTreeNodeSchema,
|
|
108
|
+
LsDirectoryTreeNode_FileSchema,
|
|
109
|
+
LsErrorSchema,
|
|
110
|
+
LsRejectedSchema,
|
|
111
|
+
LsResultSchema,
|
|
112
|
+
LsSuccessSchema,
|
|
113
|
+
McpArgsSchema,
|
|
114
|
+
McpErrorSchema,
|
|
115
|
+
McpResultSchema,
|
|
116
|
+
McpSuccessSchema,
|
|
117
|
+
McpTextContentSchema,
|
|
118
|
+
McpToolCallSchema,
|
|
119
|
+
McpToolDefinitionSchema,
|
|
120
|
+
McpToolResultContentItemSchema,
|
|
121
|
+
ModelDetailsSchema,
|
|
122
|
+
ReadErrorSchema,
|
|
123
|
+
ReadRejectedSchema,
|
|
124
|
+
ReadResultSchema,
|
|
125
|
+
ReadSuccessSchema,
|
|
126
|
+
RequestContextResultSchema,
|
|
127
|
+
RequestContextSchema,
|
|
128
|
+
RequestContextSuccessSchema,
|
|
129
|
+
SelectedContextSchema,
|
|
130
|
+
SetBlobResultSchema,
|
|
131
|
+
SetupVmEnvironmentResultSchema,
|
|
132
|
+
SetupVmEnvironmentSuccessSchema,
|
|
133
|
+
ShellRejectedSchema,
|
|
134
|
+
ShellResultSchema,
|
|
135
|
+
ShellStreamExitSchema,
|
|
136
|
+
ShellStreamSchema,
|
|
137
|
+
ShellStreamStdoutSchema,
|
|
138
|
+
ShellSuccessSchema,
|
|
139
|
+
SwitchModeRequestResponseSchema,
|
|
140
|
+
SwitchModeRequestResponse_RejectedSchema,
|
|
141
|
+
ToolCallSchema,
|
|
142
|
+
UserMessageActionSchema,
|
|
143
|
+
UserMessageSchema,
|
|
144
|
+
WebSearchRequestResponseSchema,
|
|
145
|
+
WebSearchRequestResponse_RejectedSchema,
|
|
146
|
+
WriteErrorSchema,
|
|
147
|
+
WriteRejectedSchema,
|
|
148
|
+
WriteResultSchema,
|
|
149
|
+
WriteSuccessSchema,
|
|
150
|
+
WriteShellStdinErrorSchema,
|
|
151
|
+
WriteShellStdinResultSchema,
|
|
152
|
+
GetUsableModelsRequestSchema,
|
|
153
|
+
GetUsableModelsResponseSchema,
|
|
154
|
+
type AgentServerMessage,
|
|
155
|
+
type ConversationStateStructure,
|
|
156
|
+
type ExecServerMessage,
|
|
157
|
+
type InteractionQuery,
|
|
158
|
+
type InteractionResponse,
|
|
159
|
+
type KvServerMessage,
|
|
160
|
+
type McpToolDefinition,
|
|
161
|
+
type ShellStream,
|
|
162
|
+
type UserMessage,
|
|
163
|
+
} from "./proto/agent_pb.ts";
|
|
164
|
+
|
|
165
|
+
const CURSOR_API_URL = "https://api2.cursor.sh";
|
|
166
|
+
const CONNECT_END_STREAM_FLAG = 0b00000010;
|
|
167
|
+
// Use import.meta.url for bridge path resolution (jiti supports this)
|
|
168
|
+
const BRIDGE_PATH = pathResolve(dirname(fileURLToPath(import.meta.url)), "h2-bridge.mjs");
|
|
169
|
+
|
|
170
|
+
// ── Types ──
|
|
171
|
+
|
|
172
|
+
interface OpenAIToolDef {
|
|
173
|
+
type: "function";
|
|
174
|
+
function: {
|
|
175
|
+
name: string;
|
|
176
|
+
description?: string;
|
|
177
|
+
parameters?: Record<string, unknown>;
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface ChatCompletionRequest {
|
|
182
|
+
model: string;
|
|
183
|
+
messages: OpenAIMessage[];
|
|
184
|
+
stream?: boolean;
|
|
185
|
+
temperature?: number;
|
|
186
|
+
max_tokens?: number;
|
|
187
|
+
tools?: OpenAIToolDef[];
|
|
188
|
+
tool_choice?: unknown;
|
|
189
|
+
reasoning_effort?: string;
|
|
190
|
+
user?: string;
|
|
191
|
+
pi_session_id?: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface CursorRequestPayload {
|
|
195
|
+
requestBytes: Uint8Array;
|
|
196
|
+
blobStore: Map<string, Uint8Array>;
|
|
197
|
+
mcpTools: McpToolDefinition[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface PendingExec {
|
|
201
|
+
execId: string;
|
|
202
|
+
execMsgId: number;
|
|
203
|
+
toolCallId: string;
|
|
204
|
+
toolName: string;
|
|
205
|
+
decodedArgs: string;
|
|
206
|
+
resultCase?: string;
|
|
207
|
+
nativeArgs?: Record<string, unknown>;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type { BridgeHandle };
|
|
211
|
+
|
|
212
|
+
export type BridgeFactory = (options: SpawnBridgeOptions) => BridgeHandle;
|
|
213
|
+
|
|
214
|
+
interface ActiveBridge {
|
|
215
|
+
bridge: BridgeHandle;
|
|
216
|
+
heartbeatTimer: ReturnType<typeof setInterval>;
|
|
217
|
+
blobStore: Map<string, Uint8Array>;
|
|
218
|
+
mcpTools: McpToolDefinition[];
|
|
219
|
+
pendingExecs: PendingExec[];
|
|
220
|
+
currentTurn: ParsedTurn;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export type { StoredConversation };
|
|
224
|
+
export type {
|
|
225
|
+
ContentPart, OpenAIMessage, OpenAIToolCall, ParsedAssistantTextStep, ParsedMessages,
|
|
226
|
+
ParsedToolCallStep, ParsedToolResult, ParsedTurn, ParsedTurnStep, ToolResultInfo,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
interface StreamState {
|
|
230
|
+
toolCallIndex: number;
|
|
231
|
+
pendingExecs: PendingExec[];
|
|
232
|
+
outputTokens: number;
|
|
233
|
+
totalTokens: number;
|
|
234
|
+
promptTokenEstimate: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── State ──
|
|
238
|
+
|
|
239
|
+
const activeBridges = new Map<string, ActiveBridge>();
|
|
240
|
+
const CONVERSATION_TTL_MS = 30 * 60 * 1000;
|
|
241
|
+
let bridgeFactory: BridgeFactory = spawnBridge;
|
|
242
|
+
let debugRequestCounter = 0;
|
|
243
|
+
let debugLogFilePath: string | undefined;
|
|
244
|
+
|
|
245
|
+
function isProxyDebugEnabled(): boolean {
|
|
246
|
+
const raw = process.env.PI_CURSOR_PROVIDER_DEBUG?.trim().toLowerCase();
|
|
247
|
+
return !!raw && raw !== "0" && raw !== "false" && raw !== "off";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function truncateDebugString(value: string, max = 4000): string {
|
|
251
|
+
return value.length > max ? `${value.slice(0, max)}…<truncated ${value.length - max} chars>` : value;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sanitizeForDebug(value: unknown): unknown {
|
|
255
|
+
if (value == null) return value;
|
|
256
|
+
if (typeof value === "string") return truncateDebugString(value);
|
|
257
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
258
|
+
if (value instanceof Uint8Array || Buffer.isBuffer(value)) {
|
|
259
|
+
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
260
|
+
return {
|
|
261
|
+
__type: value instanceof Uint8Array ? "Uint8Array" : "Buffer",
|
|
262
|
+
byteLength: bytes.length,
|
|
263
|
+
sha256: createHash("sha256").update(bytes).digest("hex").slice(0, 16),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeForDebug(item));
|
|
267
|
+
if (value instanceof Map) {
|
|
268
|
+
return {
|
|
269
|
+
__type: "Map",
|
|
270
|
+
size: value.size,
|
|
271
|
+
entries: Array.from(value.entries()).slice(0, 20).map(([k, v]) => [sanitizeForDebug(k), sanitizeForDebug(v)]),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (typeof value === "object") {
|
|
275
|
+
const entries = Object.entries(value as Record<string, unknown>).map(([key, inner]) => {
|
|
276
|
+
if (key === "accessToken" || key.toLowerCase() === "authorization") {
|
|
277
|
+
return [key, "<redacted>"] as const;
|
|
278
|
+
}
|
|
279
|
+
if (key === "data" && typeof inner === "string") return [key, `<redacted base64 ${inner.length} chars>`] as const;
|
|
280
|
+
return [key, sanitizeForDebug(inner)] as const;
|
|
281
|
+
});
|
|
282
|
+
return Object.fromEntries(entries);
|
|
283
|
+
}
|
|
284
|
+
return String(value);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getDebugLogFilePath(): string {
|
|
288
|
+
const configured = process.env.PI_CURSOR_PROVIDER_DEBUG_FILE?.trim();
|
|
289
|
+
if (configured) return configured;
|
|
290
|
+
if (debugLogFilePath) return debugLogFilePath;
|
|
291
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
292
|
+
debugLogFilePath = pathJoin(tmpdir(), `pi-cursor-provider-debug-${stamp}-${process.pid}.log`);
|
|
293
|
+
return debugLogFilePath;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function debugLog(event: string, data?: Record<string, unknown>): void {
|
|
297
|
+
if (!isProxyDebugEnabled()) return;
|
|
298
|
+
const line = JSON.stringify({
|
|
299
|
+
ts: new Date().toISOString(),
|
|
300
|
+
pid: process.pid,
|
|
301
|
+
event,
|
|
302
|
+
...(data ? sanitizeForDebug(data) : {}),
|
|
303
|
+
});
|
|
304
|
+
const file = getDebugLogFilePath();
|
|
305
|
+
try {
|
|
306
|
+
appendFileSync(file, `${line}\n`, "utf8");
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error("[pi-cursor-provider] failed to write debug log", error);
|
|
309
|
+
console.error(`[pi-cursor-provider] ${line}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function nextDebugRequestId(): string {
|
|
314
|
+
debugRequestCounter += 1;
|
|
315
|
+
return `req-${debugRequestCounter}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export const __testInternals = {
|
|
319
|
+
activeBridges,
|
|
320
|
+
conversationStates,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export function setBridgeFactoryForTests(factory?: BridgeFactory): void {
|
|
324
|
+
bridgeFactory = factory ?? spawnBridge;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let proxyServer: ReturnType<typeof createServer> | undefined;
|
|
328
|
+
let proxyPort: number | undefined;
|
|
329
|
+
let proxyAccessTokenProvider: ((req?: IncomingMessage) => Promise<string>) | undefined;
|
|
330
|
+
|
|
331
|
+
// ── Bridge spawn ──
|
|
332
|
+
|
|
333
|
+
function frameConnectMessage(data: Uint8Array, flags = 0): Buffer {
|
|
334
|
+
const frame = Buffer.alloc(5 + data.length);
|
|
335
|
+
frame[0] = flags;
|
|
336
|
+
frame.writeUInt32BE(data.length, 1);
|
|
337
|
+
frame.set(data, 5);
|
|
338
|
+
return frame;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
interface SpawnBridgeOptions {
|
|
342
|
+
accessToken: string;
|
|
343
|
+
rpcPath: string;
|
|
344
|
+
url?: string;
|
|
345
|
+
unary?: boolean;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function spawnBridge(options: SpawnBridgeOptions): BridgeHandle {
|
|
349
|
+
debugLog("bridge.spawn", { rpcPath: options.rpcPath, url: options.url ?? CURSOR_API_URL, unary: options.unary ?? false });
|
|
350
|
+
const proc = spawn("node", [BRIDGE_PATH], {
|
|
351
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const config = JSON.stringify({
|
|
355
|
+
accessToken: options.accessToken,
|
|
356
|
+
url: options.url ?? CURSOR_API_URL,
|
|
357
|
+
path: options.rpcPath,
|
|
358
|
+
unary: options.unary ?? false,
|
|
359
|
+
});
|
|
360
|
+
// Built before the config frame goes out, so even the very first write is covered by the
|
|
361
|
+
// handle's error listeners rather than being able to throw at the host.
|
|
362
|
+
const handle = createBridgeHandle(proc as unknown as BridgeStreams, {
|
|
363
|
+
debug: (event, data) => debugLog(event, { rpcPath: options.rpcPath, ...data }),
|
|
364
|
+
});
|
|
365
|
+
handle.write(new TextEncoder().encode(config));
|
|
366
|
+
return handle;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── Unary RPC (for model discovery) ──
|
|
370
|
+
|
|
371
|
+
export async function callCursorUnaryRpc(options: {
|
|
372
|
+
accessToken: string;
|
|
373
|
+
rpcPath: string;
|
|
374
|
+
requestBody: Uint8Array;
|
|
375
|
+
url?: string;
|
|
376
|
+
timeoutMs?: number;
|
|
377
|
+
}): Promise<{ body: Uint8Array; exitCode: number; timedOut: boolean }> {
|
|
378
|
+
const bridge = bridgeFactory({
|
|
379
|
+
accessToken: options.accessToken,
|
|
380
|
+
rpcPath: options.rpcPath,
|
|
381
|
+
url: options.url,
|
|
382
|
+
unary: true,
|
|
383
|
+
});
|
|
384
|
+
const chunks: Buffer[] = [];
|
|
385
|
+
return new Promise((resolve) => {
|
|
386
|
+
let timedOut = false;
|
|
387
|
+
const timeoutMs = options.timeoutMs ?? 5_000;
|
|
388
|
+
const timeout = timeoutMs > 0
|
|
389
|
+
? setTimeout(() => {
|
|
390
|
+
timedOut = true;
|
|
391
|
+
try { bridge.proc.kill(); } catch {}
|
|
392
|
+
}, timeoutMs)
|
|
393
|
+
: undefined;
|
|
394
|
+
|
|
395
|
+
bridge.onData((chunk) => { chunks.push(Buffer.from(chunk)); });
|
|
396
|
+
bridge.onClose((exitCode) => {
|
|
397
|
+
if (timeout) clearTimeout(timeout);
|
|
398
|
+
resolve({ body: Buffer.concat(chunks), exitCode, timedOut });
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
bridge.write(options.requestBody);
|
|
402
|
+
bridge.end();
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Model discovery ──
|
|
407
|
+
|
|
408
|
+
export interface CursorModel {
|
|
409
|
+
id: string;
|
|
410
|
+
name: string;
|
|
411
|
+
reasoning: boolean;
|
|
412
|
+
contextWindow: number;
|
|
413
|
+
maxTokens: number;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let cachedModels: CursorModel[] | null = null;
|
|
417
|
+
|
|
418
|
+
export async function getCursorModels(apiKey: string): Promise<CursorModel[]> {
|
|
419
|
+
if (cachedModels) return cachedModels;
|
|
420
|
+
let failure = "unknown";
|
|
421
|
+
try {
|
|
422
|
+
const requestPayload = create(GetUsableModelsRequestSchema, {});
|
|
423
|
+
const requestBody = toBinary(GetUsableModelsRequestSchema, requestPayload);
|
|
424
|
+
const response = await callCursorUnaryRpc({
|
|
425
|
+
accessToken: apiKey,
|
|
426
|
+
rpcPath: "/agent.v1.AgentService/GetUsableModels",
|
|
427
|
+
requestBody,
|
|
428
|
+
// 5s is fine mid-session but not at process start, when the host is spawning
|
|
429
|
+
// bridges and probing every account at once — a cold h2-bridge plus TLS handshake
|
|
430
|
+
// routinely exceeds it there, and the caller mistook the empty result for an
|
|
431
|
+
// account with no models.
|
|
432
|
+
timeoutMs: 20_000,
|
|
433
|
+
});
|
|
434
|
+
if (!response.timedOut && response.exitCode === 0 && response.body.length > 0) {
|
|
435
|
+
let decoded: any = null;
|
|
436
|
+
try {
|
|
437
|
+
decoded = fromBinary(GetUsableModelsResponseSchema, response.body);
|
|
438
|
+
} catch {
|
|
439
|
+
// Try Connect framing
|
|
440
|
+
const body = decodeConnectUnaryBody(response.body);
|
|
441
|
+
if (body) {
|
|
442
|
+
try { decoded = fromBinary(GetUsableModelsResponseSchema, body); } catch {}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (decoded?.models?.length) {
|
|
446
|
+
const models = normalizeCursorModels(decoded.models);
|
|
447
|
+
if (models.length > 0) {
|
|
448
|
+
cachedModels = models;
|
|
449
|
+
return models;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
failure = `undecodable response (${response.body.length} bytes)`;
|
|
453
|
+
} else {
|
|
454
|
+
failure = `timedOut=${response.timedOut} exitCode=${response.exitCode} bytes=${response.body.length}`;
|
|
455
|
+
}
|
|
456
|
+
} catch (err) {
|
|
457
|
+
failure = err instanceof Error ? err.message : String(err);
|
|
458
|
+
console.error("[cursor-provider] Model discovery failed:", failure);
|
|
459
|
+
}
|
|
460
|
+
console.warn("[cursor-provider] Model discovery returned no models");
|
|
461
|
+
throw new Error(`GetUsableModels failed: ${failure}`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function decodeConnectUnaryBody(payload: Uint8Array): Uint8Array | null {
|
|
465
|
+
if (payload.length < 5) return null;
|
|
466
|
+
let offset = 0;
|
|
467
|
+
while (offset + 5 <= payload.length) {
|
|
468
|
+
const flags = payload[offset]!;
|
|
469
|
+
const view = new DataView(payload.buffer, payload.byteOffset + offset, payload.byteLength - offset);
|
|
470
|
+
const messageLength = view.getUint32(1, false);
|
|
471
|
+
const frameEnd = offset + 5 + messageLength;
|
|
472
|
+
if (frameEnd > payload.length) return null;
|
|
473
|
+
if ((flags & 0b0000_0001) !== 0) return null;
|
|
474
|
+
if ((flags & 0b0000_0010) === 0) return payload.subarray(offset + 5, frameEnd);
|
|
475
|
+
offset = frameEnd;
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function normalizeCursorModels(models: readonly unknown[]): CursorModel[] {
|
|
481
|
+
const byId = new Map<string, CursorModel>();
|
|
482
|
+
for (const model of models) {
|
|
483
|
+
const m = model as any;
|
|
484
|
+
const id = m?.modelId?.trim?.();
|
|
485
|
+
if (!id) continue;
|
|
486
|
+
const name = m.displayName || m.displayNameShort || m.displayModelId || id;
|
|
487
|
+
byId.set(id, {
|
|
488
|
+
id,
|
|
489
|
+
name,
|
|
490
|
+
reasoning: Boolean(m.thinkingDetails),
|
|
491
|
+
contextWindow: 200_000,
|
|
492
|
+
maxTokens: 64_000,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Proxy server ──
|
|
499
|
+
|
|
500
|
+
export function getProxyPort(): number | undefined {
|
|
501
|
+
return proxyPort;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function startProxy(
|
|
505
|
+
getAccessToken: (req?: IncomingMessage) => Promise<string>,
|
|
506
|
+
): Promise<number> {
|
|
507
|
+
proxyAccessTokenProvider = getAccessToken;
|
|
508
|
+
if (proxyServer && proxyPort) return proxyPort;
|
|
509
|
+
|
|
510
|
+
return new Promise((resolve, reject) => {
|
|
511
|
+
const server = createServer(async (req, res) => {
|
|
512
|
+
const url = new URL(req.url ?? "/", `http://localhost`);
|
|
513
|
+
const requestId = nextDebugRequestId();
|
|
514
|
+
// Same rule as the bridge pipe: a socket that dies under us is a logged event, never an
|
|
515
|
+
// uncaught exception in the host process.
|
|
516
|
+
res.on("error", (error) => debugLog("http.response_error", { requestId, error: String(error) }));
|
|
517
|
+
req.on("error", (error) => debugLog("http.request_error", { requestId, error: String(error) }));
|
|
518
|
+
debugLog("http.request", { requestId, method: req.method, pathname: url.pathname, headers: req.headers });
|
|
519
|
+
|
|
520
|
+
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
521
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
522
|
+
res.end(JSON.stringify({ object: "list", data: [] }));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
|
|
527
|
+
try {
|
|
528
|
+
const body = await readBody(req);
|
|
529
|
+
const parsed = JSON.parse(body) as ChatCompletionRequest;
|
|
530
|
+
debugLog("http.chat.body", { requestId, body: parsed });
|
|
531
|
+
if (!proxyAccessTokenProvider) throw new Error("No access token provider");
|
|
532
|
+
// The request is handed to the provider so a multi-account host can pick the
|
|
533
|
+
// account THIS request authenticated as. Single-account callers ignore it.
|
|
534
|
+
const accessToken = await proxyAccessTokenProvider(req);
|
|
535
|
+
await handleChatCompletion(parsed, accessToken, req, res, requestId);
|
|
536
|
+
} catch (err) {
|
|
537
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
538
|
+
debugLog("http.chat.error", { requestId, message, stack: err instanceof Error ? err.stack : undefined });
|
|
539
|
+
// Streaming may already have sent headers — writing them again throws from inside
|
|
540
|
+
// the catch, and that throw escapes as an unhandled rejection.
|
|
541
|
+
if (res.headersSent || res.writableEnded) {
|
|
542
|
+
if (!res.writableEnded) res.end();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
546
|
+
res.end(JSON.stringify({ error: { message, type: "server_error", code: "internal_error" } }));
|
|
547
|
+
}
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
res.writeHead(404);
|
|
552
|
+
res.end("Not Found");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
server.on("clientError", (error, socket) => {
|
|
556
|
+
debugLog("http.client_error", { error: String(error) });
|
|
557
|
+
socket.destroy();
|
|
558
|
+
});
|
|
559
|
+
server.on("error", (error) => debugLog("http.server_error", { error: String(error) }));
|
|
560
|
+
|
|
561
|
+
server.listen(0, "127.0.0.1", () => {
|
|
562
|
+
const addr = server.address();
|
|
563
|
+
if (typeof addr === "object" && addr) {
|
|
564
|
+
proxyPort = addr.port;
|
|
565
|
+
proxyServer = server;
|
|
566
|
+
// The listener is process-scoped, not session-scoped. Do not let an idle
|
|
567
|
+
// one-shot Pi process hang just because the proxy is ready; the OS will
|
|
568
|
+
// close it when the process exits. This also lets the listener survive
|
|
569
|
+
// `/new` and other in-process session changes.
|
|
570
|
+
server.unref();
|
|
571
|
+
debugLog("proxy.start", { port: proxyPort, debugLogFile: isProxyDebugEnabled() ? getDebugLogFilePath() : undefined });
|
|
572
|
+
resolve(proxyPort);
|
|
573
|
+
} else {
|
|
574
|
+
reject(new Error("Failed to bind proxy"));
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export function cleanupAllSessionState(): void {
|
|
581
|
+
debugLog("session.cleanup_all", { activeBridgeCount: activeBridges.size, conversationCount: conversationStates.size });
|
|
582
|
+
for (const [bridgeKey, active] of activeBridges) {
|
|
583
|
+
cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
|
|
584
|
+
}
|
|
585
|
+
clearConversationRegistry();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function stopProxy(): void {
|
|
589
|
+
debugLog("proxy.stop", { port: proxyPort });
|
|
590
|
+
if (proxyServer) {
|
|
591
|
+
proxyServer.close();
|
|
592
|
+
proxyServer = undefined;
|
|
593
|
+
proxyPort = undefined;
|
|
594
|
+
proxyAccessTokenProvider = undefined;
|
|
595
|
+
}
|
|
596
|
+
cleanupAllSessionState();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
600
|
+
return new Promise((resolve, reject) => {
|
|
601
|
+
const chunks: Buffer[] = [];
|
|
602
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
603
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
604
|
+
req.on("error", reject);
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── Request handling ──
|
|
609
|
+
|
|
610
|
+
export function evictStaleConversations(now = Date.now()): void {
|
|
611
|
+
for (const [key, stored] of conversationStates) {
|
|
612
|
+
if (!stored.sessionScoped && now - stored.lastAccessMs > CONVERSATION_TTL_MS) {
|
|
613
|
+
debugLog("conversation.evict", { key, stored, now });
|
|
614
|
+
conversationStates.delete(key);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Insert reasoning effort into model ID, before -fast/-thinking suffix.
|
|
621
|
+
* e.g. model="gpt-5.4" + effort="medium" → "gpt-5.4-medium"
|
|
622
|
+
* model="gpt-5.4-fast" + effort="high" → "gpt-5.4-high-fast"
|
|
623
|
+
* If no effort provided, returns model as-is.
|
|
624
|
+
*/
|
|
625
|
+
export function resolveModelId(model: string, reasoningEffort?: string): string {
|
|
626
|
+
if (!reasoningEffort) return model;
|
|
627
|
+
|
|
628
|
+
let suffix = "";
|
|
629
|
+
let base = model;
|
|
630
|
+
if (base.endsWith("-fast")) {
|
|
631
|
+
suffix = "-fast";
|
|
632
|
+
base = base.slice(0, -5);
|
|
633
|
+
} else if (base.endsWith("-thinking")) {
|
|
634
|
+
suffix = "-thinking";
|
|
635
|
+
base = base.slice(0, -9);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return `${base}-${reasoningEffort}${suffix}`;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Effort-folded ids ("cursor-grok-4.6") exist only in this provider's registry — Cursor's
|
|
643
|
+
* API knows the suffixed variants ("cursor-grok-4.6-medium"). A request without an effort
|
|
644
|
+
* (e.g. thinking off) would otherwise be sent under the bare folded id and die with
|
|
645
|
+
* `not_found`. Map it to the closest variant the account actually listed.
|
|
646
|
+
*/
|
|
647
|
+
const EFFORT_FALLBACK_ORDER = ["medium", "high", "low", "xhigh", "max", "none"];
|
|
648
|
+
|
|
649
|
+
export function resolveUsableModelId(modelId: string): string {
|
|
650
|
+
const known = cachedModels;
|
|
651
|
+
if (!known || known.length === 0) return modelId;
|
|
652
|
+
if (known.some((m) => m.id === modelId)) return modelId;
|
|
653
|
+
let suffix = "";
|
|
654
|
+
let base = modelId;
|
|
655
|
+
if (base.endsWith("-fast")) {
|
|
656
|
+
suffix = "-fast";
|
|
657
|
+
base = base.slice(0, -5);
|
|
658
|
+
} else if (base.endsWith("-thinking")) {
|
|
659
|
+
suffix = "-thinking";
|
|
660
|
+
base = base.slice(0, -9);
|
|
661
|
+
}
|
|
662
|
+
for (const effort of EFFORT_FALLBACK_ORDER) {
|
|
663
|
+
const candidate = `${base}-${effort}${suffix}`;
|
|
664
|
+
if (known.some((m) => m.id === candidate)) return candidate;
|
|
665
|
+
}
|
|
666
|
+
return modelId;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function handleChatCompletion(
|
|
670
|
+
body: ChatCompletionRequest,
|
|
671
|
+
accessToken: string,
|
|
672
|
+
req: IncomingMessage,
|
|
673
|
+
res: ServerResponse,
|
|
674
|
+
requestId: string,
|
|
675
|
+
): Promise<void> {
|
|
676
|
+
const parsed = parseMessages(body.messages);
|
|
677
|
+
const { systemPrompt, userText, turns, toolResults } = parsed;
|
|
678
|
+
const modelId = resolveUsableModelId(resolveModelId(body.model, body.reasoning_effort));
|
|
679
|
+
const tools = body.tools ?? [];
|
|
680
|
+
|
|
681
|
+
debugLog("chat.parsed_messages", {
|
|
682
|
+
requestId,
|
|
683
|
+
systemPrompt,
|
|
684
|
+
userText,
|
|
685
|
+
turns,
|
|
686
|
+
toolResults,
|
|
687
|
+
messageCount: body.messages.length,
|
|
688
|
+
model: body.model,
|
|
689
|
+
resolvedModelId: modelId,
|
|
690
|
+
stream: body.stream !== false,
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
if (!userText && toolResults.length === 0) {
|
|
694
|
+
debugLog("chat.no_user_message", { requestId, messages: body.messages });
|
|
695
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
696
|
+
res.end(JSON.stringify({ error: { message: "No user message found", type: "invalid_request_error" } }));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const sessionId = derivePiSessionId(body);
|
|
701
|
+
const bridgeKey = deriveBridgeKey(body.messages, sessionId);
|
|
702
|
+
const convKey = deriveConversationKey(body.messages, sessionId);
|
|
703
|
+
const activeBridge = activeBridges.get(bridgeKey);
|
|
704
|
+
debugLog("chat.session_keys", {
|
|
705
|
+
requestId,
|
|
706
|
+
sessionId,
|
|
707
|
+
bridgeKey,
|
|
708
|
+
convKey,
|
|
709
|
+
hasActiveBridge: !!activeBridge,
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const promptTokens = estimatePromptTokens(body.messages, body.tools);
|
|
713
|
+
|
|
714
|
+
if (activeBridge && toolResults.length > 0) {
|
|
715
|
+
debugLog("chat.resume_tool_results", { requestId, bridgeKey, toolResults, pendingExecs: activeBridge.pendingExecs });
|
|
716
|
+
activeBridges.delete(bridgeKey);
|
|
717
|
+
if (activeBridge.bridge.alive) {
|
|
718
|
+
handleToolResultResume(activeBridge, toolResults, modelId, bridgeKey, convKey, turns, req, res, body.stream !== false, requestId, promptTokens);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
cleanupBridge(activeBridge.bridge, activeBridge.heartbeatTimer, bridgeKey);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (activeBridge && activeBridges.has(bridgeKey)) {
|
|
725
|
+
cleanupBridge(activeBridge.bridge, activeBridge.heartbeatTimer, bridgeKey);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
let stored = conversationStates.get(convKey);
|
|
729
|
+
debugLog("chat.stored_state.before", { requestId, convKey, stored });
|
|
730
|
+
// Rotation moves the session between providers mid-conversation. While it is away, Cursor
|
|
731
|
+
// sees nothing — so a checkpoint that predates that excursion would resume a past with a
|
|
732
|
+
// hole in it. Detect the jump and start clean instead.
|
|
733
|
+
if (stored && isStaleForTranscript(stored, parsed.turns.length)) {
|
|
734
|
+
debugLog("conversation.stale_checkpoint", {
|
|
735
|
+
requestId,
|
|
736
|
+
convKey,
|
|
737
|
+
turnsCovered: stored.turnsCovered,
|
|
738
|
+
completedTurns: parsed.turns.length,
|
|
739
|
+
});
|
|
740
|
+
forgetConversation(convKey);
|
|
741
|
+
stored = undefined;
|
|
742
|
+
}
|
|
743
|
+
if (!stored) {
|
|
744
|
+
stored = {
|
|
745
|
+
conversationId: deterministicConversationId(convKey),
|
|
746
|
+
checkpoint: null,
|
|
747
|
+
|
|
748
|
+
sessionScoped: !!sessionId,
|
|
749
|
+
blobStore: new Map(),
|
|
750
|
+
lastAccessMs: Date.now(),
|
|
751
|
+
turnsCovered: parsed.turns.length,
|
|
752
|
+
};
|
|
753
|
+
conversationStates.set(convKey, stored);
|
|
754
|
+
}
|
|
755
|
+
stored.turnsCovered = parsed.turns.length;
|
|
756
|
+
stored.lastAccessMs = Date.now();
|
|
757
|
+
evictStaleConversations();
|
|
758
|
+
|
|
759
|
+
const mcpTools = buildMcpToolDefinitions(tools);
|
|
760
|
+
// Cursor's own conversation is not available here, so the whole session is replayed from
|
|
761
|
+
// Pi's transcript. The turn that is still in flight — the assistant's tool calls and the
|
|
762
|
+
// results that just came back — is part of that history: dropping it and re-sending the
|
|
763
|
+
// original question makes the model redo work it already did, and dropping the question and
|
|
764
|
+
// sending raw tool output leaves it with no task at all.
|
|
765
|
+
const rebuiltTurns = historyForRebuild(parsed);
|
|
766
|
+
const hasCheckpoint = !!stored.checkpoint;
|
|
767
|
+
const effectiveUserText = requestActionText(parsed, { hasCheckpoint });
|
|
768
|
+
const effectiveSystemPrompt = systemPromptForRebuild(systemPrompt, parsed, { hasCheckpoint });
|
|
769
|
+
if (!stored.checkpoint) {
|
|
770
|
+
debugLog("chat.no_checkpoint", { requestId, convKey, conversationId: stored.conversationId });
|
|
771
|
+
}
|
|
772
|
+
const payload = buildCursorRequest(
|
|
773
|
+
modelId, effectiveSystemPrompt, effectiveUserText, rebuiltTurns,
|
|
774
|
+
stored.conversationId, stored.checkpoint, stored.blobStore,
|
|
775
|
+
);
|
|
776
|
+
debugLog("chat.cursor_request", {
|
|
777
|
+
requestId,
|
|
778
|
+
conversationId: stored.conversationId,
|
|
779
|
+
effectiveUserText,
|
|
780
|
+
restoredSystemPromptChars: effectiveSystemPrompt.length - systemPrompt.length,
|
|
781
|
+
turnCount: turns.length,
|
|
782
|
+
hasCheckpoint,
|
|
783
|
+
payload,
|
|
784
|
+
});
|
|
785
|
+
payload.mcpTools = mcpTools;
|
|
786
|
+
|
|
787
|
+
const currentTurn: ParsedTurn = {
|
|
788
|
+
userText: effectiveUserText,
|
|
789
|
+
steps: [],
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
if (body.stream === false) {
|
|
793
|
+
debugLog("chat.dispatch_nonstream", { requestId, convKey });
|
|
794
|
+
await handleNonStreamingResponse(payload, accessToken, modelId, convKey, turns, currentTurn, req, res, requestId, promptTokens);
|
|
795
|
+
} else {
|
|
796
|
+
debugLog("chat.dispatch_stream", { requestId, bridgeKey, convKey });
|
|
797
|
+
handleStreamingResponse(payload, accessToken, modelId, bridgeKey, convKey, turns, currentTurn, req, res, requestId, promptTokens);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ── Message parsing ──
|
|
802
|
+
|
|
803
|
+
function getTurnToolCallResults(turn: ParsedTurn): Map<string, ParsedToolResult> {
|
|
804
|
+
const results = new Map<string, ParsedToolResult>();
|
|
805
|
+
for (const step of turn.steps) {
|
|
806
|
+
if (step.kind === "toolCall" && step.result) results.set(step.toolCallId, step.result);
|
|
807
|
+
}
|
|
808
|
+
return results;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function appendAssistantTextToTurn(turn: ParsedTurn, text: string): void {
|
|
812
|
+
if (!text) return;
|
|
813
|
+
const last = turn.steps.at(-1);
|
|
814
|
+
if (last?.kind === "assistantText") {
|
|
815
|
+
last.text += text;
|
|
816
|
+
} else {
|
|
817
|
+
turn.steps.push({ kind: "assistantText", text });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export function parseMessages(messages: OpenAIMessage[]): ParsedMessages {
|
|
822
|
+
return parseMessagesPure(messages, debugLog);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ── Tool definitions ──
|
|
826
|
+
|
|
827
|
+
function buildMcpToolDefinitions(tools: OpenAIToolDef[]): McpToolDefinition[] {
|
|
828
|
+
return tools.map((t) => {
|
|
829
|
+
const fn = t.function;
|
|
830
|
+
const jsonSchema: JsonValue = fn.parameters && typeof fn.parameters === "object"
|
|
831
|
+
? (fn.parameters as JsonValue)
|
|
832
|
+
: { type: "object", properties: {}, required: [] };
|
|
833
|
+
const inputSchema = toBinary(ValueSchema, fromJson(ValueSchema, jsonSchema));
|
|
834
|
+
return create(McpToolDefinitionSchema, {
|
|
835
|
+
name: fn.name,
|
|
836
|
+
description: fn.description || "",
|
|
837
|
+
providerIdentifier: "pi",
|
|
838
|
+
toolName: fn.name,
|
|
839
|
+
inputSchema,
|
|
840
|
+
});
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function decodeMcpArgValue(value: Uint8Array): unknown {
|
|
845
|
+
try {
|
|
846
|
+
const parsed = fromBinary(ValueSchema, value);
|
|
847
|
+
return toJson(ValueSchema, parsed);
|
|
848
|
+
} catch {}
|
|
849
|
+
return new TextDecoder().decode(value);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function decodeMcpArgsMap(args: Record<string, Uint8Array>): Record<string, unknown> {
|
|
853
|
+
const decoded: Record<string, unknown> = {};
|
|
854
|
+
for (const [key, value] of Object.entries(args)) decoded[key] = decodeMcpArgValue(value);
|
|
855
|
+
return decoded;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── Build Cursor protobuf request ──
|
|
859
|
+
|
|
860
|
+
function encodeMcpArgValue(value: unknown): Uint8Array {
|
|
861
|
+
try {
|
|
862
|
+
return toBinary(ValueSchema, fromJson(ValueSchema, value as JsonValue));
|
|
863
|
+
} catch {
|
|
864
|
+
return new TextEncoder().encode(String(value));
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function encodeMcpArgsMap(args: Record<string, unknown>): Record<string, Uint8Array> {
|
|
869
|
+
const encoded: Record<string, Uint8Array> = {};
|
|
870
|
+
for (const [key, value] of Object.entries(args)) encoded[key] = encodeMcpArgValue(value);
|
|
871
|
+
return encoded;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// No generated schema for selectedContextBlob; emit raw wire format for the two
|
|
875
|
+
// fields Cursor actually reads: field 1 (repeated bytes) rootPromptMessagesJson
|
|
876
|
+
// refs, field 22 (string) clientName. blobId.length < 128 (SHA256 = 32 bytes).
|
|
877
|
+
function buildSelectedContextBlob(rootPromptBlobIds: Uint8Array[], clientName: string): Uint8Array {
|
|
878
|
+
const parts: Uint8Array[] = [];
|
|
879
|
+
for (const blobId of rootPromptBlobIds) {
|
|
880
|
+
parts.push(new Uint8Array([0x0A, blobId.length, ...blobId]));
|
|
881
|
+
}
|
|
882
|
+
const clientBytes = new TextEncoder().encode(clientName);
|
|
883
|
+
parts.push(new Uint8Array([0xB2, 0x01, clientBytes.length, ...clientBytes]));
|
|
884
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
885
|
+
const result = new Uint8Array(total);
|
|
886
|
+
let offset = 0;
|
|
887
|
+
for (const p of parts) { result.set(p, offset); offset += p.length; }
|
|
888
|
+
return result;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function storeAsBlob(data: Uint8Array, blobStore: Map<string, Uint8Array>): Uint8Array {
|
|
892
|
+
const id = new Uint8Array(createHash("sha256").update(data).digest());
|
|
893
|
+
blobStore.set(Buffer.from(id).toString("hex"), data);
|
|
894
|
+
return id;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function createUserMessage(text: string, selectedContextBlob: Uint8Array): UserMessage {
|
|
898
|
+
const messageId = crypto.randomUUID();
|
|
899
|
+
return create(UserMessageSchema, {
|
|
900
|
+
text,
|
|
901
|
+
messageId,
|
|
902
|
+
selectedContext: create(SelectedContextSchema, {}),
|
|
903
|
+
mode: 1,
|
|
904
|
+
selectedContextBlob,
|
|
905
|
+
correlationId: messageId,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function buildTurnStepBytes(step: ParsedTurnStep): Uint8Array {
|
|
910
|
+
if (step.kind === "assistantText") {
|
|
911
|
+
return toBinary(ConversationStepSchema, create(ConversationStepSchema, {
|
|
912
|
+
message: {
|
|
913
|
+
case: "assistantMessage",
|
|
914
|
+
value: create(AssistantMessageSchema, { text: step.text }),
|
|
915
|
+
},
|
|
916
|
+
}));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const toolName = step.toolName || "tool";
|
|
920
|
+
const mcpToolCall = create(McpToolCallSchema, {
|
|
921
|
+
args: create(McpArgsSchema, {
|
|
922
|
+
name: toolName,
|
|
923
|
+
args: encodeMcpArgsMap(step.arguments),
|
|
924
|
+
toolCallId: step.toolCallId,
|
|
925
|
+
providerIdentifier: "pi",
|
|
926
|
+
toolName,
|
|
927
|
+
}),
|
|
928
|
+
...(step.result && {
|
|
929
|
+
result: create(McpResultSchema, {
|
|
930
|
+
result: step.result.isError
|
|
931
|
+
? {
|
|
932
|
+
case: "error",
|
|
933
|
+
value: create(McpErrorSchema, { error: step.result.content }),
|
|
934
|
+
}
|
|
935
|
+
: {
|
|
936
|
+
case: "success",
|
|
937
|
+
value: create(McpSuccessSchema, {
|
|
938
|
+
content: [
|
|
939
|
+
create(McpToolResultContentItemSchema, {
|
|
940
|
+
content: {
|
|
941
|
+
case: "text",
|
|
942
|
+
value: create(McpTextContentSchema, { text: step.result.content }),
|
|
943
|
+
},
|
|
944
|
+
}),
|
|
945
|
+
],
|
|
946
|
+
isError: false,
|
|
947
|
+
}),
|
|
948
|
+
},
|
|
949
|
+
}),
|
|
950
|
+
}),
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
return toBinary(ConversationStepSchema, create(ConversationStepSchema, {
|
|
954
|
+
message: {
|
|
955
|
+
case: "toolCall",
|
|
956
|
+
value: create(ToolCallSchema, {
|
|
957
|
+
tool: {
|
|
958
|
+
case: "mcpToolCall",
|
|
959
|
+
value: mcpToolCall,
|
|
960
|
+
},
|
|
961
|
+
}),
|
|
962
|
+
},
|
|
963
|
+
}));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
export function buildCursorRequest(
|
|
967
|
+
modelId: string,
|
|
968
|
+
systemPrompt: string,
|
|
969
|
+
userText: string,
|
|
970
|
+
turns: ParsedTurn[],
|
|
971
|
+
conversationId: string,
|
|
972
|
+
checkpoint: Uint8Array | null,
|
|
973
|
+
existingBlobStore?: Map<string, Uint8Array>,
|
|
974
|
+
): CursorRequestPayload {
|
|
975
|
+
debugLog("cursor_request.build.start", {
|
|
976
|
+
modelId,
|
|
977
|
+
systemPrompt,
|
|
978
|
+
userText,
|
|
979
|
+
turns,
|
|
980
|
+
conversationId,
|
|
981
|
+
checkpoint,
|
|
982
|
+
existingBlobStore,
|
|
983
|
+
});
|
|
984
|
+
const blobStore = new Map<string, Uint8Array>(existingBlobStore ?? []);
|
|
985
|
+
|
|
986
|
+
const systemBytes = new TextEncoder().encode(JSON.stringify({ role: "system", content: systemPrompt }));
|
|
987
|
+
const systemBlobId = storeAsBlob(systemBytes, blobStore);
|
|
988
|
+
const selectedCtxBlob = storeAsBlob(
|
|
989
|
+
buildSelectedContextBlob([systemBlobId], "pi"), blobStore,
|
|
990
|
+
);
|
|
991
|
+
|
|
992
|
+
let conversationState;
|
|
993
|
+
if (checkpoint) {
|
|
994
|
+
conversationState = fromBinary(ConversationStateStructureSchema, checkpoint);
|
|
995
|
+
} else {
|
|
996
|
+
const turnBlobIds: Uint8Array[] = [];
|
|
997
|
+
for (const turn of turns) {
|
|
998
|
+
const userMsg = createUserMessage(turn.userText, selectedCtxBlob);
|
|
999
|
+
const userMsgBlobId = storeAsBlob(toBinary(UserMessageSchema, userMsg), blobStore);
|
|
1000
|
+
const stepBlobIds = turn.steps.map(s => storeAsBlob(buildTurnStepBytes(s), blobStore));
|
|
1001
|
+
|
|
1002
|
+
const agentTurn = create(AgentConversationTurnStructureSchema, {
|
|
1003
|
+
userMessage: userMsgBlobId,
|
|
1004
|
+
steps: stepBlobIds,
|
|
1005
|
+
requestId: crypto.randomUUID(),
|
|
1006
|
+
});
|
|
1007
|
+
const turnStructure = create(ConversationTurnStructureSchema, {
|
|
1008
|
+
turn: { case: "agentConversationTurn", value: agentTurn },
|
|
1009
|
+
});
|
|
1010
|
+
turnBlobIds.push(storeAsBlob(toBinary(ConversationTurnStructureSchema, turnStructure), blobStore));
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
conversationState = create(ConversationStateStructureSchema, {
|
|
1014
|
+
rootPromptMessagesJson: [systemBlobId],
|
|
1015
|
+
turns: turnBlobIds,
|
|
1016
|
+
todos: [],
|
|
1017
|
+
pendingToolCalls: [],
|
|
1018
|
+
previousWorkspaceUris: [`file://${process.cwd()}`],
|
|
1019
|
+
mode: 1,
|
|
1020
|
+
fileStates: {},
|
|
1021
|
+
fileStatesV2: {},
|
|
1022
|
+
summaryArchives: [],
|
|
1023
|
+
turnTimings: [],
|
|
1024
|
+
subagentStates: {},
|
|
1025
|
+
selfSummaryCount: 0,
|
|
1026
|
+
readPaths: [],
|
|
1027
|
+
clientName: "pi",
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const userMessage = createUserMessage(userText, selectedCtxBlob);
|
|
1032
|
+
const action = create(ConversationActionSchema, {
|
|
1033
|
+
action: { case: "userMessageAction", value: create(UserMessageActionSchema, { userMessage }) },
|
|
1034
|
+
});
|
|
1035
|
+
const modelDetails = create(ModelDetailsSchema, { modelId, displayModelId: modelId, displayName: modelId });
|
|
1036
|
+
const runRequest = create(AgentRunRequestSchema, { conversationState, action, modelDetails, conversationId });
|
|
1037
|
+
const clientMessage = create(AgentClientMessageSchema, {
|
|
1038
|
+
message: { case: "runRequest", value: runRequest },
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
const payload = { requestBytes: toBinary(AgentClientMessageSchema, clientMessage), blobStore, mcpTools: [] };
|
|
1042
|
+
debugLog("cursor_request.build.end", payload);
|
|
1043
|
+
return payload;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ── Server message processing ──
|
|
1047
|
+
|
|
1048
|
+
function processServerMessage(
|
|
1049
|
+
msg: AgentServerMessage,
|
|
1050
|
+
blobStore: Map<string, Uint8Array>,
|
|
1051
|
+
mcpTools: McpToolDefinition[],
|
|
1052
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1053
|
+
state: StreamState,
|
|
1054
|
+
onText: (text: string, isThinking?: boolean) => void,
|
|
1055
|
+
onMcpExec: (exec: PendingExec) => void,
|
|
1056
|
+
onCheckpoint?: (checkpointBytes: Uint8Array) => void,
|
|
1057
|
+
onTurnEnded?: () => void,
|
|
1058
|
+
): CursorFrameClass {
|
|
1059
|
+
const msgCase = msg.message.case;
|
|
1060
|
+
const updateCase = msgCase === "interactionUpdate" ? (msg.message.value as { message?: { case?: string } }).message?.case : undefined;
|
|
1061
|
+
const execCase = msgCase === "execServerMessage" ? (msg.message.value as { message?: { case?: string } }).message?.case : undefined;
|
|
1062
|
+
let classified = classifyCursorFrame({ messageCase: msgCase, updateCase, execCase });
|
|
1063
|
+
debugLog("server_message", { msgCase, updateCase, execCase, classified });
|
|
1064
|
+
|
|
1065
|
+
if (msgCase === "interactionUpdate") {
|
|
1066
|
+
const update = msg.message.value as any;
|
|
1067
|
+
if (updateCase === "textDelta") {
|
|
1068
|
+
const delta = update.message.value.text || "";
|
|
1069
|
+
if (delta) onText(delta, false);
|
|
1070
|
+
} else if (updateCase === "thinkingDelta") {
|
|
1071
|
+
const delta = update.message.value.text || "";
|
|
1072
|
+
if (delta) onText(delta, true);
|
|
1073
|
+
} else if (updateCase === "tokenDelta") {
|
|
1074
|
+
state.outputTokens += update.message.value.tokens ?? 0;
|
|
1075
|
+
} else if (classified.completesTurn) {
|
|
1076
|
+
onTurnEnded?.();
|
|
1077
|
+
}
|
|
1078
|
+
} else if (msgCase === "kvServerMessage") {
|
|
1079
|
+
handleKvMessage(msg.message.value as KvServerMessage, blobStore, sendFrame);
|
|
1080
|
+
} else if (msgCase === "execServerMessage") {
|
|
1081
|
+
if (handleExecMessage(msg.message.value as ExecServerMessage, mcpTools, sendFrame, onMcpExec)) {
|
|
1082
|
+
classified = { kind: execCase === "mcpArgs" ? "mcpExec" : "nativeExec", countsAsProgress: true, completesTurn: false };
|
|
1083
|
+
}
|
|
1084
|
+
} else if (msgCase === "conversationCheckpointUpdate") {
|
|
1085
|
+
const stateStructure = msg.message.value as ConversationStateStructure;
|
|
1086
|
+
if ((stateStructure as any).tokenDetails) {
|
|
1087
|
+
state.totalTokens = (stateStructure as any).tokenDetails.usedTokens;
|
|
1088
|
+
}
|
|
1089
|
+
if (onCheckpoint) {
|
|
1090
|
+
onCheckpoint(toBinary(ConversationStateStructureSchema, stateStructure));
|
|
1091
|
+
}
|
|
1092
|
+
} else if (msgCase === "interactionQuery") {
|
|
1093
|
+
handleInteractionQuery(msg.message.value as InteractionQuery, sendFrame);
|
|
1094
|
+
} else if (msgCase === "execServerControlMessage") {
|
|
1095
|
+
debugLog("exec.control", { control: (msg.message.value as { message?: { case?: string } }).message?.case });
|
|
1096
|
+
}
|
|
1097
|
+
return classified;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function sendKvResponse(
|
|
1101
|
+
kvMsg: KvServerMessage,
|
|
1102
|
+
messageCase: string,
|
|
1103
|
+
value: unknown,
|
|
1104
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1105
|
+
): void {
|
|
1106
|
+
const response = create(KvClientMessageSchema, {
|
|
1107
|
+
id: (kvMsg as any).id,
|
|
1108
|
+
message: { case: messageCase as any, value: value as any },
|
|
1109
|
+
});
|
|
1110
|
+
const clientMsg = create(AgentClientMessageSchema, {
|
|
1111
|
+
message: { case: "kvClientMessage", value: response },
|
|
1112
|
+
});
|
|
1113
|
+
sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMsg)));
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function handleKvMessage(
|
|
1117
|
+
kvMsg: KvServerMessage,
|
|
1118
|
+
blobStore: Map<string, Uint8Array>,
|
|
1119
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1120
|
+
): void {
|
|
1121
|
+
const kvCase = (kvMsg as any).message.case;
|
|
1122
|
+
if (kvCase === "getBlobArgs") {
|
|
1123
|
+
const blobId = (kvMsg as any).message.value.blobId;
|
|
1124
|
+
const blobIdKey = Buffer.from(blobId).toString("hex");
|
|
1125
|
+
const blobData = blobStore.get(blobIdKey);
|
|
1126
|
+
// A blob we do not hold is answered with an empty result and no error, so a hole in the
|
|
1127
|
+
// conversation looks exactly like a healthy one. Never let that be silent again.
|
|
1128
|
+
if (!blobData) {
|
|
1129
|
+
debugLog("kv.blob_miss", { blobIdKey, knownBlobs: blobStore.size });
|
|
1130
|
+
}
|
|
1131
|
+
sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, blobData ? { blobData } : {}), sendFrame);
|
|
1132
|
+
} else if (kvCase === "setBlobArgs") {
|
|
1133
|
+
const { blobId, blobData } = (kvMsg as any).message.value;
|
|
1134
|
+
blobStore.set(Buffer.from(blobId).toString("hex"), blobData);
|
|
1135
|
+
sendKvResponse(kvMsg, "setBlobResult", create(SetBlobResultSchema, {}), sendFrame);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function piToolNames(mcpTools: McpToolDefinition[]): Set<string> {
|
|
1140
|
+
const names = new Set<string>();
|
|
1141
|
+
for (const tool of mcpTools) {
|
|
1142
|
+
if (tool.name) names.add(tool.name);
|
|
1143
|
+
if (tool.toolName) names.add(tool.toolName);
|
|
1144
|
+
}
|
|
1145
|
+
return names;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function handleInteractionQuery(query: InteractionQuery, sendFrame: (data: Uint8Array) => void): void {
|
|
1149
|
+
const queryCase = query.query.case;
|
|
1150
|
+
const reason = NATIVE_TOOL_UNAVAILABLE;
|
|
1151
|
+
let result: InteractionResponse["result"];
|
|
1152
|
+
switch (queryCase) {
|
|
1153
|
+
case "webSearchRequestQuery":
|
|
1154
|
+
result = {
|
|
1155
|
+
case: "webSearchRequestResponse",
|
|
1156
|
+
value: create(WebSearchRequestResponseSchema, {
|
|
1157
|
+
result: { case: "rejected", value: create(WebSearchRequestResponse_RejectedSchema, { reason }) },
|
|
1158
|
+
}),
|
|
1159
|
+
};
|
|
1160
|
+
break;
|
|
1161
|
+
case "askQuestionInteractionQuery":
|
|
1162
|
+
result = {
|
|
1163
|
+
case: "askQuestionInteractionResponse",
|
|
1164
|
+
value: create(AskQuestionInteractionResponseSchema, {
|
|
1165
|
+
result: create(AskQuestionResultSchema, {
|
|
1166
|
+
result: { case: "rejected", value: create(AskQuestionRejectedSchema, { reason }) },
|
|
1167
|
+
}),
|
|
1168
|
+
}),
|
|
1169
|
+
};
|
|
1170
|
+
break;
|
|
1171
|
+
case "switchModeRequestQuery":
|
|
1172
|
+
result = {
|
|
1173
|
+
case: "switchModeRequestResponse",
|
|
1174
|
+
value: create(SwitchModeRequestResponseSchema, {
|
|
1175
|
+
result: { case: "rejected", value: create(SwitchModeRequestResponse_RejectedSchema, { reason }) },
|
|
1176
|
+
}),
|
|
1177
|
+
};
|
|
1178
|
+
break;
|
|
1179
|
+
case "exaSearchRequestQuery":
|
|
1180
|
+
result = {
|
|
1181
|
+
case: "exaSearchRequestResponse",
|
|
1182
|
+
value: create(ExaSearchRequestResponseSchema, {
|
|
1183
|
+
result: { case: "rejected", value: create(ExaSearchRequestResponse_RejectedSchema, { reason }) },
|
|
1184
|
+
}),
|
|
1185
|
+
};
|
|
1186
|
+
break;
|
|
1187
|
+
case "exaFetchRequestQuery":
|
|
1188
|
+
result = {
|
|
1189
|
+
case: "exaFetchRequestResponse",
|
|
1190
|
+
value: create(ExaFetchRequestResponseSchema, {
|
|
1191
|
+
result: { case: "rejected", value: create(ExaFetchRequestResponse_RejectedSchema, { reason }) },
|
|
1192
|
+
}),
|
|
1193
|
+
};
|
|
1194
|
+
break;
|
|
1195
|
+
case "createPlanRequestQuery":
|
|
1196
|
+
result = {
|
|
1197
|
+
case: "createPlanRequestResponse",
|
|
1198
|
+
value: create(CreatePlanRequestResponseSchema, {
|
|
1199
|
+
result: create(CreatePlanResultSchema, {
|
|
1200
|
+
planUri: "",
|
|
1201
|
+
result: { case: "error", value: create(CreatePlanErrorSchema, { error: reason }) },
|
|
1202
|
+
}),
|
|
1203
|
+
}),
|
|
1204
|
+
};
|
|
1205
|
+
break;
|
|
1206
|
+
case "setupVmEnvironmentArgs":
|
|
1207
|
+
result = {
|
|
1208
|
+
case: "setupVmEnvironmentResult",
|
|
1209
|
+
value: create(SetupVmEnvironmentResultSchema, {
|
|
1210
|
+
result: { case: "success", value: create(SetupVmEnvironmentSuccessSchema, {}) },
|
|
1211
|
+
}),
|
|
1212
|
+
};
|
|
1213
|
+
break;
|
|
1214
|
+
case undefined:
|
|
1215
|
+
debugLog("interaction_query.unhandled", { queryCase });
|
|
1216
|
+
return;
|
|
1217
|
+
default: {
|
|
1218
|
+
const _never: never = queryCase;
|
|
1219
|
+
debugLog("interaction_query.unhandled", { queryCase: _never });
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
const response = create(InteractionResponseSchema, { id: query.id, result });
|
|
1224
|
+
sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, create(AgentClientMessageSchema, {
|
|
1225
|
+
message: { case: "interactionResponse", value: response },
|
|
1226
|
+
}))));
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function handleExecMessage(
|
|
1230
|
+
execMsg: ExecServerMessage,
|
|
1231
|
+
mcpTools: McpToolDefinition[],
|
|
1232
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1233
|
+
onMcpExec: (exec: PendingExec) => void,
|
|
1234
|
+
): boolean {
|
|
1235
|
+
const execCase = (execMsg as any).message.case;
|
|
1236
|
+
const REJECT_REASON = NATIVE_TOOL_UNAVAILABLE;
|
|
1237
|
+
|
|
1238
|
+
if (execCase === "requestContextArgs") {
|
|
1239
|
+
const requestContext = create(RequestContextSchema, {
|
|
1240
|
+
rules: [], repositoryInfo: [], tools: mcpTools, gitRepos: [],
|
|
1241
|
+
projectLayouts: [], mcpInstructions: [], fileContents: {}, customSubagents: [],
|
|
1242
|
+
});
|
|
1243
|
+
const result = create(RequestContextResultSchema, {
|
|
1244
|
+
result: { case: "success", value: create(RequestContextSuccessSchema, { requestContext }) },
|
|
1245
|
+
});
|
|
1246
|
+
sendExecResult(execMsg, "requestContextResult", result, sendFrame);
|
|
1247
|
+
return false;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (execCase === "mcpArgs") {
|
|
1251
|
+
const mcpArgs = (execMsg as any).message.value;
|
|
1252
|
+
const decoded = decodeMcpArgsMap(mcpArgs.args ?? {});
|
|
1253
|
+
onMcpExec({
|
|
1254
|
+
execId: (execMsg as any).execId,
|
|
1255
|
+
execMsgId: (execMsg as any).id,
|
|
1256
|
+
toolCallId: mcpArgs.toolCallId || crypto.randomUUID(),
|
|
1257
|
+
toolName: mcpArgs.toolName || mcpArgs.name,
|
|
1258
|
+
decodedArgs: JSON.stringify(decoded),
|
|
1259
|
+
resultCase: "mcpResult",
|
|
1260
|
+
});
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const nativeArgs = protoRecord((execMsg as any).message.value);
|
|
1265
|
+
const mapped = mapNativeExecToPiTool(String(execCase ?? ""), nativeArgs, piToolNames(mcpTools));
|
|
1266
|
+
if (mapped) {
|
|
1267
|
+
onMcpExec({
|
|
1268
|
+
execId: (execMsg as any).execId,
|
|
1269
|
+
execMsgId: (execMsg as any).id,
|
|
1270
|
+
toolCallId: typeof nativeArgs.toolCallId === "string" && nativeArgs.toolCallId
|
|
1271
|
+
? nativeArgs.toolCallId
|
|
1272
|
+
: crypto.randomUUID(),
|
|
1273
|
+
toolName: mapped.toolName,
|
|
1274
|
+
decodedArgs: JSON.stringify(mapped.args),
|
|
1275
|
+
resultCase: mapped.resultCase,
|
|
1276
|
+
nativeArgs,
|
|
1277
|
+
});
|
|
1278
|
+
return true;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (execCase === "readArgs") {
|
|
1282
|
+
const args = (execMsg as any).message.value;
|
|
1283
|
+
sendExecResult(execMsg, "readResult", create(ReadResultSchema, {
|
|
1284
|
+
result: { case: "rejected", value: create(ReadRejectedSchema, { path: args.path, reason: REJECT_REASON }) },
|
|
1285
|
+
}), sendFrame);
|
|
1286
|
+
return false;
|
|
1287
|
+
}
|
|
1288
|
+
if (execCase === "lsArgs") {
|
|
1289
|
+
const args = (execMsg as any).message.value;
|
|
1290
|
+
sendExecResult(execMsg, "lsResult", create(LsResultSchema, {
|
|
1291
|
+
result: { case: "rejected", value: create(LsRejectedSchema, { path: args.path, reason: REJECT_REASON }) },
|
|
1292
|
+
}), sendFrame);
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
if (execCase === "grepArgs") {
|
|
1296
|
+
sendExecResult(execMsg, "grepResult", create(GrepResultSchema, {
|
|
1297
|
+
result: { case: "error", value: create(GrepErrorSchema, { error: REJECT_REASON }) },
|
|
1298
|
+
}), sendFrame);
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
if (execCase === "writeArgs") {
|
|
1302
|
+
const args = (execMsg as any).message.value;
|
|
1303
|
+
sendExecResult(execMsg, "writeResult", create(WriteResultSchema, {
|
|
1304
|
+
result: { case: "rejected", value: create(WriteRejectedSchema, { path: args.path, reason: REJECT_REASON }) },
|
|
1305
|
+
}), sendFrame);
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
if (execCase === "deleteArgs") {
|
|
1309
|
+
const args = (execMsg as any).message.value;
|
|
1310
|
+
sendExecResult(execMsg, "deleteResult", create(DeleteResultSchema, {
|
|
1311
|
+
result: { case: "rejected", value: create(DeleteRejectedSchema, { path: args.path, reason: REJECT_REASON }) },
|
|
1312
|
+
}), sendFrame);
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
if (execCase === "shellArgs") {
|
|
1316
|
+
const args = (execMsg as any).message.value;
|
|
1317
|
+
sendExecResult(execMsg, "shellResult", create(ShellResultSchema, {
|
|
1318
|
+
result: { case: "rejected", value: create(ShellRejectedSchema, {
|
|
1319
|
+
command: args.command ?? "", workingDirectory: args.workingDirectory ?? "",
|
|
1320
|
+
reason: REJECT_REASON, isReadonly: false,
|
|
1321
|
+
}) },
|
|
1322
|
+
}), sendFrame);
|
|
1323
|
+
return false;
|
|
1324
|
+
}
|
|
1325
|
+
if (execCase === "shellStreamArgs") {
|
|
1326
|
+
const args = (execMsg as any).message.value;
|
|
1327
|
+
sendExecResult(execMsg, "shellStream", create(ShellStreamSchema, {
|
|
1328
|
+
event: { case: "rejected", value: create(ShellRejectedSchema, {
|
|
1329
|
+
command: args.command ?? "", workingDirectory: args.workingDirectory ?? "",
|
|
1330
|
+
reason: REJECT_REASON, isReadonly: false,
|
|
1331
|
+
}) },
|
|
1332
|
+
}), sendFrame);
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
if (execCase === "backgroundShellSpawnArgs") {
|
|
1336
|
+
const args = (execMsg as any).message.value;
|
|
1337
|
+
sendExecResult(execMsg, "backgroundShellSpawnResult", create(BackgroundShellSpawnResultSchema, {
|
|
1338
|
+
result: { case: "rejected", value: create(ShellRejectedSchema, {
|
|
1339
|
+
command: args.command ?? "", workingDirectory: args.workingDirectory ?? "",
|
|
1340
|
+
reason: REJECT_REASON, isReadonly: false,
|
|
1341
|
+
}) },
|
|
1342
|
+
}), sendFrame);
|
|
1343
|
+
return false;
|
|
1344
|
+
}
|
|
1345
|
+
if (execCase === "writeShellStdinArgs") {
|
|
1346
|
+
sendExecResult(execMsg, "writeShellStdinResult", create(WriteShellStdinResultSchema, {
|
|
1347
|
+
result: { case: "error", value: create(WriteShellStdinErrorSchema, { error: REJECT_REASON }) },
|
|
1348
|
+
}), sendFrame);
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
if (execCase === "fetchArgs") {
|
|
1352
|
+
const args = (execMsg as any).message.value;
|
|
1353
|
+
sendExecResult(execMsg, "fetchResult", create(FetchResultSchema, {
|
|
1354
|
+
result: { case: "error", value: create(FetchErrorSchema, { url: args.url ?? "", error: REJECT_REASON }) },
|
|
1355
|
+
}), sendFrame);
|
|
1356
|
+
return false;
|
|
1357
|
+
}
|
|
1358
|
+
if (execCase === "diagnosticsArgs") {
|
|
1359
|
+
sendExecResult(execMsg, "diagnosticsResult", create(DiagnosticsResultSchema, {}), sendFrame);
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
const miscCaseMap: Record<string, string> = {
|
|
1364
|
+
listMcpResourcesExecArgs: "listMcpResourcesExecResult",
|
|
1365
|
+
readMcpResourceExecArgs: "readMcpResourceExecResult",
|
|
1366
|
+
recordScreenArgs: "recordScreenResult",
|
|
1367
|
+
computerUseArgs: "computerUseResult",
|
|
1368
|
+
};
|
|
1369
|
+
const resultCase = miscCaseMap[execCase as string];
|
|
1370
|
+
if (resultCase) {
|
|
1371
|
+
sendExecResult(execMsg, resultCase, create(McpResultSchema, {}), sendFrame);
|
|
1372
|
+
return false;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
throwUnhandledExec(execMsg, execCase, sendFrame);
|
|
1376
|
+
return false;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function throwUnhandledExec(
|
|
1380
|
+
execMsg: ExecServerMessage,
|
|
1381
|
+
execCase: unknown,
|
|
1382
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1383
|
+
): void {
|
|
1384
|
+
console.error(`[cursor-provider] UNHANDLED exec case: "${String(execCase)}". Rejecting so the bridge cannot stall.`);
|
|
1385
|
+
const control = create(ExecClientControlMessageSchema, {
|
|
1386
|
+
message: {
|
|
1387
|
+
case: "throw",
|
|
1388
|
+
value: create(ExecClientThrowSchema, {
|
|
1389
|
+
id: (execMsg as any).id,
|
|
1390
|
+
error: `${NATIVE_TOOL_UNAVAILABLE} (unhandled ${String(execCase ?? "exec")})`,
|
|
1391
|
+
}),
|
|
1392
|
+
},
|
|
1393
|
+
});
|
|
1394
|
+
sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, create(AgentClientMessageSchema, {
|
|
1395
|
+
message: { case: "execClientControlMessage", value: control },
|
|
1396
|
+
}))));
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function sendExecResult(
|
|
1400
|
+
execMsg: ExecServerMessage,
|
|
1401
|
+
messageCase: string,
|
|
1402
|
+
value: unknown,
|
|
1403
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1404
|
+
): void {
|
|
1405
|
+
const execClientMessage = create(ExecClientMessageSchema, {
|
|
1406
|
+
id: (execMsg as any).id,
|
|
1407
|
+
execId: (execMsg as any).execId,
|
|
1408
|
+
message: { case: messageCase as any, value: value as any },
|
|
1409
|
+
});
|
|
1410
|
+
const clientMessage = create(AgentClientMessageSchema, {
|
|
1411
|
+
message: { case: "execClientMessage", value: execClientMessage },
|
|
1412
|
+
});
|
|
1413
|
+
sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
|
|
1414
|
+
if (messageCase === "shellStream") {
|
|
1415
|
+
const event = (value as ShellStream).event.case;
|
|
1416
|
+
if (event === "exit" || event === "rejected" || event === "permissionDenied" || event === "backgrounded") {
|
|
1417
|
+
// The exit event describes the process, not the end of the streaming exec
|
|
1418
|
+
// RPC. Without streamClose Cursor keeps draining that RPC forever while
|
|
1419
|
+
// Run heartbeats continue, so Pi stalls after an already completed bash.
|
|
1420
|
+
sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, create(AgentClientMessageSchema, {
|
|
1421
|
+
message: {
|
|
1422
|
+
case: "execClientControlMessage",
|
|
1423
|
+
value: create(ExecClientControlMessageSchema, {
|
|
1424
|
+
message: { case: "streamClose", value: create(ExecClientStreamCloseSchema, { id: execMsg.id }) },
|
|
1425
|
+
}),
|
|
1426
|
+
},
|
|
1427
|
+
}))));
|
|
1428
|
+
debugLog("exec.stream_closed", { execMsgId: execMsg.id, execId: execMsg.execId, terminalEvent: event });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function sendPendingExecResult(
|
|
1434
|
+
exec: PendingExec,
|
|
1435
|
+
value: unknown,
|
|
1436
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1437
|
+
): void {
|
|
1438
|
+
sendExecResult(
|
|
1439
|
+
{ id: exec.execMsgId, execId: exec.execId } as ExecServerMessage,
|
|
1440
|
+
exec.resultCase ?? "mcpResult",
|
|
1441
|
+
value,
|
|
1442
|
+
sendFrame,
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function protoRecord(value: unknown): Record<string, unknown> {
|
|
1447
|
+
if (!value || typeof value !== "object") return {};
|
|
1448
|
+
return { ...(value as Record<string, unknown>) };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function nativePath(exec: PendingExec): string {
|
|
1452
|
+
const path = exec.nativeArgs?.path;
|
|
1453
|
+
return typeof path === "string" ? path : "";
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
function resumePendingExecWithToolResult(
|
|
1457
|
+
exec: PendingExec,
|
|
1458
|
+
content: string,
|
|
1459
|
+
isError: boolean,
|
|
1460
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1461
|
+
): void {
|
|
1462
|
+
try {
|
|
1463
|
+
resumePendingExecWithToolResultUnchecked(exec, content, isError, sendFrame);
|
|
1464
|
+
} catch (err) {
|
|
1465
|
+
console.error("[cursor-provider] Failed to encode tool result; throwing so Cursor cannot wait forever:", err instanceof Error ? err.message : err);
|
|
1466
|
+
throwUnhandledExec(
|
|
1467
|
+
{ id: exec.execMsgId, execId: exec.execId } as ExecServerMessage,
|
|
1468
|
+
exec.resultCase,
|
|
1469
|
+
sendFrame,
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function resumePendingExecWithToolResultUnchecked(
|
|
1475
|
+
exec: PendingExec,
|
|
1476
|
+
content: string,
|
|
1477
|
+
isError: boolean,
|
|
1478
|
+
sendFrame: (data: Uint8Array) => void,
|
|
1479
|
+
): void {
|
|
1480
|
+
const resultCase = exec.resultCase ?? "mcpResult";
|
|
1481
|
+
if (resultCase === "mcpResult") {
|
|
1482
|
+
sendPendingExecResult(exec, create(McpResultSchema, {
|
|
1483
|
+
result: {
|
|
1484
|
+
case: "success",
|
|
1485
|
+
value: create(McpSuccessSchema, {
|
|
1486
|
+
content: [
|
|
1487
|
+
create(McpToolResultContentItemSchema, {
|
|
1488
|
+
content: { case: "text", value: create(McpTextContentSchema, { text: content }) },
|
|
1489
|
+
}),
|
|
1490
|
+
],
|
|
1491
|
+
isError,
|
|
1492
|
+
}),
|
|
1493
|
+
},
|
|
1494
|
+
}), sendFrame);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (resultCase === "readResult") {
|
|
1498
|
+
const path = nativePath(exec);
|
|
1499
|
+
sendPendingExecResult(exec, create(ReadResultSchema, isError
|
|
1500
|
+
? { result: { case: "error", value: create(ReadErrorSchema, { path, error: content }) } }
|
|
1501
|
+
: {
|
|
1502
|
+
result: {
|
|
1503
|
+
case: "success",
|
|
1504
|
+
value: create(ReadSuccessSchema, {
|
|
1505
|
+
path,
|
|
1506
|
+
totalLines: content.split("\n").length,
|
|
1507
|
+
fileSize: BigInt(Buffer.byteLength(content)),
|
|
1508
|
+
truncated: false,
|
|
1509
|
+
output: { case: "content", value: content },
|
|
1510
|
+
}),
|
|
1511
|
+
},
|
|
1512
|
+
}), sendFrame);
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
if (resultCase === "writeResult") {
|
|
1516
|
+
const path = nativePath(exec);
|
|
1517
|
+
sendPendingExecResult(exec, create(WriteResultSchema, isError
|
|
1518
|
+
? { result: { case: "error", value: create(WriteErrorSchema, { path, error: content }) } }
|
|
1519
|
+
: {
|
|
1520
|
+
result: {
|
|
1521
|
+
case: "success",
|
|
1522
|
+
value: create(WriteSuccessSchema, {
|
|
1523
|
+
path,
|
|
1524
|
+
linesCreated: content.split("\n").length,
|
|
1525
|
+
fileSize: Buffer.byteLength(content),
|
|
1526
|
+
fileContentAfterWrite: content,
|
|
1527
|
+
}),
|
|
1528
|
+
},
|
|
1529
|
+
}), sendFrame);
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (resultCase === "deleteResult") {
|
|
1533
|
+
const path = nativePath(exec);
|
|
1534
|
+
sendPendingExecResult(exec, create(DeleteResultSchema, isError
|
|
1535
|
+
? { result: { case: "error", value: create(DeleteErrorSchema, { path, error: content }) } }
|
|
1536
|
+
: {
|
|
1537
|
+
result: {
|
|
1538
|
+
case: "success",
|
|
1539
|
+
value: create(DeleteSuccessSchema, {
|
|
1540
|
+
path,
|
|
1541
|
+
deletedFile: path,
|
|
1542
|
+
fileSize: 0n,
|
|
1543
|
+
prevContent: "",
|
|
1544
|
+
}),
|
|
1545
|
+
},
|
|
1546
|
+
}), sendFrame);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
if (resultCase === "grepResult") {
|
|
1550
|
+
const pattern = typeof exec.nativeArgs?.pattern === "string" ? exec.nativeArgs.pattern : "";
|
|
1551
|
+
const path = nativePath(exec) || ".";
|
|
1552
|
+
sendPendingExecResult(exec, create(GrepResultSchema, isError
|
|
1553
|
+
? { result: { case: "error", value: create(GrepErrorSchema, { error: content }) } }
|
|
1554
|
+
: {
|
|
1555
|
+
result: {
|
|
1556
|
+
case: "success",
|
|
1557
|
+
value: create(GrepSuccessSchema, {
|
|
1558
|
+
pattern,
|
|
1559
|
+
path,
|
|
1560
|
+
outputMode: "content",
|
|
1561
|
+
workspaceResults: {
|
|
1562
|
+
[path]: create(GrepUnionResultSchema, {
|
|
1563
|
+
result: {
|
|
1564
|
+
case: "content",
|
|
1565
|
+
value: create(GrepContentResultSchema, {
|
|
1566
|
+
matches: [create(GrepFileMatchSchema, {
|
|
1567
|
+
file: path,
|
|
1568
|
+
matches: content.split("\n").slice(0, 200).map((line, index) => create(GrepContentMatchSchema, {
|
|
1569
|
+
lineNumber: index + 1,
|
|
1570
|
+
content: line,
|
|
1571
|
+
contentTruncated: false,
|
|
1572
|
+
})),
|
|
1573
|
+
})],
|
|
1574
|
+
totalLines: content.split("\n").length,
|
|
1575
|
+
totalMatchedLines: content.split("\n").length,
|
|
1576
|
+
clientTruncated: content.split("\n").length > 200,
|
|
1577
|
+
ripgrepTruncated: false,
|
|
1578
|
+
}),
|
|
1579
|
+
},
|
|
1580
|
+
}),
|
|
1581
|
+
},
|
|
1582
|
+
}),
|
|
1583
|
+
},
|
|
1584
|
+
}), sendFrame);
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
if (resultCase === "lsResult") {
|
|
1588
|
+
const path = nativePath(exec) || ".";
|
|
1589
|
+
const files = content.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 500);
|
|
1590
|
+
sendPendingExecResult(exec, create(LsResultSchema, isError
|
|
1591
|
+
? { result: { case: "error", value: create(LsErrorSchema, { path, error: content }) } }
|
|
1592
|
+
: {
|
|
1593
|
+
result: {
|
|
1594
|
+
case: "success",
|
|
1595
|
+
value: create(LsSuccessSchema, {
|
|
1596
|
+
directoryTreeRoot: create(LsDirectoryTreeNodeSchema, {
|
|
1597
|
+
absPath: path,
|
|
1598
|
+
childrenDirs: [],
|
|
1599
|
+
childrenFiles: files.map((name) => create(LsDirectoryTreeNode_FileSchema, { name })),
|
|
1600
|
+
childrenWereProcessed: true,
|
|
1601
|
+
fullSubtreeExtensionCounts: {},
|
|
1602
|
+
numFiles: files.length,
|
|
1603
|
+
}),
|
|
1604
|
+
}),
|
|
1605
|
+
},
|
|
1606
|
+
}), sendFrame);
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
if (resultCase === "shellResult") {
|
|
1610
|
+
const command = typeof exec.nativeArgs?.command === "string" ? exec.nativeArgs.command : "";
|
|
1611
|
+
const workingDirectory = typeof exec.nativeArgs?.workingDirectory === "string" ? exec.nativeArgs.workingDirectory : "";
|
|
1612
|
+
sendPendingExecResult(exec, create(ShellResultSchema, {
|
|
1613
|
+
result: {
|
|
1614
|
+
case: "success",
|
|
1615
|
+
value: create(ShellSuccessSchema, {
|
|
1616
|
+
command,
|
|
1617
|
+
workingDirectory,
|
|
1618
|
+
exitCode: isError ? 1 : 0,
|
|
1619
|
+
signal: "",
|
|
1620
|
+
stdout: isError ? "" : content,
|
|
1621
|
+
stderr: isError ? content : "",
|
|
1622
|
+
executionTime: 0,
|
|
1623
|
+
}),
|
|
1624
|
+
},
|
|
1625
|
+
}), sendFrame);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
if (resultCase === "shellStream") {
|
|
1629
|
+
const cwd = typeof exec.nativeArgs?.workingDirectory === "string" ? exec.nativeArgs.workingDirectory : "";
|
|
1630
|
+
sendPendingExecResult(exec, create(ShellStreamSchema, {
|
|
1631
|
+
event: { case: "stdout", value: create(ShellStreamStdoutSchema, { data: content }) },
|
|
1632
|
+
}), sendFrame);
|
|
1633
|
+
sendPendingExecResult(exec, create(ShellStreamSchema, {
|
|
1634
|
+
event: {
|
|
1635
|
+
case: "exit",
|
|
1636
|
+
value: create(ShellStreamExitSchema, { code: isError ? 1 : 0, cwd, aborted: false }),
|
|
1637
|
+
},
|
|
1638
|
+
}), sendFrame);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
sendPendingExecResult(exec, create(McpResultSchema, {
|
|
1642
|
+
result: {
|
|
1643
|
+
case: "success",
|
|
1644
|
+
value: create(McpSuccessSchema, {
|
|
1645
|
+
content: [
|
|
1646
|
+
create(McpToolResultContentItemSchema, {
|
|
1647
|
+
content: { case: "text", value: create(McpTextContentSchema, { text: content }) },
|
|
1648
|
+
}),
|
|
1649
|
+
],
|
|
1650
|
+
isError,
|
|
1651
|
+
}),
|
|
1652
|
+
},
|
|
1653
|
+
}), sendFrame);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
|
|
1657
|
+
// ── Key derivation ──
|
|
1658
|
+
|
|
1659
|
+
export function derivePiSessionId(body: Pick<ChatCompletionRequest, "pi_session_id" | "user">): string | undefined {
|
|
1660
|
+
const raw = body.pi_session_id ?? body.user;
|
|
1661
|
+
if (typeof raw !== "string") return undefined;
|
|
1662
|
+
const trimmed = raw.trim();
|
|
1663
|
+
return trimmed ? trimmed : undefined;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
export { deriveBridgeKeyFromSessionId, deriveConversationKeyFromSessionId, deterministicConversationId };
|
|
1667
|
+
|
|
1668
|
+
export function deriveBridgeKey(messages: OpenAIMessage[], sessionId?: string): string {
|
|
1669
|
+
if (sessionId) return deriveBridgeKeyFromSessionId(sessionId);
|
|
1670
|
+
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1671
|
+
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1672
|
+
return createHash("sha256").update(`bridge:${firstUserText.slice(0, 200)}`).digest("hex").slice(0, 16);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
export function deriveConversationKey(messages: OpenAIMessage[], sessionId?: string): string {
|
|
1676
|
+
if (sessionId) return deriveConversationKeyFromSessionId(sessionId);
|
|
1677
|
+
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
1678
|
+
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
1679
|
+
return createHash("sha256").update(`conv:${firstUserText.slice(0, 200)}`).digest("hex").slice(0, 16);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
export function cleanupSessionState(sessionId?: string): void {
|
|
1683
|
+
if (!sessionId) return;
|
|
1684
|
+
const bridgeKey = deriveBridgeKeyFromSessionId(sessionId);
|
|
1685
|
+
const convKey = deriveConversationKeyFromSessionId(sessionId);
|
|
1686
|
+
const active = activeBridges.get(bridgeKey);
|
|
1687
|
+
debugLog("session.cleanup", { sessionId, bridgeKey, convKey, hasActiveBridge: !!active, hadConversation: conversationStates.has(convKey) });
|
|
1688
|
+
if (active) cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
|
|
1689
|
+
dropConversation(convKey);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* Pi just compacted this session. Pi now holds a short summary, but Cursor is still sitting on
|
|
1694
|
+
* the full pre-compaction conversation behind its checkpoint — and will keep answering from it,
|
|
1695
|
+
* and keep reporting its size, as if nothing was compacted. Drop the checkpoint and move to a
|
|
1696
|
+
* fresh conversation id so the next request is rebuilt from Pi's compacted turns.
|
|
1697
|
+
*
|
|
1698
|
+
* The in-flight bridge has to go too: a pending tool result would otherwise resume the very
|
|
1699
|
+
* conversation we are trying to leave behind.
|
|
1700
|
+
*/
|
|
1701
|
+
export function resetConversationForSession(sessionId?: string): void {
|
|
1702
|
+
if (!sessionId) return;
|
|
1703
|
+
const bridgeKey = deriveBridgeKeyFromSessionId(sessionId);
|
|
1704
|
+
const convKey = deriveConversationKeyFromSessionId(sessionId);
|
|
1705
|
+
const active = activeBridges.get(bridgeKey);
|
|
1706
|
+
const hadConversation = forgetConversation(convKey);
|
|
1707
|
+
debugLog("conversation.reset_after_compaction", {
|
|
1708
|
+
sessionId,
|
|
1709
|
+
bridgeKey,
|
|
1710
|
+
convKey,
|
|
1711
|
+
hadConversation,
|
|
1712
|
+
hasActiveBridge: !!active,
|
|
1713
|
+
nextConversationId: deterministicConversationId(convKey),
|
|
1714
|
+
});
|
|
1715
|
+
if (active) cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
// ── Thinking tag filter ──
|
|
1719
|
+
|
|
1720
|
+
const THINKING_TAG_NAMES = ['think', 'thinking', 'reasoning', 'thought', 'think_intent'];
|
|
1721
|
+
const MAX_THINKING_TAG_LEN = 16;
|
|
1722
|
+
|
|
1723
|
+
function createThinkingTagFilter() {
|
|
1724
|
+
let buffer = '';
|
|
1725
|
+
let inThinking = false;
|
|
1726
|
+
return {
|
|
1727
|
+
process(text: string) {
|
|
1728
|
+
const input = buffer + text;
|
|
1729
|
+
buffer = '';
|
|
1730
|
+
let content = '';
|
|
1731
|
+
let reasoning = '';
|
|
1732
|
+
let lastIdx = 0;
|
|
1733
|
+
const re = new RegExp(`<(/?)(?:${THINKING_TAG_NAMES.join('|')})\\s*>`, 'gi');
|
|
1734
|
+
let match: RegExpExecArray | null;
|
|
1735
|
+
while ((match = re.exec(input)) !== null) {
|
|
1736
|
+
const before = input.slice(lastIdx, match.index);
|
|
1737
|
+
if (inThinking) reasoning += before; else content += before;
|
|
1738
|
+
inThinking = match[1] !== '/';
|
|
1739
|
+
lastIdx = re.lastIndex;
|
|
1740
|
+
}
|
|
1741
|
+
const rest = input.slice(lastIdx);
|
|
1742
|
+
const ltPos = rest.lastIndexOf('<');
|
|
1743
|
+
if (ltPos >= 0 && rest.length - ltPos < MAX_THINKING_TAG_LEN && /^<\/?[a-z_]*$/i.test(rest.slice(ltPos))) {
|
|
1744
|
+
buffer = rest.slice(ltPos);
|
|
1745
|
+
const before = rest.slice(0, ltPos);
|
|
1746
|
+
if (inThinking) reasoning += before; else content += before;
|
|
1747
|
+
} else {
|
|
1748
|
+
if (inThinking) reasoning += rest; else content += rest;
|
|
1749
|
+
}
|
|
1750
|
+
return { content, reasoning };
|
|
1751
|
+
},
|
|
1752
|
+
flush() {
|
|
1753
|
+
const b = buffer;
|
|
1754
|
+
buffer = '';
|
|
1755
|
+
if (!b) return { content: '', reasoning: '' };
|
|
1756
|
+
return inThinking ? { content: '', reasoning: b } : { content: b, reasoning: '' };
|
|
1757
|
+
},
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
// ── Connect frame parser ──
|
|
1762
|
+
|
|
1763
|
+
function createConnectFrameParser(
|
|
1764
|
+
onMessage: (bytes: Uint8Array) => void,
|
|
1765
|
+
onEndStream: (bytes: Uint8Array) => void,
|
|
1766
|
+
): (incoming: Buffer) => void {
|
|
1767
|
+
let pending = Buffer.alloc(0);
|
|
1768
|
+
return (incoming: Buffer) => {
|
|
1769
|
+
pending = Buffer.concat([pending, incoming]);
|
|
1770
|
+
while (pending.length >= 5) {
|
|
1771
|
+
const flags = pending[0]!;
|
|
1772
|
+
const msgLen = pending.readUInt32BE(1);
|
|
1773
|
+
if (pending.length < 5 + msgLen) break;
|
|
1774
|
+
const messageBytes = pending.subarray(5, 5 + msgLen);
|
|
1775
|
+
pending = pending.subarray(5 + msgLen);
|
|
1776
|
+
if (flags & CONNECT_END_STREAM_FLAG) onEndStream(messageBytes);
|
|
1777
|
+
else onMessage(messageBytes);
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function parseConnectEndStream(data: Uint8Array): Error | null {
|
|
1783
|
+
try {
|
|
1784
|
+
const payload = JSON.parse(new TextDecoder().decode(data));
|
|
1785
|
+
const error = payload?.error;
|
|
1786
|
+
if (error) return new Error(`Connect error ${error.code ?? "unknown"}: ${error.message ?? "Unknown error"}`);
|
|
1787
|
+
return null;
|
|
1788
|
+
} catch {
|
|
1789
|
+
return new Error("Failed to parse Connect end stream");
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function makeHeartbeatBytes(): Uint8Array {
|
|
1794
|
+
const heartbeat = create(AgentClientMessageSchema, {
|
|
1795
|
+
message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) },
|
|
1796
|
+
});
|
|
1797
|
+
return frameConnectMessage(toBinary(AgentClientMessageSchema, heartbeat));
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function computeUsage(state: StreamState) {
|
|
1801
|
+
return resolveCursorUsage(state);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
function respondWithPendingToolCalls(
|
|
1805
|
+
modelId: string,
|
|
1806
|
+
pendingExecs: PendingExec[],
|
|
1807
|
+
stream: boolean,
|
|
1808
|
+
res: ServerResponse,
|
|
1809
|
+
promptTokenEstimate = 0,
|
|
1810
|
+
): void {
|
|
1811
|
+
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1812
|
+
const created = Math.floor(Date.now() / 1000);
|
|
1813
|
+
const usage = resolveCursorUsage({
|
|
1814
|
+
outputTokens: 0,
|
|
1815
|
+
totalTokens: 0,
|
|
1816
|
+
promptTokenEstimate,
|
|
1817
|
+
});
|
|
1818
|
+
const toolCalls = pendingExecs.map((exec, index) => ({
|
|
1819
|
+
index,
|
|
1820
|
+
id: exec.toolCallId,
|
|
1821
|
+
type: "function" as const,
|
|
1822
|
+
function: { name: exec.toolName, arguments: exec.decodedArgs },
|
|
1823
|
+
}));
|
|
1824
|
+
|
|
1825
|
+
if (stream) {
|
|
1826
|
+
res.writeHead(200, {
|
|
1827
|
+
"Content-Type": "text/event-stream",
|
|
1828
|
+
"Cache-Control": "no-cache",
|
|
1829
|
+
"Connection": "keep-alive",
|
|
1830
|
+
});
|
|
1831
|
+
for (const toolCall of toolCalls) {
|
|
1832
|
+
res.write(`data: ${JSON.stringify({
|
|
1833
|
+
id: completionId,
|
|
1834
|
+
object: "chat.completion.chunk",
|
|
1835
|
+
created,
|
|
1836
|
+
model: modelId,
|
|
1837
|
+
choices: [{ index: 0, delta: { tool_calls: [toolCall] }, finish_reason: null }],
|
|
1838
|
+
})}\n\n`);
|
|
1839
|
+
}
|
|
1840
|
+
res.write(`data: ${JSON.stringify({
|
|
1841
|
+
id: completionId,
|
|
1842
|
+
object: "chat.completion.chunk",
|
|
1843
|
+
created,
|
|
1844
|
+
model: modelId,
|
|
1845
|
+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
|
|
1846
|
+
})}\n\n`);
|
|
1847
|
+
res.write(`data: ${JSON.stringify({
|
|
1848
|
+
id: completionId,
|
|
1849
|
+
object: "chat.completion.chunk",
|
|
1850
|
+
created,
|
|
1851
|
+
model: modelId,
|
|
1852
|
+
choices: [],
|
|
1853
|
+
usage,
|
|
1854
|
+
})}\n\n`);
|
|
1855
|
+
res.write("data: [DONE]\n\n");
|
|
1856
|
+
res.end();
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1861
|
+
res.end(JSON.stringify({
|
|
1862
|
+
id: completionId,
|
|
1863
|
+
object: "chat.completion",
|
|
1864
|
+
created,
|
|
1865
|
+
model: modelId,
|
|
1866
|
+
choices: [{
|
|
1867
|
+
index: 0,
|
|
1868
|
+
message: { role: "assistant", content: null, tool_calls: toolCalls },
|
|
1869
|
+
finish_reason: "tool_calls",
|
|
1870
|
+
}],
|
|
1871
|
+
usage,
|
|
1872
|
+
}));
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// ── Streaming response ──
|
|
1876
|
+
|
|
1877
|
+
function startBridge(accessToken: string, requestBytes: Uint8Array) {
|
|
1878
|
+
const bridge = bridgeFactory({ accessToken, rpcPath: "/agent.v1.AgentService/Run" });
|
|
1879
|
+
debugLog("bridge.start_run", { requestBytes });
|
|
1880
|
+
bridge.write(frameConnectMessage(requestBytes));
|
|
1881
|
+
const heartbeatTimer = setInterval(() => bridge.write(makeHeartbeatBytes()), 5_000);
|
|
1882
|
+
return { bridge, heartbeatTimer };
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function handleStreamingResponse(
|
|
1886
|
+
payload: CursorRequestPayload,
|
|
1887
|
+
accessToken: string,
|
|
1888
|
+
modelId: string,
|
|
1889
|
+
bridgeKey: string,
|
|
1890
|
+
convKey: string,
|
|
1891
|
+
completedTurns: ParsedTurn[],
|
|
1892
|
+
currentTurn: ParsedTurn,
|
|
1893
|
+
req: IncomingMessage,
|
|
1894
|
+
res: ServerResponse,
|
|
1895
|
+
requestId: string,
|
|
1896
|
+
promptTokenEstimate = 0,
|
|
1897
|
+
): void {
|
|
1898
|
+
debugLog("stream.start", { requestId, bridgeKey, convKey, modelId });
|
|
1899
|
+
const { bridge, heartbeatTimer } = startBridge(accessToken, payload.requestBytes);
|
|
1900
|
+
writeSSEStream(
|
|
1901
|
+
bridge,
|
|
1902
|
+
heartbeatTimer,
|
|
1903
|
+
payload.blobStore,
|
|
1904
|
+
payload.mcpTools,
|
|
1905
|
+
modelId,
|
|
1906
|
+
bridgeKey,
|
|
1907
|
+
convKey,
|
|
1908
|
+
completedTurns,
|
|
1909
|
+
currentTurn,
|
|
1910
|
+
req,
|
|
1911
|
+
res,
|
|
1912
|
+
requestId,
|
|
1913
|
+
promptTokenEstimate,
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function sendCancelAction(
|
|
1918
|
+
bridge: BridgeHandle,
|
|
1919
|
+
): void {
|
|
1920
|
+
debugLog("bridge.cancel_action", {});
|
|
1921
|
+
const action = create(ConversationActionSchema, {
|
|
1922
|
+
action: { case: "cancelAction", value: create(CancelActionSchema, {}) },
|
|
1923
|
+
});
|
|
1924
|
+
const clientMessage = create(AgentClientMessageSchema, {
|
|
1925
|
+
message: { case: "conversationAction", value: action },
|
|
1926
|
+
});
|
|
1927
|
+
bridge.write(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
function cleanupBridge(
|
|
1931
|
+
bridge: BridgeHandle,
|
|
1932
|
+
heartbeatTimer: ReturnType<typeof setInterval>,
|
|
1933
|
+
bridgeKey: string,
|
|
1934
|
+
): void {
|
|
1935
|
+
debugLog("bridge.cleanup", { bridgeKey, alive: bridge.alive });
|
|
1936
|
+
clearInterval(heartbeatTimer);
|
|
1937
|
+
if (bridge.alive) sendCancelAction(bridge);
|
|
1938
|
+
// Ending stdin alone does not terminate a streaming HTTP/2 request: Cursor may
|
|
1939
|
+
// keep the response side open and the bridge process alive indefinitely. Once
|
|
1940
|
+
// this Run is cancelled or stale, kill the subprocess so dead connections do
|
|
1941
|
+
// not accumulate across stalls, compactions, and session switches.
|
|
1942
|
+
bridge.destroy();
|
|
1943
|
+
activeBridges.delete(bridgeKey);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function writeSSEStream(
|
|
1947
|
+
bridge: BridgeHandle,
|
|
1948
|
+
heartbeatTimer: ReturnType<typeof setInterval>,
|
|
1949
|
+
blobStore: Map<string, Uint8Array>,
|
|
1950
|
+
mcpTools: McpToolDefinition[],
|
|
1951
|
+
modelId: string,
|
|
1952
|
+
bridgeKey: string,
|
|
1953
|
+
convKey: string,
|
|
1954
|
+
completedTurns: ParsedTurn[],
|
|
1955
|
+
currentTurn: ParsedTurn,
|
|
1956
|
+
req: IncomingMessage,
|
|
1957
|
+
res: ServerResponse,
|
|
1958
|
+
requestId?: string,
|
|
1959
|
+
promptTokenEstimate = 0,
|
|
1960
|
+
): void {
|
|
1961
|
+
debugLog("stream.writer_start", { requestId, bridgeKey, convKey, modelId, completedTurnCount: completedTurns.length, currentTurn });
|
|
1962
|
+
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
1963
|
+
const created = Math.floor(Date.now() / 1000);
|
|
1964
|
+
|
|
1965
|
+
const stopKeepalive = startSSEResponse(res);
|
|
1966
|
+
|
|
1967
|
+
let closed = false;
|
|
1968
|
+
let coalesceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1969
|
+
const clearCoalesceTimer = () => {
|
|
1970
|
+
if (!coalesceTimer) return;
|
|
1971
|
+
clearTimeout(coalesceTimer);
|
|
1972
|
+
coalesceTimer = undefined;
|
|
1973
|
+
};
|
|
1974
|
+
const sendSSE = (data: object) => {
|
|
1975
|
+
if (closed) return;
|
|
1976
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
1977
|
+
};
|
|
1978
|
+
const sendDone = () => {
|
|
1979
|
+
if (closed) return;
|
|
1980
|
+
res.write("data: [DONE]\n\n");
|
|
1981
|
+
};
|
|
1982
|
+
const closeResponse = () => {
|
|
1983
|
+
if (closed) return;
|
|
1984
|
+
closed = true;
|
|
1985
|
+
clearCoalesceTimer();
|
|
1986
|
+
stopKeepalive();
|
|
1987
|
+
transportWatchdog.stop();
|
|
1988
|
+
upstreamWatchdog.stop();
|
|
1989
|
+
res.end();
|
|
1990
|
+
};
|
|
1991
|
+
|
|
1992
|
+
const makeChunk = (delta: Record<string, unknown>, finishReason: string | null = null) => ({
|
|
1993
|
+
id: completionId, object: "chat.completion.chunk", created, model: modelId,
|
|
1994
|
+
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
|
1995
|
+
});
|
|
1996
|
+
|
|
1997
|
+
const failStalledRun = (
|
|
1998
|
+
kind: "transport" | "useful_output",
|
|
1999
|
+
silentForMs: number,
|
|
2000
|
+
) => {
|
|
2001
|
+
if (closed) return;
|
|
2002
|
+
const missing = kind === "transport" ? "upstream frames" : "useful output";
|
|
2003
|
+
const message = `Cursor Run stalled: no ${missing} for ${formatStallDuration(silentForMs)}; stream timed out`;
|
|
2004
|
+
console.error(`[cursor-provider] ${kind} stall (${modelId}):`, message);
|
|
2005
|
+
debugLog("stream.upstream_stall", { requestId, bridgeKey, convKey, modelId, kind, silentForMs });
|
|
2006
|
+
cancelled = true;
|
|
2007
|
+
if (!toolCallsFlushed) {
|
|
2008
|
+
for (const exec of state.pendingExecs) {
|
|
2009
|
+
resumePendingExecWithToolResult(exec, message, true, (data) => bridge.write(data));
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
cleanupBridge(bridge, heartbeatTimer, bridgeKey);
|
|
2013
|
+
sendSSE(makeChunk({ content: message }, "error"));
|
|
2014
|
+
sendSSE(makeUsageChunk());
|
|
2015
|
+
sendDone();
|
|
2016
|
+
closeResponse();
|
|
2017
|
+
};
|
|
2018
|
+
|
|
2019
|
+
// A decoded housekeeping frame proves the HTTP/2 transport is alive, but only
|
|
2020
|
+
// visible tokens or a Pi-bound tool prove the model is making useful progress.
|
|
2021
|
+
const transportWatchdog = startUpstreamWatchdog(
|
|
2022
|
+
(silentForMs) => failStalledRun("transport", silentForMs),
|
|
2023
|
+
resolveTransportStallTimeoutMs(),
|
|
2024
|
+
);
|
|
2025
|
+
const upstreamWatchdog = startUpstreamWatchdog(
|
|
2026
|
+
(silentForMs) => failStalledRun("useful_output", silentForMs),
|
|
2027
|
+
resolveUpstreamStallTimeoutMs(),
|
|
2028
|
+
);
|
|
2029
|
+
|
|
2030
|
+
const makeUsageChunk = () => {
|
|
2031
|
+
const { prompt_tokens, completion_tokens, total_tokens } = computeUsage(state);
|
|
2032
|
+
return {
|
|
2033
|
+
id: completionId, object: "chat.completion.chunk", created, model: modelId,
|
|
2034
|
+
choices: [],
|
|
2035
|
+
usage: { prompt_tokens, completion_tokens, total_tokens },
|
|
2036
|
+
};
|
|
2037
|
+
};
|
|
2038
|
+
|
|
2039
|
+
const state: StreamState = { toolCallIndex: 0, pendingExecs: [], outputTokens: 0, totalTokens: 0, promptTokenEstimate };
|
|
2040
|
+
const tagFilter = createThinkingTagFilter();
|
|
2041
|
+
let mcpExecReceived = false;
|
|
2042
|
+
let toolCallsFlushed = false;
|
|
2043
|
+
let cancelled = false;
|
|
2044
|
+
let latestCheckpoint: Uint8Array | null = null;
|
|
2045
|
+
const coalesceMs = resolveToolCallCoalesceMs();
|
|
2046
|
+
|
|
2047
|
+
const flushPiToolCalls = () => {
|
|
2048
|
+
clearCoalesceTimer();
|
|
2049
|
+
if (closed || cancelled || toolCallsFlushed || state.pendingExecs.length === 0) return;
|
|
2050
|
+
toolCallsFlushed = true;
|
|
2051
|
+
mcpExecReceived = true;
|
|
2052
|
+
|
|
2053
|
+
const flushed = tagFilter.flush();
|
|
2054
|
+
if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
2055
|
+
if (flushed.content) {
|
|
2056
|
+
appendAssistantTextToTurn(currentTurn, flushed.content);
|
|
2057
|
+
sendSSE(makeChunk({ content: flushed.content }));
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
for (const exec of state.pendingExecs) {
|
|
2061
|
+
const toolCallIndex = state.toolCallIndex++;
|
|
2062
|
+
sendSSE(makeChunk({
|
|
2063
|
+
tool_calls: [{
|
|
2064
|
+
index: toolCallIndex, id: exec.toolCallId, type: "function",
|
|
2065
|
+
function: { name: exec.toolName, arguments: exec.decodedArgs },
|
|
2066
|
+
}],
|
|
2067
|
+
}));
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
activeBridges.set(bridgeKey, {
|
|
2071
|
+
bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs: state.pendingExecs, currentTurn,
|
|
2072
|
+
});
|
|
2073
|
+
debugLog("stream.tool_call_pause", { requestId, bridgeKey, pendingExecs: state.pendingExecs, currentTurn });
|
|
2074
|
+
|
|
2075
|
+
sendSSE(makeChunk({}, "tool_calls"));
|
|
2076
|
+
sendSSE(makeUsageChunk());
|
|
2077
|
+
sendDone();
|
|
2078
|
+
closeResponse();
|
|
2079
|
+
};
|
|
2080
|
+
|
|
2081
|
+
const queuePiTool = (exec: PendingExec) => {
|
|
2082
|
+
if (closed || cancelled || toolCallsFlushed) {
|
|
2083
|
+
debugLog("stream.late_exec_rejected", { requestId, bridgeKey, exec });
|
|
2084
|
+
resumePendingExecWithToolResult(exec, NATIVE_TOOL_UNAVAILABLE, true, (data) => bridge.write(data));
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
state.pendingExecs.push(exec);
|
|
2088
|
+
mcpExecReceived = true;
|
|
2089
|
+
currentTurn.steps.push({
|
|
2090
|
+
kind: "toolCall",
|
|
2091
|
+
toolCallId: exec.toolCallId,
|
|
2092
|
+
toolName: exec.toolName,
|
|
2093
|
+
arguments: parseToolCallArguments(exec.decodedArgs),
|
|
2094
|
+
});
|
|
2095
|
+
clearCoalesceTimer();
|
|
2096
|
+
coalesceTimer = setTimeout(flushPiToolCalls, coalesceMs);
|
|
2097
|
+
coalesceTimer.unref?.();
|
|
2098
|
+
};
|
|
2099
|
+
|
|
2100
|
+
const finishOpenAITurn = () => {
|
|
2101
|
+
if (closed || mcpExecReceived) return;
|
|
2102
|
+
const flushed = tagFilter.flush();
|
|
2103
|
+
if (flushed.reasoning) sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
|
|
2104
|
+
if (flushed.content) {
|
|
2105
|
+
appendAssistantTextToTurn(currentTurn, flushed.content);
|
|
2106
|
+
sendSSE(makeChunk({ content: flushed.content }));
|
|
2107
|
+
}
|
|
2108
|
+
sendSSE(makeChunk({}, "stop"));
|
|
2109
|
+
sendSSE(makeUsageChunk());
|
|
2110
|
+
sendDone();
|
|
2111
|
+
closeResponse();
|
|
2112
|
+
cleanupBridge(bridge, heartbeatTimer, bridgeKey);
|
|
2113
|
+
};
|
|
2114
|
+
|
|
2115
|
+
// Detect client disconnect (e.g. user pressed Escape in pi)
|
|
2116
|
+
const onClientClose = () => {
|
|
2117
|
+
if (cancelled || closed) return;
|
|
2118
|
+
debugLog("stream.client_close", { requestId, bridgeKey, convKey });
|
|
2119
|
+
cancelled = true;
|
|
2120
|
+
cleanupBridge(bridge, heartbeatTimer, bridgeKey);
|
|
2121
|
+
closeResponse();
|
|
2122
|
+
};
|
|
2123
|
+
req.on("close", onClientClose);
|
|
2124
|
+
res.on("close", onClientClose);
|
|
2125
|
+
|
|
2126
|
+
const processChunk = createConnectFrameParser(
|
|
2127
|
+
(messageBytes) => {
|
|
2128
|
+
try {
|
|
2129
|
+
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
2130
|
+
transportWatchdog.touch();
|
|
2131
|
+
const classified = processServerMessage(
|
|
2132
|
+
serverMessage, blobStore, mcpTools,
|
|
2133
|
+
(data) => bridge.write(data),
|
|
2134
|
+
state,
|
|
2135
|
+
(text, isThinking) => {
|
|
2136
|
+
if (isThinking) {
|
|
2137
|
+
sendSSE(makeChunk({ reasoning_content: text }));
|
|
2138
|
+
} else {
|
|
2139
|
+
const { content, reasoning } = tagFilter.process(text);
|
|
2140
|
+
if (reasoning) sendSSE(makeChunk({ reasoning_content: reasoning }));
|
|
2141
|
+
if (content) {
|
|
2142
|
+
appendAssistantTextToTurn(currentTurn, content);
|
|
2143
|
+
sendSSE(makeChunk({ content }));
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
},
|
|
2147
|
+
(exec) => {
|
|
2148
|
+
queuePiTool(exec);
|
|
2149
|
+
},
|
|
2150
|
+
(checkpointBytes) => {
|
|
2151
|
+
latestCheckpoint = checkpointBytes;
|
|
2152
|
+
const stored = conversationStates.get(convKey);
|
|
2153
|
+
if (stored) {
|
|
2154
|
+
stored.checkpoint = checkpointBytes;
|
|
2155
|
+
for (const [k, v] of blobStore) stored.blobStore.set(k, v);
|
|
2156
|
+
|
|
2157
|
+
stored.lastAccessMs = Date.now();
|
|
2158
|
+
}
|
|
2159
|
+
debugLog("stream.checkpoint_buffered", { requestId, convKey, checkpointBytes });
|
|
2160
|
+
},
|
|
2161
|
+
() => {
|
|
2162
|
+
debugLog("stream.turn_ended", { requestId, bridgeKey, convKey, mcpExecReceived, closed });
|
|
2163
|
+
finishOpenAITurn();
|
|
2164
|
+
},
|
|
2165
|
+
);
|
|
2166
|
+
if (classified.countsAsProgress) upstreamWatchdog.touch();
|
|
2167
|
+
} catch (err) {
|
|
2168
|
+
console.error("[cursor-provider] Stream message processing error:", err instanceof Error ? err.message : err);
|
|
2169
|
+
}
|
|
2170
|
+
},
|
|
2171
|
+
(endStreamBytes) => {
|
|
2172
|
+
const endError = parseConnectEndStream(endStreamBytes);
|
|
2173
|
+
if (endError) {
|
|
2174
|
+
console.error(`[cursor-provider] Cursor stream error (${modelId}):`, endError.message);
|
|
2175
|
+
conversationStates.delete(convKey);
|
|
2176
|
+
sendSSE(makeChunk({ content: endError.message }, "error"));
|
|
2177
|
+
sendSSE(makeUsageChunk());
|
|
2178
|
+
sendDone();
|
|
2179
|
+
closeResponse();
|
|
2180
|
+
}
|
|
2181
|
+
},
|
|
2182
|
+
);
|
|
2183
|
+
|
|
2184
|
+
bridge.onData(processChunk);
|
|
2185
|
+
|
|
2186
|
+
bridge.onClose((code) => {
|
|
2187
|
+
debugLog("stream.bridge_close", { requestId, bridgeKey, convKey, code, cancelled, mcpExecReceived, toolCallsFlushed, currentTurn, latestCheckpoint });
|
|
2188
|
+
clearCoalesceTimer();
|
|
2189
|
+
transportWatchdog.stop();
|
|
2190
|
+
upstreamWatchdog.stop();
|
|
2191
|
+
clearInterval(heartbeatTimer);
|
|
2192
|
+
req.removeListener("close", onClientClose);
|
|
2193
|
+
res.removeListener("close", onClientClose);
|
|
2194
|
+
const stored = conversationStates.get(convKey);
|
|
2195
|
+
if (stored) {
|
|
2196
|
+
for (const [k, v] of blobStore) stored.blobStore.set(k, v);
|
|
2197
|
+
stored.lastAccessMs = Date.now();
|
|
2198
|
+
if (!cancelled && latestCheckpoint) {
|
|
2199
|
+
stored.checkpoint = latestCheckpoint;
|
|
2200
|
+
debugLog("stream.checkpoint_committed", { requestId, convKey, stored });
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
if (cancelled || closed) return;
|
|
2204
|
+
if (!mcpExecReceived) {
|
|
2205
|
+
finishOpenAITurn();
|
|
2206
|
+
} else if (!toolCallsFlushed) {
|
|
2207
|
+
sendSSE(makeChunk({ content: "Cursor ended the turn before tools could run" }, "error"));
|
|
2208
|
+
sendSSE(makeUsageChunk());
|
|
2209
|
+
sendDone();
|
|
2210
|
+
closeResponse();
|
|
2211
|
+
} else if (code !== 0) {
|
|
2212
|
+
sendSSE(makeChunk({ content: "Bridge connection lost" }, "error"));
|
|
2213
|
+
sendSSE(makeUsageChunk());
|
|
2214
|
+
sendDone();
|
|
2215
|
+
closeResponse();
|
|
2216
|
+
activeBridges.delete(bridgeKey);
|
|
2217
|
+
}
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
export function writeSSEStreamForTests(args: {
|
|
2222
|
+
bridge: BridgeHandle;
|
|
2223
|
+
heartbeatTimer: ReturnType<typeof setInterval>;
|
|
2224
|
+
blobStore?: Map<string, Uint8Array>;
|
|
2225
|
+
mcpTools?: McpToolDefinition[];
|
|
2226
|
+
modelId: string;
|
|
2227
|
+
bridgeKey: string;
|
|
2228
|
+
convKey: string;
|
|
2229
|
+
completedTurns: ParsedTurn[];
|
|
2230
|
+
currentTurn: ParsedTurn;
|
|
2231
|
+
req: IncomingMessage;
|
|
2232
|
+
res: ServerResponse;
|
|
2233
|
+
requestId?: string;
|
|
2234
|
+
promptTokenEstimate?: number;
|
|
2235
|
+
}): void {
|
|
2236
|
+
writeSSEStream(
|
|
2237
|
+
args.bridge,
|
|
2238
|
+
args.heartbeatTimer,
|
|
2239
|
+
args.blobStore ?? new Map(),
|
|
2240
|
+
args.mcpTools ?? [],
|
|
2241
|
+
args.modelId,
|
|
2242
|
+
args.bridgeKey,
|
|
2243
|
+
args.convKey,
|
|
2244
|
+
args.completedTurns,
|
|
2245
|
+
args.currentTurn,
|
|
2246
|
+
args.req,
|
|
2247
|
+
args.res,
|
|
2248
|
+
args.requestId,
|
|
2249
|
+
args.promptTokenEstimate ?? 0,
|
|
2250
|
+
);
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
// ── Tool result resume ──
|
|
2254
|
+
|
|
2255
|
+
function handleToolResultResume(
|
|
2256
|
+
active: ActiveBridge,
|
|
2257
|
+
toolResults: ToolResultInfo[],
|
|
2258
|
+
modelId: string,
|
|
2259
|
+
bridgeKey: string,
|
|
2260
|
+
convKey: string,
|
|
2261
|
+
completedTurns: ParsedTurn[],
|
|
2262
|
+
req: IncomingMessage,
|
|
2263
|
+
res: ServerResponse,
|
|
2264
|
+
stream: boolean,
|
|
2265
|
+
requestId?: string,
|
|
2266
|
+
promptTokenEstimate = 0,
|
|
2267
|
+
): void {
|
|
2268
|
+
const { bridge, heartbeatTimer, blobStore, mcpTools, pendingExecs, currentTurn } = active;
|
|
2269
|
+
debugLog("tool_resume.start", { requestId, bridgeKey, convKey, toolResults, pendingExecs, currentTurn });
|
|
2270
|
+
|
|
2271
|
+
for (const result of toolResults) {
|
|
2272
|
+
const turnToolStep = currentTurn.steps.find((step) => step.kind === "toolCall" && step.toolCallId === result.toolCallId);
|
|
2273
|
+
if (turnToolStep) {
|
|
2274
|
+
turnToolStep.result = { content: result.content, isError: false };
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
const turnResults = getTurnToolCallResults(currentTurn);
|
|
2279
|
+
const unresolvedExecs = pendingExecs.filter((exec) => !turnResults.has(exec.toolCallId));
|
|
2280
|
+
if (unresolvedExecs.length > 0) {
|
|
2281
|
+
activeBridges.set(bridgeKey, {
|
|
2282
|
+
bridge,
|
|
2283
|
+
heartbeatTimer,
|
|
2284
|
+
blobStore,
|
|
2285
|
+
mcpTools,
|
|
2286
|
+
pendingExecs,
|
|
2287
|
+
currentTurn,
|
|
2288
|
+
});
|
|
2289
|
+
debugLog("tool_resume.partial_wait", { requestId, bridgeKey, unresolvedExecs, currentTurn });
|
|
2290
|
+
respondWithPendingToolCalls(modelId, unresolvedExecs, stream, res, promptTokenEstimate);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
// Listen on the new OpenAI stream BEFORE writing exec results. Cursor often
|
|
2295
|
+
// replies in the same tick (text, thinking, the next exec, turnEnded). The
|
|
2296
|
+
// paused writer already called res.end(), so those frames used to land on a
|
|
2297
|
+
// closed SSE and vanish. Pi then sat on Working until the upstream stall watchdog fired.
|
|
2298
|
+
// Tool results belong to the same user turn that initiated the tool calls.
|
|
2299
|
+
// parseMessages keeps tool continuations out of completed history, so completedTurns
|
|
2300
|
+
// already reflects the correct history covered before this in-flight turn.
|
|
2301
|
+
writeSSEStream(
|
|
2302
|
+
bridge,
|
|
2303
|
+
heartbeatTimer,
|
|
2304
|
+
blobStore,
|
|
2305
|
+
mcpTools,
|
|
2306
|
+
modelId,
|
|
2307
|
+
bridgeKey,
|
|
2308
|
+
convKey,
|
|
2309
|
+
completedTurns,
|
|
2310
|
+
currentTurn,
|
|
2311
|
+
req,
|
|
2312
|
+
res,
|
|
2313
|
+
requestId,
|
|
2314
|
+
promptTokenEstimate,
|
|
2315
|
+
);
|
|
2316
|
+
|
|
2317
|
+
for (const exec of pendingExecs) {
|
|
2318
|
+
const result = turnResults.get(exec.toolCallId);
|
|
2319
|
+
if (!result) continue;
|
|
2320
|
+
resumePendingExecWithToolResult(exec, result.content, result.isError, (data) => bridge.write(data));
|
|
2321
|
+
debugLog("tool_resume.sent_result", { requestId, exec, result });
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
export function resumeCursorToolResultsForTests(
|
|
2326
|
+
active: ActiveBridge,
|
|
2327
|
+
toolResults: ToolResultInfo[],
|
|
2328
|
+
req: IncomingMessage,
|
|
2329
|
+
res: ServerResponse,
|
|
2330
|
+
opts: {
|
|
2331
|
+
modelId: string;
|
|
2332
|
+
bridgeKey: string;
|
|
2333
|
+
convKey: string;
|
|
2334
|
+
completedTurns?: ParsedTurn[];
|
|
2335
|
+
},
|
|
2336
|
+
): void {
|
|
2337
|
+
handleToolResultResume(
|
|
2338
|
+
active,
|
|
2339
|
+
toolResults,
|
|
2340
|
+
opts.modelId,
|
|
2341
|
+
opts.bridgeKey,
|
|
2342
|
+
opts.convKey,
|
|
2343
|
+
opts.completedTurns ?? [],
|
|
2344
|
+
req,
|
|
2345
|
+
res,
|
|
2346
|
+
true,
|
|
2347
|
+
);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// ── Non-streaming response ──
|
|
2351
|
+
|
|
2352
|
+
async function handleNonStreamingResponse(
|
|
2353
|
+
payload: CursorRequestPayload,
|
|
2354
|
+
accessToken: string,
|
|
2355
|
+
modelId: string,
|
|
2356
|
+
convKey: string,
|
|
2357
|
+
completedTurns: ParsedTurn[],
|
|
2358
|
+
currentTurn: ParsedTurn,
|
|
2359
|
+
req: IncomingMessage,
|
|
2360
|
+
res: ServerResponse,
|
|
2361
|
+
requestId?: string,
|
|
2362
|
+
promptTokenEstimate = 0,
|
|
2363
|
+
): Promise<void> {
|
|
2364
|
+
debugLog("nonstream.start", { requestId, convKey, modelId, currentTurn, completedTurnCount: completedTurns.length });
|
|
2365
|
+
const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
|
|
2366
|
+
const created = Math.floor(Date.now() / 1000);
|
|
2367
|
+
|
|
2368
|
+
const { bridge, heartbeatTimer } = startBridge(accessToken, payload.requestBytes);
|
|
2369
|
+
let cancelled = false;
|
|
2370
|
+
|
|
2371
|
+
const onClientClose = () => {
|
|
2372
|
+
if (cancelled) return;
|
|
2373
|
+
debugLog("nonstream.client_close", { requestId, convKey });
|
|
2374
|
+
cancelled = true;
|
|
2375
|
+
clearInterval(heartbeatTimer);
|
|
2376
|
+
if (bridge.alive) {
|
|
2377
|
+
sendCancelAction(bridge);
|
|
2378
|
+
bridge.end();
|
|
2379
|
+
}
|
|
2380
|
+
};
|
|
2381
|
+
req.on("close", onClientClose);
|
|
2382
|
+
res.on("close", onClientClose);
|
|
2383
|
+
const state: StreamState = { toolCallIndex: 0, pendingExecs: [], outputTokens: 0, totalTokens: 0, promptTokenEstimate };
|
|
2384
|
+
const tagFilter = createThinkingTagFilter();
|
|
2385
|
+
let fullText = "";
|
|
2386
|
+
let nonStreamError: Error | null = null;
|
|
2387
|
+
let latestCheckpoint: Uint8Array | null = null;
|
|
2388
|
+
|
|
2389
|
+
return new Promise((resolve) => {
|
|
2390
|
+
let settled = false;
|
|
2391
|
+
|
|
2392
|
+
const endBridge = () => {
|
|
2393
|
+
clearInterval(heartbeatTimer);
|
|
2394
|
+
if (bridge.alive) {
|
|
2395
|
+
sendCancelAction(bridge);
|
|
2396
|
+
bridge.end();
|
|
2397
|
+
}
|
|
2398
|
+
};
|
|
2399
|
+
|
|
2400
|
+
const commitCheckpoint = () => {
|
|
2401
|
+
const stored = conversationStates.get(convKey);
|
|
2402
|
+
if (!stored) return;
|
|
2403
|
+
for (const [k, v] of payload.blobStore) stored.blobStore.set(k, v);
|
|
2404
|
+
stored.lastAccessMs = Date.now();
|
|
2405
|
+
if (!cancelled && !nonStreamError && latestCheckpoint) {
|
|
2406
|
+
stored.checkpoint = latestCheckpoint;
|
|
2407
|
+
debugLog("nonstream.checkpoint_committed", { requestId, convKey, stored });
|
|
2408
|
+
}
|
|
2409
|
+
};
|
|
2410
|
+
|
|
2411
|
+
const settle = () => {
|
|
2412
|
+
if (settled) return;
|
|
2413
|
+
settled = true;
|
|
2414
|
+
req.removeListener("close", onClientClose);
|
|
2415
|
+
res.removeListener("close", onClientClose);
|
|
2416
|
+
commitCheckpoint();
|
|
2417
|
+
|
|
2418
|
+
if (cancelled) {
|
|
2419
|
+
if (!res.headersSent) {
|
|
2420
|
+
res.writeHead(499, { "Content-Type": "application/json" });
|
|
2421
|
+
res.end(JSON.stringify({ error: { message: "Client closed request", type: "aborted", code: "client_closed" } }));
|
|
2422
|
+
}
|
|
2423
|
+
endBridge();
|
|
2424
|
+
resolve();
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
if (nonStreamError) {
|
|
2429
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
2430
|
+
res.end(JSON.stringify({
|
|
2431
|
+
error: { message: nonStreamError.message, type: "upstream_error", code: "cursor_error" },
|
|
2432
|
+
}));
|
|
2433
|
+
endBridge();
|
|
2434
|
+
resolve();
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
const flushed = tagFilter.flush();
|
|
2439
|
+
fullText += flushed.content;
|
|
2440
|
+
appendAssistantTextToTurn(currentTurn, flushed.content);
|
|
2441
|
+
const usage = computeUsage(state);
|
|
2442
|
+
|
|
2443
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2444
|
+
res.end(JSON.stringify({
|
|
2445
|
+
id: completionId, object: "chat.completion", created, model: modelId,
|
|
2446
|
+
choices: [{ index: 0, message: { role: "assistant", content: fullText }, finish_reason: "stop" }],
|
|
2447
|
+
usage,
|
|
2448
|
+
}));
|
|
2449
|
+
endBridge();
|
|
2450
|
+
resolve();
|
|
2451
|
+
};
|
|
2452
|
+
|
|
2453
|
+
bridge.onData(createConnectFrameParser(
|
|
2454
|
+
(messageBytes) => {
|
|
2455
|
+
try {
|
|
2456
|
+
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
2457
|
+
processServerMessage(
|
|
2458
|
+
serverMessage, payload.blobStore, payload.mcpTools,
|
|
2459
|
+
(data) => bridge.write(data),
|
|
2460
|
+
state,
|
|
2461
|
+
(text, isThinking) => {
|
|
2462
|
+
if (isThinking) return;
|
|
2463
|
+
const { content } = tagFilter.process(text);
|
|
2464
|
+
fullText += content;
|
|
2465
|
+
appendAssistantTextToTurn(currentTurn, content);
|
|
2466
|
+
},
|
|
2467
|
+
(exec) => {
|
|
2468
|
+
resumePendingExecWithToolResult(
|
|
2469
|
+
exec,
|
|
2470
|
+
"Tools are not available on this non-streaming request.",
|
|
2471
|
+
true,
|
|
2472
|
+
(data) => bridge.write(data),
|
|
2473
|
+
);
|
|
2474
|
+
},
|
|
2475
|
+
(checkpointBytes) => {
|
|
2476
|
+
latestCheckpoint = checkpointBytes;
|
|
2477
|
+
const stored = conversationStates.get(convKey);
|
|
2478
|
+
if (stored) {
|
|
2479
|
+
stored.checkpoint = checkpointBytes;
|
|
2480
|
+
for (const [k, v] of payload.blobStore) stored.blobStore.set(k, v);
|
|
2481
|
+
|
|
2482
|
+
stored.lastAccessMs = Date.now();
|
|
2483
|
+
}
|
|
2484
|
+
debugLog("nonstream.checkpoint_buffered", { requestId, convKey, checkpointBytes });
|
|
2485
|
+
},
|
|
2486
|
+
() => {
|
|
2487
|
+
debugLog("nonstream.turn_ended", { requestId, convKey });
|
|
2488
|
+
settle();
|
|
2489
|
+
},
|
|
2490
|
+
);
|
|
2491
|
+
} catch (err) {
|
|
2492
|
+
console.error("[cursor-provider] Non-stream message processing error:", err instanceof Error ? err.message : err);
|
|
2493
|
+
}
|
|
2494
|
+
},
|
|
2495
|
+
(endStreamBytes) => {
|
|
2496
|
+
const endError = parseConnectEndStream(endStreamBytes);
|
|
2497
|
+
if (endError) {
|
|
2498
|
+
console.error(`[cursor-provider] Cursor non-stream error (${modelId}):`, endError.message);
|
|
2499
|
+
conversationStates.delete(convKey);
|
|
2500
|
+
nonStreamError = endError;
|
|
2501
|
+
}
|
|
2502
|
+
},
|
|
2503
|
+
));
|
|
2504
|
+
|
|
2505
|
+
bridge.onClose(() => {
|
|
2506
|
+
debugLog("nonstream.bridge_close", { requestId, convKey, cancelled, settled, nonStreamError: nonStreamError?.message, currentTurn, latestCheckpoint });
|
|
2507
|
+
settle();
|
|
2508
|
+
});
|
|
2509
|
+
});
|
|
2510
|
+
}
|