@offbynan/pi-cursor-provider 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/proxy.ts ADDED
@@ -0,0 +1,2761 @@
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 {
8
+ create,
9
+ fromBinary,
10
+ fromJson,
11
+ type JsonValue,
12
+ toBinary,
13
+ toJson,
14
+ } from "@bufbuild/protobuf";
15
+ import { ValueSchema } from "@bufbuild/protobuf/wkt";
16
+ import {
17
+ createServer,
18
+ type IncomingMessage,
19
+ type ServerResponse,
20
+ } from "node:http";
21
+ import { spawn, type ChildProcess } from "node:child_process";
22
+ import { createHash } from "node:crypto";
23
+ import { appendFileSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { resolve as pathResolve, dirname, join as pathJoin } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import {
28
+ AgentClientMessageSchema,
29
+ AgentRunRequestSchema,
30
+ AgentServerMessageSchema,
31
+ CancelActionSchema,
32
+ ClientHeartbeatSchema,
33
+ ConversationActionSchema,
34
+ ConversationStateStructureSchema,
35
+ ConversationStepSchema,
36
+ AgentConversationTurnStructureSchema,
37
+ ConversationTurnStructureSchema,
38
+ AssistantMessageSchema,
39
+ BackgroundShellSpawnResultSchema,
40
+ DeleteResultSchema,
41
+ DeleteRejectedSchema,
42
+ DiagnosticsResultSchema,
43
+ ExecClientMessageSchema,
44
+ FetchErrorSchema,
45
+ FetchResultSchema,
46
+ GetBlobResultSchema,
47
+ GrepErrorSchema,
48
+ GrepResultSchema,
49
+ KvClientMessageSchema,
50
+ LsRejectedSchema,
51
+ LsResultSchema,
52
+ McpArgsSchema,
53
+ McpErrorSchema,
54
+ McpResultSchema,
55
+ McpSuccessSchema,
56
+ McpTextContentSchema,
57
+ McpToolCallSchema,
58
+ McpToolDefinitionSchema,
59
+ McpToolResultContentItemSchema,
60
+ ModelDetailsSchema,
61
+ ReadRejectedSchema,
62
+ ReadResultSchema,
63
+ RequestContextResultSchema,
64
+ RequestContextSchema,
65
+ RequestContextSuccessSchema,
66
+ SelectedContextSchema,
67
+ SelectedImageSchema,
68
+ SetBlobResultSchema,
69
+ ShellRejectedSchema,
70
+ ShellResultSchema,
71
+ ShellStreamSchema,
72
+ ToolCallSchema,
73
+ UserMessageActionSchema,
74
+ UserMessageSchema,
75
+ WriteRejectedSchema,
76
+ WriteResultSchema,
77
+ WriteShellStdinErrorSchema,
78
+ WriteShellStdinResultSchema,
79
+ GetUsableModelsRequestSchema,
80
+ GetUsableModelsResponseSchema,
81
+ type AgentServerMessage,
82
+ type ConversationStateStructure,
83
+ type ExecServerMessage,
84
+ type KvServerMessage,
85
+ type McpToolDefinition,
86
+ type UserMessage,
87
+ } from "./proto/agent_pb.js";
88
+
89
+ const CURSOR_API_URL = "https://api2.cursor.sh";
90
+ const CONNECT_END_STREAM_FLAG = 0b00000010;
91
+ // Use import.meta.url for bridge path resolution (jiti supports this)
92
+ const BRIDGE_PATH = pathResolve(
93
+ dirname(fileURLToPath(import.meta.url)),
94
+ "h2-bridge.mjs",
95
+ );
96
+
97
+ // ── Types ──
98
+
99
+ interface OpenAIToolCall {
100
+ id: string;
101
+ type: "function";
102
+ function: { name: string; arguments: string };
103
+ }
104
+
105
+ interface ContentPart {
106
+ type: string;
107
+ text?: string;
108
+ image_url?: { url: string };
109
+ }
110
+
111
+ interface OpenAIMessage {
112
+ role: "system" | "user" | "assistant" | "tool";
113
+ content: string | null | ContentPart[];
114
+ tool_call_id?: string;
115
+ tool_calls?: OpenAIToolCall[];
116
+ }
117
+
118
+ interface OpenAIToolDef {
119
+ type: "function";
120
+ function: {
121
+ name: string;
122
+ description?: string;
123
+ parameters?: Record<string, unknown>;
124
+ };
125
+ }
126
+
127
+ interface ChatCompletionRequest {
128
+ model: string;
129
+ messages: OpenAIMessage[];
130
+ stream?: boolean;
131
+ temperature?: number;
132
+ max_tokens?: number;
133
+ tools?: OpenAIToolDef[];
134
+ tool_choice?: unknown;
135
+ reasoning_effort?: string;
136
+ user?: string;
137
+ pi_session_id?: string;
138
+ }
139
+
140
+ interface CursorRequestPayload {
141
+ requestBytes: Uint8Array;
142
+ blobStore: Map<string, Uint8Array>;
143
+ mcpTools: McpToolDefinition[];
144
+ }
145
+
146
+ interface PendingExec {
147
+ execId: string;
148
+ execMsgId: number;
149
+ toolCallId: string;
150
+ toolName: string;
151
+ decodedArgs: string;
152
+ }
153
+
154
+ interface BridgeHandle {
155
+ proc: Pick<ChildProcess, "kill">;
156
+ readonly alive: boolean;
157
+ write(data: Uint8Array): void;
158
+ end(): void;
159
+ unref(): void;
160
+ onData(cb: (chunk: Buffer) => void): void;
161
+ onClose(cb: (code: number) => void): void;
162
+ }
163
+
164
+ export type BridgeFactory = (options: SpawnBridgeOptions) => BridgeHandle;
165
+
166
+ interface ActiveBridge {
167
+ bridge: BridgeHandle;
168
+ heartbeatTimer: ReturnType<typeof setInterval>;
169
+ blobStore: Map<string, Uint8Array>;
170
+ mcpTools: McpToolDefinition[];
171
+ pendingExecs: PendingExec[];
172
+ currentTurn: ParsedTurn;
173
+ }
174
+
175
+ export interface StoredConversation {
176
+ conversationId: string;
177
+ checkpoint: Uint8Array | null;
178
+ blobStore: Map<string, Uint8Array>;
179
+ }
180
+
181
+ interface StreamState {
182
+ toolCallIndex: number;
183
+ pendingExecs: PendingExec[];
184
+ outputTokens: number;
185
+ totalTokens: number;
186
+ }
187
+
188
+ interface ToolResultInfo {
189
+ toolCallId: string;
190
+ content: string;
191
+ }
192
+
193
+ export interface ParsedToolResult {
194
+ content: string;
195
+ isError: boolean;
196
+ }
197
+
198
+ export interface ParsedAssistantTextStep {
199
+ kind: "assistantText";
200
+ text: string;
201
+ }
202
+
203
+ export interface ParsedToolCallStep {
204
+ kind: "toolCall";
205
+ toolCallId: string;
206
+ toolName: string;
207
+ arguments: Record<string, unknown>;
208
+ result?: ParsedToolResult;
209
+ }
210
+
211
+ export type ParsedTurnStep = ParsedAssistantTextStep | ParsedToolCallStep;
212
+
213
+ export interface ParsedImage {
214
+ mimeType: string;
215
+ data: Uint8Array;
216
+ }
217
+
218
+ function extractImagesFromContent(
219
+ content: OpenAIMessage["content"],
220
+ ): ParsedImage[] {
221
+ if (!Array.isArray(content)) return [];
222
+ const images: ParsedImage[] = [];
223
+ for (const part of content) {
224
+ if (part.type !== "image_url" || !part.image_url?.url) continue;
225
+ const match = part.image_url.url.match(/^data:([^;]+);base64,(.+)$/);
226
+ if (!match) continue;
227
+ images.push({
228
+ mimeType: match[1]!,
229
+ data: Buffer.from(match[2]!, "base64"),
230
+ });
231
+ }
232
+ return images;
233
+ }
234
+
235
+ export interface ParsedTurn {
236
+ userText: string;
237
+ images: ParsedImage[];
238
+ steps: ParsedTurnStep[];
239
+ }
240
+
241
+ interface ParsedMessages {
242
+ systemPrompt: string;
243
+ userText: string;
244
+ userImages: ParsedImage[];
245
+ turns: ParsedTurn[];
246
+ toolResults: ToolResultInfo[];
247
+ }
248
+
249
+ // ── State ──
250
+
251
+ const activeBridges = new Map<string, ActiveBridge>();
252
+ const conversationStates = new Map<string, StoredConversation>();
253
+ let bridgeFactory: BridgeFactory = spawnBridge;
254
+ let debugRequestCounter = 0;
255
+ let debugLogFilePath: string | undefined;
256
+
257
+ function isProxyDebugEnabled(): boolean {
258
+ const raw = process.env.PI_CURSOR_PROVIDER_DEBUG?.trim().toLowerCase();
259
+ return !!raw && raw !== "0" && raw !== "false" && raw !== "off";
260
+ }
261
+
262
+ function truncateDebugString(value: string, max = 4000): string {
263
+ return value.length > max
264
+ ? `${value.slice(0, max)}…<truncated ${value.length - max} chars>`
265
+ : value;
266
+ }
267
+
268
+ function sanitizeForDebug(value: unknown): unknown {
269
+ if (value == null) return value;
270
+ if (typeof value === "string") return truncateDebugString(value);
271
+ if (typeof value === "number" || typeof value === "boolean") return value;
272
+ if (value instanceof Uint8Array || Buffer.isBuffer(value)) {
273
+ const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
274
+ return {
275
+ __type: value instanceof Uint8Array ? "Uint8Array" : "Buffer",
276
+ byteLength: bytes.length,
277
+ sha256: createHash("sha256").update(bytes).digest("hex").slice(0, 16),
278
+ };
279
+ }
280
+ if (Array.isArray(value)) return value.map((item) => sanitizeForDebug(item));
281
+ if (value instanceof Map) {
282
+ return {
283
+ __type: "Map",
284
+ size: value.size,
285
+ entries: Array.from(value.entries())
286
+ .slice(0, 20)
287
+ .map(([k, v]) => [sanitizeForDebug(k), sanitizeForDebug(v)]),
288
+ };
289
+ }
290
+ if (typeof value === "object") {
291
+ const entries = Object.entries(value as Record<string, unknown>).map(
292
+ ([key, inner]) => {
293
+ if (key === "accessToken") return [key, "<redacted>"] as const;
294
+ if (key === "data" && typeof inner === "string")
295
+ return [key, `<redacted base64 ${inner.length} chars>`] as const;
296
+ return [key, sanitizeForDebug(inner)] as const;
297
+ },
298
+ );
299
+ return Object.fromEntries(entries);
300
+ }
301
+ return String(value);
302
+ }
303
+
304
+ function getDebugLogFilePath(): string {
305
+ const configured = process.env.PI_CURSOR_PROVIDER_DEBUG_FILE?.trim();
306
+ if (configured) return configured;
307
+ if (debugLogFilePath) return debugLogFilePath;
308
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
309
+ debugLogFilePath = pathJoin(
310
+ tmpdir(),
311
+ `pi-cursor-provider-debug-${stamp}-${process.pid}.log`,
312
+ );
313
+ return debugLogFilePath;
314
+ }
315
+
316
+ function debugLog(event: string, data?: Record<string, unknown>): void {
317
+ if (!isProxyDebugEnabled()) return;
318
+ const line = JSON.stringify({
319
+ ts: new Date().toISOString(),
320
+ pid: process.pid,
321
+ event,
322
+ ...(data ? sanitizeForDebug(data) : {}),
323
+ });
324
+ const file = getDebugLogFilePath();
325
+ try {
326
+ appendFileSync(file, `${line}\n`, "utf8");
327
+ } catch (error) {
328
+ console.error("[pi-cursor-provider] failed to write debug log", error);
329
+ console.error(`[pi-cursor-provider] ${line}`);
330
+ }
331
+ }
332
+
333
+ function nextDebugRequestId(): string {
334
+ debugRequestCounter += 1;
335
+ return `req-${debugRequestCounter}`;
336
+ }
337
+
338
+ export const __testInternals = {
339
+ activeBridges,
340
+ conversationStates,
341
+ };
342
+
343
+ export function setBridgeFactoryForTests(factory?: BridgeFactory): void {
344
+ bridgeFactory = factory ?? spawnBridge;
345
+ }
346
+
347
+ let proxyServer: ReturnType<typeof createServer> | undefined;
348
+ let proxyPort: number | undefined;
349
+ let proxyAccessTokenProvider: (() => Promise<string>) | undefined;
350
+
351
+ // ── Bridge spawn ──
352
+
353
+ function lpEncode(data: Uint8Array): Buffer {
354
+ const buf = Buffer.alloc(4 + data.length);
355
+ buf.writeUInt32BE(data.length, 0);
356
+ buf.set(data, 4);
357
+ return buf;
358
+ }
359
+
360
+ function frameConnectMessage(data: Uint8Array, flags = 0): Buffer {
361
+ const frame = Buffer.alloc(5 + data.length);
362
+ frame[0] = flags;
363
+ frame.writeUInt32BE(data.length, 1);
364
+ frame.set(data, 5);
365
+ return frame;
366
+ }
367
+
368
+ interface SpawnBridgeOptions {
369
+ accessToken: string;
370
+ rpcPath: string;
371
+ url?: string;
372
+ unary?: boolean;
373
+ }
374
+
375
+ function spawnBridge(options: SpawnBridgeOptions): BridgeHandle {
376
+ debugLog("bridge.spawn", {
377
+ rpcPath: options.rpcPath,
378
+ url: options.url ?? CURSOR_API_URL,
379
+ unary: options.unary ?? false,
380
+ });
381
+ const proc = spawn("node", [BRIDGE_PATH], {
382
+ stdio: ["pipe", "pipe", "ignore"],
383
+ });
384
+
385
+ const config = JSON.stringify({
386
+ accessToken: options.accessToken,
387
+ url: options.url ?? CURSOR_API_URL,
388
+ path: options.rpcPath,
389
+ unary: options.unary ?? false,
390
+ });
391
+ proc.stdin!.write(lpEncode(new TextEncoder().encode(config)));
392
+
393
+ const cbs = {
394
+ data: null as ((chunk: Buffer) => void) | null,
395
+ close: null as ((code: number) => void) | null,
396
+ };
397
+
398
+ let exited = false;
399
+ let exitCode = 1;
400
+
401
+ let pending = Buffer.alloc(0);
402
+ proc.stdout!.on("data", (chunk: Buffer) => {
403
+ pending = Buffer.concat([pending, chunk]);
404
+ while (pending.length >= 4) {
405
+ const len = pending.readUInt32BE(0);
406
+ if (pending.length < 4 + len) break;
407
+ const payload = pending.subarray(4, 4 + len);
408
+ pending = pending.subarray(4 + len);
409
+ cbs.data?.(Buffer.from(payload));
410
+ }
411
+ });
412
+
413
+ proc.on("exit", (code) => {
414
+ exited = true;
415
+ exitCode = code ?? 1;
416
+ // Destroy stdio pipes immediately so their handles don't keep the event
417
+ // loop alive after the bridge exits (critical for `pi -p` to exit cleanly).
418
+ try { proc.stdout!.destroy(); } catch {}
419
+ try { proc.stdin!.destroy(); } catch {}
420
+ debugLog("bridge.exit", { rpcPath: options.rpcPath, exitCode });
421
+ cbs.close?.(exitCode);
422
+ });
423
+
424
+ return {
425
+ proc,
426
+ get alive() {
427
+ return !exited;
428
+ },
429
+ write(data: Uint8Array) {
430
+ try {
431
+ proc.stdin!.write(lpEncode(data));
432
+ } catch {}
433
+ },
434
+ end() {
435
+ try {
436
+ proc.stdin!.write(lpEncode(new Uint8Array(0)));
437
+ proc.stdin!.end();
438
+ } catch {}
439
+ },
440
+ onData(cb: (chunk: Buffer) => void) {
441
+ cbs.data = cb;
442
+ },
443
+ unref() {
444
+ try {
445
+ proc.unref();
446
+ (proc.stdout as any)?.unref?.();
447
+ } catch {}
448
+ },
449
+ onClose(cb: (code: number) => void) {
450
+ if (exited) {
451
+ queueMicrotask(() => cb(exitCode));
452
+ } else {
453
+ cbs.close = cb;
454
+ }
455
+ },
456
+ };
457
+ }
458
+
459
+ // ── Unary RPC (for model discovery) ──
460
+
461
+ export async function callCursorUnaryRpc(options: {
462
+ accessToken: string;
463
+ rpcPath: string;
464
+ requestBody: Uint8Array;
465
+ url?: string;
466
+ timeoutMs?: number;
467
+ }): Promise<{ body: Uint8Array; exitCode: number; timedOut: boolean }> {
468
+ const bridge = bridgeFactory({
469
+ accessToken: options.accessToken,
470
+ rpcPath: options.rpcPath,
471
+ url: options.url,
472
+ unary: true,
473
+ });
474
+ const chunks: Buffer[] = [];
475
+ return new Promise((resolve) => {
476
+ let timedOut = false;
477
+ const timeoutMs = options.timeoutMs ?? 5_000;
478
+ const timeout =
479
+ timeoutMs > 0
480
+ ? setTimeout(() => {
481
+ timedOut = true;
482
+ try {
483
+ bridge.proc.kill();
484
+ } catch {}
485
+ }, timeoutMs)
486
+ : undefined;
487
+
488
+ bridge.onData((chunk) => {
489
+ chunks.push(Buffer.from(chunk));
490
+ });
491
+ bridge.onClose((exitCode) => {
492
+ if (timeout) clearTimeout(timeout);
493
+ resolve({ body: Buffer.concat(chunks), exitCode, timedOut });
494
+ });
495
+
496
+ bridge.write(options.requestBody);
497
+ bridge.end();
498
+ });
499
+ }
500
+
501
+ // ── Model discovery ──
502
+
503
+ export interface CursorModel {
504
+ id: string;
505
+ name: string;
506
+ reasoning: boolean;
507
+ contextWindow: number;
508
+ maxTokens: number;
509
+ }
510
+
511
+ let cachedModels: CursorModel[] | null = null;
512
+
513
+ export async function getCursorModels(apiKey: string): Promise<CursorModel[]> {
514
+ if (cachedModels) return cachedModels;
515
+ try {
516
+ const requestPayload = create(GetUsableModelsRequestSchema, {});
517
+ const requestBody = toBinary(GetUsableModelsRequestSchema, requestPayload);
518
+ const response = await callCursorUnaryRpc({
519
+ accessToken: apiKey,
520
+ rpcPath: "/agent.v1.AgentService/GetUsableModels",
521
+ requestBody,
522
+ });
523
+ if (
524
+ !response.timedOut &&
525
+ response.exitCode === 0 &&
526
+ response.body.length > 0
527
+ ) {
528
+ let decoded: any = null;
529
+ try {
530
+ decoded = fromBinary(GetUsableModelsResponseSchema, response.body);
531
+ } catch {
532
+ // Try Connect framing
533
+ const body = decodeConnectUnaryBody(response.body);
534
+ if (body) {
535
+ try {
536
+ decoded = fromBinary(GetUsableModelsResponseSchema, body);
537
+ } catch {}
538
+ }
539
+ }
540
+ if (decoded?.models?.length) {
541
+ const models = normalizeCursorModels(decoded.models);
542
+ if (models.length > 0) {
543
+ cachedModels = models;
544
+ return models;
545
+ }
546
+ }
547
+ }
548
+ } catch (err) {
549
+ console.error(
550
+ "[cursor-provider] Model discovery failed:",
551
+ err instanceof Error ? err.message : err,
552
+ );
553
+ }
554
+ console.warn("[cursor-provider] Model discovery returned no models");
555
+ return [];
556
+ }
557
+
558
+ function decodeConnectUnaryBody(payload: Uint8Array): Uint8Array | null {
559
+ if (payload.length < 5) return null;
560
+ let offset = 0;
561
+ while (offset + 5 <= payload.length) {
562
+ const flags = payload[offset]!;
563
+ const view = new DataView(
564
+ payload.buffer,
565
+ payload.byteOffset + offset,
566
+ payload.byteLength - offset,
567
+ );
568
+ const messageLength = view.getUint32(1, false);
569
+ const frameEnd = offset + 5 + messageLength;
570
+ if (frameEnd > payload.length) return null;
571
+ if ((flags & 0b0000_0001) !== 0) return null;
572
+ if ((flags & 0b0000_0010) === 0)
573
+ return payload.subarray(offset + 5, frameEnd);
574
+ offset = frameEnd;
575
+ }
576
+ return null;
577
+ }
578
+
579
+ function normalizeCursorModels(models: readonly unknown[]): CursorModel[] {
580
+ const byId = new Map<string, CursorModel>();
581
+ for (const model of models) {
582
+ const m = model as any;
583
+ const id = m?.modelId?.trim?.();
584
+ if (!id) continue;
585
+ const name = m.displayName || m.displayNameShort || m.displayModelId || id;
586
+ byId.set(id, {
587
+ id,
588
+ name,
589
+ reasoning: Boolean(m.thinkingDetails),
590
+ contextWindow: 200_000,
591
+ maxTokens: 64_000,
592
+ });
593
+ }
594
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
595
+ }
596
+
597
+ // ── Proxy server ──
598
+
599
+ export function getProxyPort(): number | undefined {
600
+ return proxyPort;
601
+ }
602
+
603
+ export async function startProxy(
604
+ getAccessToken: () => Promise<string>,
605
+ ): Promise<number> {
606
+ proxyAccessTokenProvider = getAccessToken;
607
+ if (proxyServer && proxyPort) return proxyPort;
608
+
609
+ return new Promise((resolve, reject) => {
610
+ const server = createServer(async (req, res) => {
611
+ const url = new URL(req.url ?? "/", `http://localhost`);
612
+ const requestId = nextDebugRequestId();
613
+ debugLog("http.request", {
614
+ requestId,
615
+ method: req.method,
616
+ pathname: url.pathname,
617
+ headers: req.headers,
618
+ });
619
+
620
+ // Prevent HTTP keep-alive from holding the event loop open after a
621
+ // response completes (critical for `pi -p` to exit cleanly).
622
+ res.setHeader("Connection", "close");
623
+
624
+ if (req.method === "GET" && url.pathname === "/v1/models") {
625
+ res.writeHead(200, { "Content-Type": "application/json" });
626
+ res.end(JSON.stringify({ object: "list", data: [] }));
627
+ return;
628
+ }
629
+
630
+ if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
631
+ try {
632
+ const body = await readBody(req);
633
+ const parsed = JSON.parse(body) as ChatCompletionRequest;
634
+ debugLog("http.chat.body", { requestId, body: parsed });
635
+ if (!proxyAccessTokenProvider)
636
+ throw new Error("No access token provider");
637
+ const accessToken = await proxyAccessTokenProvider();
638
+ await handleChatCompletion(parsed, accessToken, req, res, requestId);
639
+ } catch (err) {
640
+ const message = err instanceof Error ? err.message : String(err);
641
+ debugLog("http.chat.error", {
642
+ requestId,
643
+ message,
644
+ stack: err instanceof Error ? err.stack : undefined,
645
+ });
646
+ res.writeHead(500, { "Content-Type": "application/json" });
647
+ res.end(
648
+ JSON.stringify({
649
+ error: { message, type: "server_error", code: "internal_error" },
650
+ }),
651
+ );
652
+ }
653
+ return;
654
+ }
655
+
656
+ res.writeHead(404);
657
+ res.end("Not Found");
658
+ });
659
+
660
+ server.listen(0, "127.0.0.1", () => {
661
+ const addr = server.address();
662
+ if (typeof addr === "object" && addr) {
663
+ proxyPort = addr.port;
664
+ proxyServer = server;
665
+ // Don't hold the event loop open — pi -p must be able to exit cleanly
666
+ // after a response without an explicit shutdown signal.
667
+ server.unref();
668
+ // unref() only covers the listening socket; accepted connection sockets
669
+ // are ref'd by default. Unref each accepted socket so keep-alive HTTP
670
+ // connections don't prevent process exit either.
671
+ server.on("connection", (socket) => socket.unref());
672
+ debugLog("proxy.start", {
673
+ port: proxyPort,
674
+ debugLogFile: isProxyDebugEnabled()
675
+ ? getDebugLogFilePath()
676
+ : undefined,
677
+ });
678
+ resolve(proxyPort);
679
+ } else {
680
+ reject(new Error("Failed to bind proxy"));
681
+ }
682
+ });
683
+ });
684
+ }
685
+
686
+ export function cleanupAllSessionState(): void {
687
+ debugLog("session.cleanup_all", {
688
+ activeBridgeCount: activeBridges.size,
689
+ conversationCount: conversationStates.size,
690
+ });
691
+ for (const [bridgeKey, active] of activeBridges) {
692
+ cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
693
+ }
694
+ conversationStates.clear();
695
+ }
696
+
697
+ export function stopProxy(): void {
698
+ debugLog("proxy.stop", { port: proxyPort });
699
+ if (proxyServer) {
700
+ proxyServer.close();
701
+ proxyServer = undefined;
702
+ proxyPort = undefined;
703
+ proxyAccessTokenProvider = undefined;
704
+ }
705
+ cleanupAllSessionState();
706
+ }
707
+
708
+ function readBody(req: IncomingMessage): Promise<string> {
709
+ return new Promise((resolve, reject) => {
710
+ const chunks: Buffer[] = [];
711
+ req.on("data", (c: Buffer) => chunks.push(c));
712
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
713
+ req.on("error", reject);
714
+ });
715
+ }
716
+
717
+ // ── Request handling ──
718
+
719
+ /**
720
+ * Insert reasoning effort into model ID, before -fast/-thinking suffix.
721
+ * e.g. model="gpt-5.4" + effort="medium" → "gpt-5.4-medium"
722
+ * model="gpt-5.4-fast" + effort="high" → "gpt-5.4-high-fast"
723
+ * If no effort provided, returns model as-is.
724
+ */
725
+ export function resolveModelId(
726
+ model: string,
727
+ reasoningEffort?: string,
728
+ ): string {
729
+ if (!reasoningEffort) return model;
730
+
731
+ let suffix = "";
732
+ let base = model;
733
+ if (base.endsWith("-fast")) {
734
+ suffix = "-fast";
735
+ base = base.slice(0, -5);
736
+ } else if (base.endsWith("-thinking")) {
737
+ suffix = "-thinking";
738
+ base = base.slice(0, -9);
739
+ }
740
+
741
+ return `${base}-${reasoningEffort}${suffix}`;
742
+ }
743
+
744
+ async function handleChatCompletion(
745
+ body: ChatCompletionRequest,
746
+ accessToken: string,
747
+ req: IncomingMessage,
748
+ res: ServerResponse,
749
+ requestId: string,
750
+ ): Promise<void> {
751
+ const { systemPrompt, userText, userImages, turns, toolResults } =
752
+ parseMessages(body.messages);
753
+ const modelId = resolveModelId(body.model, body.reasoning_effort);
754
+ const tools = body.tools ?? [];
755
+
756
+ debugLog("chat.parsed_messages", {
757
+ requestId,
758
+ systemPrompt,
759
+ userText,
760
+ turns,
761
+ toolResults,
762
+ messageCount: body.messages.length,
763
+ model: body.model,
764
+ resolvedModelId: modelId,
765
+ stream: body.stream !== false,
766
+ });
767
+
768
+ if (!userText && toolResults.length === 0) {
769
+ debugLog("chat.no_user_message", { requestId, messages: body.messages });
770
+ res.writeHead(400, { "Content-Type": "application/json" });
771
+ res.end(
772
+ JSON.stringify({
773
+ error: {
774
+ message: "No user message found",
775
+ type: "invalid_request_error",
776
+ },
777
+ }),
778
+ );
779
+ return;
780
+ }
781
+
782
+ const sessionId = derivePiSessionId(body);
783
+ const bridgeKey = deriveBridgeKey(body.messages, sessionId);
784
+ const convKey = deriveConversationKey(body.messages, sessionId);
785
+ const activeBridge = activeBridges.get(bridgeKey);
786
+ debugLog("chat.session_keys", {
787
+ requestId,
788
+ sessionId,
789
+ bridgeKey,
790
+ convKey,
791
+ hasActiveBridge: !!activeBridge,
792
+ });
793
+
794
+ if (activeBridge && toolResults.length > 0) {
795
+ debugLog("chat.resume_tool_results", {
796
+ requestId,
797
+ bridgeKey,
798
+ toolResults,
799
+ pendingExecs: activeBridge.pendingExecs,
800
+ });
801
+ activeBridges.delete(bridgeKey);
802
+ if (activeBridge.bridge.alive) {
803
+ handleToolResultResume(
804
+ activeBridge,
805
+ toolResults,
806
+ modelId,
807
+ bridgeKey,
808
+ convKey,
809
+ turns,
810
+ req,
811
+ res,
812
+ body.stream !== false,
813
+ requestId,
814
+ );
815
+ return;
816
+ }
817
+ clearInterval(activeBridge.heartbeatTimer);
818
+ activeBridge.bridge.end();
819
+ }
820
+
821
+ if (activeBridge && activeBridges.has(bridgeKey)) {
822
+ clearInterval(activeBridge.heartbeatTimer);
823
+ activeBridge.bridge.end();
824
+ activeBridges.delete(bridgeKey);
825
+ }
826
+
827
+ let stored = conversationStates.get(convKey);
828
+ debugLog("chat.stored_state.before", { requestId, convKey, stored });
829
+ if (!stored) {
830
+ stored = {
831
+ conversationId: deterministicConversationId(convKey),
832
+ checkpoint: null,
833
+ blobStore: new Map(),
834
+ };
835
+ conversationStates.set(convKey, stored);
836
+ }
837
+
838
+ const mcpTools = buildMcpToolDefinitions(tools);
839
+ const effectiveUserText =
840
+ userText ||
841
+ (toolResults.length > 0
842
+ ? toolResults.map((r) => r.content).join("\n")
843
+ : "");
844
+ if (!stored.checkpoint) {
845
+ debugLog("chat.no_checkpoint", {
846
+ requestId,
847
+ convKey,
848
+ conversationId: stored.conversationId,
849
+ });
850
+ }
851
+ const payload = buildCursorRequest(
852
+ modelId,
853
+ systemPrompt,
854
+ effectiveUserText,
855
+ turns,
856
+ stored.conversationId,
857
+ stored.checkpoint,
858
+ stored.blobStore,
859
+ userImages,
860
+ );
861
+ debugLog("chat.cursor_request", {
862
+ requestId,
863
+ conversationId: stored.conversationId,
864
+ effectiveUserText,
865
+ turnCount: turns.length,
866
+ hasCheckpoint: !!stored.checkpoint,
867
+ payload,
868
+ });
869
+ payload.mcpTools = mcpTools;
870
+
871
+ const currentTurn: ParsedTurn = {
872
+ userText: effectiveUserText,
873
+ images: userImages,
874
+ steps: [],
875
+ };
876
+
877
+ if (body.stream === false) {
878
+ debugLog("chat.dispatch_nonstream", { requestId, convKey });
879
+ await handleNonStreamingResponse(
880
+ payload,
881
+ accessToken,
882
+ modelId,
883
+ convKey,
884
+ turns,
885
+ currentTurn,
886
+ req,
887
+ res,
888
+ requestId,
889
+ );
890
+ } else {
891
+ debugLog("chat.dispatch_stream", { requestId, bridgeKey, convKey });
892
+ handleStreamingResponse(
893
+ payload,
894
+ accessToken,
895
+ modelId,
896
+ bridgeKey,
897
+ convKey,
898
+ turns,
899
+ currentTurn,
900
+ req,
901
+ res,
902
+ requestId,
903
+ );
904
+ }
905
+ }
906
+
907
+ // ── Message parsing ──
908
+
909
+ function textContent(content: OpenAIMessage["content"]): string {
910
+ if (content == null) return "";
911
+ if (typeof content === "string") return content;
912
+ return content
913
+ .filter((p) => p.type === "text" && p.text)
914
+ .map((p) => p.text!)
915
+ .join("\n");
916
+ }
917
+
918
+ function parseToolCallArguments(raw: string): Record<string, unknown> {
919
+ try {
920
+ const parsed = JSON.parse(raw);
921
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
922
+ return parsed as Record<string, unknown>;
923
+ }
924
+ return { value: parsed };
925
+ } catch {
926
+ return raw ? { __raw: raw } : {};
927
+ }
928
+ }
929
+
930
+ function isToolCallStep(step: ParsedTurnStep): step is ParsedToolCallStep {
931
+ return step.kind === "toolCall";
932
+ }
933
+
934
+ function getTurnToolCallResults(
935
+ turn: ParsedTurn,
936
+ ): Map<string, ParsedToolResult> {
937
+ const results = new Map<string, ParsedToolResult>();
938
+ for (const step of turn.steps) {
939
+ if (step.kind === "toolCall" && step.result)
940
+ results.set(step.toolCallId, step.result);
941
+ }
942
+ return results;
943
+ }
944
+
945
+ function appendAssistantTextToTurn(turn: ParsedTurn, text: string): void {
946
+ if (!text) return;
947
+ const last = turn.steps.at(-1);
948
+ if (last?.kind === "assistantText") {
949
+ last.text += text;
950
+ } else {
951
+ turn.steps.push({ kind: "assistantText", text });
952
+ }
953
+ }
954
+
955
+ function stripTurnRuntimeState(
956
+ turn: ParsedTurn & {
957
+ toolCallById?: Map<string, ParsedToolCallStep>;
958
+ sawToolResult?: boolean;
959
+ sawAssistantAfterToolResult?: boolean;
960
+ },
961
+ ): ParsedTurn {
962
+ return { userText: turn.userText, images: turn.images, steps: turn.steps };
963
+ }
964
+
965
+ export function parseMessages(messages: OpenAIMessage[]): ParsedMessages {
966
+ let systemPrompt = "You are a helpful assistant.";
967
+ const turns: ParsedTurn[] = [];
968
+
969
+ debugLog("parse_messages.start", { messages });
970
+
971
+ const systemParts = messages
972
+ .filter((m) => m.role === "system")
973
+ .map((m) => textContent(m.content));
974
+ if (systemParts.length > 0) systemPrompt = systemParts.join("\n");
975
+
976
+ const nonSystem = messages.filter((m) => m.role !== "system");
977
+ let currentTurn:
978
+ | (ParsedTurn & {
979
+ toolCallById: Map<string, ParsedToolCallStep>;
980
+ sawToolResult: boolean;
981
+ sawAssistantAfterToolResult: boolean;
982
+ })
983
+ | null = null;
984
+
985
+ const finalizeCurrentTurn = () => {
986
+ if (!currentTurn) return;
987
+ turns.push(stripTurnRuntimeState(currentTurn));
988
+ currentTurn = null;
989
+ };
990
+
991
+ for (const msg of nonSystem) {
992
+ if (msg.role === "user") {
993
+ finalizeCurrentTurn();
994
+ currentTurn = {
995
+ userText: textContent(msg.content),
996
+ images: extractImagesFromContent(msg.content),
997
+ steps: [],
998
+ toolCallById: new Map(),
999
+ sawToolResult: false,
1000
+ sawAssistantAfterToolResult: false,
1001
+ };
1002
+ continue;
1003
+ }
1004
+
1005
+ if (!currentTurn) continue;
1006
+
1007
+ if (msg.role === "assistant") {
1008
+ const text = textContent(msg.content);
1009
+ if (text) {
1010
+ if (currentTurn.sawToolResult)
1011
+ currentTurn.sawAssistantAfterToolResult = true;
1012
+ currentTurn.steps.push({ kind: "assistantText", text });
1013
+ }
1014
+
1015
+ for (const toolCall of msg.tool_calls ?? []) {
1016
+ const step: ParsedToolCallStep = {
1017
+ kind: "toolCall",
1018
+ toolCallId: toolCall.id,
1019
+ toolName: toolCall.function.name,
1020
+ arguments: parseToolCallArguments(toolCall.function.arguments),
1021
+ };
1022
+ currentTurn.steps.push(step);
1023
+ currentTurn.toolCallById.set(step.toolCallId, step);
1024
+ }
1025
+ continue;
1026
+ }
1027
+
1028
+ if (msg.role === "tool") {
1029
+ const toolCallId = msg.tool_call_id ?? "";
1030
+ const content = textContent(msg.content);
1031
+ const existing = toolCallId
1032
+ ? currentTurn.toolCallById.get(toolCallId)
1033
+ : undefined;
1034
+ if (existing) {
1035
+ existing.result = { content, isError: false };
1036
+ } else {
1037
+ const step: ParsedToolCallStep = {
1038
+ kind: "toolCall",
1039
+ toolCallId,
1040
+ toolName: "",
1041
+ arguments: {},
1042
+ result: { content, isError: false },
1043
+ };
1044
+ currentTurn.steps.push(step);
1045
+ if (toolCallId) currentTurn.toolCallById.set(toolCallId, step);
1046
+ }
1047
+ currentTurn.sawToolResult = true;
1048
+ }
1049
+ }
1050
+
1051
+ let userText = "";
1052
+ let userImages: ParsedImage[] = [];
1053
+ let toolResults: ToolResultInfo[] = [];
1054
+
1055
+ if (currentTurn) {
1056
+ const toolCallSteps = currentTurn.steps.filter(isToolCallStep);
1057
+ const hasAnyToolResults = toolCallSteps.some((step) => step.result);
1058
+ const lastStep = currentTurn.steps.at(-1);
1059
+ const isToolContinuation = lastStep?.kind === "toolCall";
1060
+
1061
+ if (currentTurn.steps.length === 0 || isToolContinuation) {
1062
+ userText = currentTurn.userText;
1063
+ userImages = currentTurn.images;
1064
+ if (hasAnyToolResults) {
1065
+ toolResults = toolCallSteps
1066
+ .filter((step) => step.result)
1067
+ .map((step) => ({
1068
+ toolCallId: step.toolCallId,
1069
+ content: step.result!.content,
1070
+ }));
1071
+ }
1072
+ } else {
1073
+ turns.push(stripTurnRuntimeState(currentTurn));
1074
+ }
1075
+ }
1076
+
1077
+ const parsed = { systemPrompt, userText, userImages, turns, toolResults };
1078
+ debugLog("parse_messages.end", parsed);
1079
+ return parsed;
1080
+ }
1081
+
1082
+ // ── Tool definitions ──
1083
+
1084
+ function buildMcpToolDefinitions(tools: OpenAIToolDef[]): McpToolDefinition[] {
1085
+ return tools.map((t) => {
1086
+ const fn = t.function;
1087
+ const jsonSchema: JsonValue =
1088
+ fn.parameters && typeof fn.parameters === "object"
1089
+ ? (fn.parameters as JsonValue)
1090
+ : { type: "object", properties: {}, required: [] };
1091
+ const inputSchema = toBinary(
1092
+ ValueSchema,
1093
+ fromJson(ValueSchema, jsonSchema),
1094
+ );
1095
+ return create(McpToolDefinitionSchema, {
1096
+ name: fn.name,
1097
+ description: fn.description || "",
1098
+ providerIdentifier: "pi",
1099
+ toolName: fn.name,
1100
+ inputSchema,
1101
+ });
1102
+ });
1103
+ }
1104
+
1105
+ function decodeMcpArgValue(value: Uint8Array): unknown {
1106
+ try {
1107
+ const parsed = fromBinary(ValueSchema, value);
1108
+ return toJson(ValueSchema, parsed);
1109
+ } catch {}
1110
+ return new TextDecoder().decode(value);
1111
+ }
1112
+
1113
+ function decodeMcpArgsMap(
1114
+ args: Record<string, Uint8Array>,
1115
+ ): Record<string, unknown> {
1116
+ const decoded: Record<string, unknown> = {};
1117
+ for (const [key, value] of Object.entries(args))
1118
+ decoded[key] = decodeMcpArgValue(value);
1119
+ return decoded;
1120
+ }
1121
+
1122
+ // ── Build Cursor protobuf request ──
1123
+
1124
+ function encodeMcpArgValue(value: unknown): Uint8Array {
1125
+ try {
1126
+ return toBinary(ValueSchema, fromJson(ValueSchema, value as JsonValue));
1127
+ } catch {
1128
+ return new TextEncoder().encode(String(value));
1129
+ }
1130
+ }
1131
+
1132
+ function encodeMcpArgsMap(
1133
+ args: Record<string, unknown>,
1134
+ ): Record<string, Uint8Array> {
1135
+ const encoded: Record<string, Uint8Array> = {};
1136
+ for (const [key, value] of Object.entries(args))
1137
+ encoded[key] = encodeMcpArgValue(value);
1138
+ return encoded;
1139
+ }
1140
+
1141
+ // No generated schema for selectedContextBlob; emit raw wire format for the two
1142
+ // fields Cursor actually reads: field 1 (repeated bytes) rootPromptMessagesJson
1143
+ // refs, field 22 (string) clientName. blobId.length < 128 (SHA256 = 32 bytes).
1144
+ function buildSelectedContextBlob(
1145
+ rootPromptBlobIds: Uint8Array[],
1146
+ clientName: string,
1147
+ ): Uint8Array {
1148
+ const parts: Uint8Array[] = [];
1149
+ for (const blobId of rootPromptBlobIds) {
1150
+ parts.push(new Uint8Array([0x0a, blobId.length, ...blobId]));
1151
+ }
1152
+ const clientBytes = new TextEncoder().encode(clientName);
1153
+ parts.push(new Uint8Array([0xb2, 0x01, clientBytes.length, ...clientBytes]));
1154
+ const total = parts.reduce((n, p) => n + p.length, 0);
1155
+ const result = new Uint8Array(total);
1156
+ let offset = 0;
1157
+ for (const p of parts) {
1158
+ result.set(p, offset);
1159
+ offset += p.length;
1160
+ }
1161
+ return result;
1162
+ }
1163
+
1164
+ function storeAsBlob(
1165
+ data: Uint8Array,
1166
+ blobStore: Map<string, Uint8Array>,
1167
+ ): Uint8Array {
1168
+ const id = new Uint8Array(createHash("sha256").update(data).digest());
1169
+ blobStore.set(Buffer.from(id).toString("hex"), data);
1170
+ return id;
1171
+ }
1172
+
1173
+ function buildSelectedImages(
1174
+ images: ParsedImage[],
1175
+ ): ReturnType<typeof create<typeof SelectedImageSchema>>[] {
1176
+ return images.map((img) =>
1177
+ create(SelectedImageSchema, {
1178
+ uuid: crypto.randomUUID(),
1179
+ path: "",
1180
+ mimeType: img.mimeType,
1181
+ dataOrBlobId: { case: "data", value: img.data },
1182
+ }),
1183
+ );
1184
+ }
1185
+
1186
+ function createUserMessage(
1187
+ text: string,
1188
+ selectedContextBlob: Uint8Array,
1189
+ images?: ParsedImage[],
1190
+ ): UserMessage {
1191
+ const messageId = crypto.randomUUID();
1192
+ return create(UserMessageSchema, {
1193
+ text,
1194
+ messageId,
1195
+ selectedContext: create(SelectedContextSchema, {
1196
+ selectedImages: images?.length ? buildSelectedImages(images) : [],
1197
+ }),
1198
+ mode: 1,
1199
+ selectedContextBlob,
1200
+ correlationId: messageId,
1201
+ });
1202
+ }
1203
+
1204
+ function buildTurnStepBytes(step: ParsedTurnStep): Uint8Array {
1205
+ if (step.kind === "assistantText") {
1206
+ return toBinary(
1207
+ ConversationStepSchema,
1208
+ create(ConversationStepSchema, {
1209
+ message: {
1210
+ case: "assistantMessage",
1211
+ value: create(AssistantMessageSchema, { text: step.text }),
1212
+ },
1213
+ }),
1214
+ );
1215
+ }
1216
+
1217
+ const toolName = step.toolName || "tool";
1218
+ const mcpToolCall = create(McpToolCallSchema, {
1219
+ args: create(McpArgsSchema, {
1220
+ name: toolName,
1221
+ args: encodeMcpArgsMap(step.arguments),
1222
+ toolCallId: step.toolCallId,
1223
+ providerIdentifier: "pi",
1224
+ toolName,
1225
+ }),
1226
+ ...(step.result && {
1227
+ result: create(McpResultSchema, {
1228
+ result: step.result.isError
1229
+ ? {
1230
+ case: "error",
1231
+ value: create(McpErrorSchema, { error: step.result.content }),
1232
+ }
1233
+ : {
1234
+ case: "success",
1235
+ value: create(McpSuccessSchema, {
1236
+ content: [
1237
+ create(McpToolResultContentItemSchema, {
1238
+ content: {
1239
+ case: "text",
1240
+ value: create(McpTextContentSchema, {
1241
+ text: step.result.content,
1242
+ }),
1243
+ },
1244
+ }),
1245
+ ],
1246
+ isError: false,
1247
+ }),
1248
+ },
1249
+ }),
1250
+ }),
1251
+ });
1252
+
1253
+ return toBinary(
1254
+ ConversationStepSchema,
1255
+ create(ConversationStepSchema, {
1256
+ message: {
1257
+ case: "toolCall",
1258
+ value: create(ToolCallSchema, {
1259
+ tool: {
1260
+ case: "mcpToolCall",
1261
+ value: mcpToolCall,
1262
+ },
1263
+ }),
1264
+ },
1265
+ }),
1266
+ );
1267
+ }
1268
+
1269
+ export function buildCursorRequest(
1270
+ modelId: string,
1271
+ systemPrompt: string,
1272
+ userText: string,
1273
+ turns: ParsedTurn[],
1274
+ conversationId: string,
1275
+ checkpoint: Uint8Array | null,
1276
+ existingBlobStore?: Map<string, Uint8Array>,
1277
+ userImages?: ParsedImage[],
1278
+ ): CursorRequestPayload {
1279
+ debugLog("cursor_request.build.start", {
1280
+ modelId,
1281
+ systemPrompt,
1282
+ userText,
1283
+ turns,
1284
+ conversationId,
1285
+ checkpoint,
1286
+ existingBlobStore,
1287
+ });
1288
+ const blobStore = new Map<string, Uint8Array>(existingBlobStore ?? []);
1289
+
1290
+ const systemBytes = new TextEncoder().encode(
1291
+ JSON.stringify({ role: "system", content: systemPrompt }),
1292
+ );
1293
+ const systemBlobId = storeAsBlob(systemBytes, blobStore);
1294
+ const selectedCtxBlob = storeAsBlob(
1295
+ buildSelectedContextBlob([systemBlobId], "pi"),
1296
+ blobStore,
1297
+ );
1298
+
1299
+ let conversationState;
1300
+ if (checkpoint) {
1301
+ conversationState = fromBinary(
1302
+ ConversationStateStructureSchema,
1303
+ checkpoint,
1304
+ );
1305
+ } else {
1306
+ const turnBlobIds: Uint8Array[] = [];
1307
+ for (const turn of turns) {
1308
+ const userMsg = createUserMessage(
1309
+ turn.userText,
1310
+ selectedCtxBlob,
1311
+ turn.images,
1312
+ );
1313
+ const userMsgBlobId = storeAsBlob(
1314
+ toBinary(UserMessageSchema, userMsg),
1315
+ blobStore,
1316
+ );
1317
+ const stepBlobIds = turn.steps.map((s) =>
1318
+ storeAsBlob(buildTurnStepBytes(s), blobStore),
1319
+ );
1320
+
1321
+ const agentTurn = create(AgentConversationTurnStructureSchema, {
1322
+ userMessage: userMsgBlobId,
1323
+ steps: stepBlobIds,
1324
+ requestId: crypto.randomUUID(),
1325
+ });
1326
+ const turnStructure = create(ConversationTurnStructureSchema, {
1327
+ turn: { case: "agentConversationTurn", value: agentTurn },
1328
+ });
1329
+ turnBlobIds.push(
1330
+ storeAsBlob(
1331
+ toBinary(ConversationTurnStructureSchema, turnStructure),
1332
+ blobStore,
1333
+ ),
1334
+ );
1335
+ }
1336
+
1337
+ conversationState = create(ConversationStateStructureSchema, {
1338
+ rootPromptMessagesJson: [systemBlobId],
1339
+ turns: turnBlobIds,
1340
+ todos: [],
1341
+ pendingToolCalls: [],
1342
+ previousWorkspaceUris: [`file://${process.cwd()}`],
1343
+ mode: 1,
1344
+ fileStates: {},
1345
+ fileStatesV2: {},
1346
+ summaryArchives: [],
1347
+ turnTimings: [],
1348
+ subagentStates: {},
1349
+ selfSummaryCount: 0,
1350
+ readPaths: [],
1351
+ clientName: "pi",
1352
+ });
1353
+ }
1354
+
1355
+ const userMessage = createUserMessage(userText, selectedCtxBlob, userImages);
1356
+ const action = create(ConversationActionSchema, {
1357
+ action: {
1358
+ case: "userMessageAction",
1359
+ value: create(UserMessageActionSchema, { userMessage }),
1360
+ },
1361
+ });
1362
+ const modelDetails = create(ModelDetailsSchema, {
1363
+ modelId,
1364
+ displayModelId: modelId,
1365
+ displayName: modelId,
1366
+ });
1367
+ const runRequest = create(AgentRunRequestSchema, {
1368
+ conversationState,
1369
+ action,
1370
+ modelDetails,
1371
+ conversationId,
1372
+ });
1373
+ const clientMessage = create(AgentClientMessageSchema, {
1374
+ message: { case: "runRequest", value: runRequest },
1375
+ });
1376
+
1377
+ const payload = {
1378
+ requestBytes: toBinary(AgentClientMessageSchema, clientMessage),
1379
+ blobStore,
1380
+ mcpTools: [],
1381
+ };
1382
+ debugLog("cursor_request.build.end", payload);
1383
+ return payload;
1384
+ }
1385
+
1386
+ // ── Server message processing ──
1387
+
1388
+ function processServerMessage(
1389
+ msg: AgentServerMessage,
1390
+ blobStore: Map<string, Uint8Array>,
1391
+ mcpTools: McpToolDefinition[],
1392
+ sendFrame: (data: Uint8Array) => void,
1393
+ state: StreamState,
1394
+ onText: (text: string, isThinking?: boolean) => void,
1395
+ onMcpExec: (exec: PendingExec) => void,
1396
+ onCheckpoint?: (checkpointBytes: Uint8Array) => void,
1397
+ ): void {
1398
+ const msgCase = msg.message.case;
1399
+ debugLog("server_message", { msgCase, msg });
1400
+
1401
+ if (msgCase === "interactionUpdate") {
1402
+ const update = msg.message.value as any;
1403
+ const updateCase = update.message?.case;
1404
+ if (updateCase === "textDelta") {
1405
+ const delta = update.message.value.text || "";
1406
+ if (delta) onText(delta, false);
1407
+ } else if (updateCase === "thinkingDelta") {
1408
+ const delta = update.message.value.text || "";
1409
+ if (delta) onText(delta, true);
1410
+ } else if (updateCase === "tokenDelta") {
1411
+ state.outputTokens += update.message.value.tokens ?? 0;
1412
+ }
1413
+ } else if (msgCase === "kvServerMessage") {
1414
+ handleKvMessage(msg.message.value as KvServerMessage, blobStore, sendFrame);
1415
+ } else if (msgCase === "execServerMessage") {
1416
+ handleExecMessage(
1417
+ msg.message.value as ExecServerMessage,
1418
+ mcpTools,
1419
+ sendFrame,
1420
+ onMcpExec,
1421
+ );
1422
+ } else if (msgCase === "conversationCheckpointUpdate") {
1423
+ const stateStructure = msg.message.value as ConversationStateStructure;
1424
+ if ((stateStructure as any).tokenDetails) {
1425
+ state.totalTokens = (stateStructure as any).tokenDetails.usedTokens;
1426
+ }
1427
+ if (onCheckpoint) {
1428
+ onCheckpoint(toBinary(ConversationStateStructureSchema, stateStructure));
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ function sendKvResponse(
1434
+ kvMsg: KvServerMessage,
1435
+ messageCase: string,
1436
+ value: unknown,
1437
+ sendFrame: (data: Uint8Array) => void,
1438
+ ): void {
1439
+ const response = create(KvClientMessageSchema, {
1440
+ id: (kvMsg as any).id,
1441
+ message: { case: messageCase as any, value: value as any },
1442
+ });
1443
+ const clientMsg = create(AgentClientMessageSchema, {
1444
+ message: { case: "kvClientMessage", value: response },
1445
+ });
1446
+ sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMsg)));
1447
+ }
1448
+
1449
+ function handleKvMessage(
1450
+ kvMsg: KvServerMessage,
1451
+ blobStore: Map<string, Uint8Array>,
1452
+ sendFrame: (data: Uint8Array) => void,
1453
+ ): void {
1454
+ const kvCase = (kvMsg as any).message.case;
1455
+ if (kvCase === "getBlobArgs") {
1456
+ const blobId = (kvMsg as any).message.value.blobId;
1457
+ const blobIdKey = Buffer.from(blobId).toString("hex");
1458
+ const blobData = blobStore.get(blobIdKey);
1459
+ sendKvResponse(
1460
+ kvMsg,
1461
+ "getBlobResult",
1462
+ create(GetBlobResultSchema, blobData ? { blobData } : {}),
1463
+ sendFrame,
1464
+ );
1465
+ } else if (kvCase === "setBlobArgs") {
1466
+ const { blobId, blobData } = (kvMsg as any).message.value;
1467
+ blobStore.set(Buffer.from(blobId).toString("hex"), blobData);
1468
+ sendKvResponse(
1469
+ kvMsg,
1470
+ "setBlobResult",
1471
+ create(SetBlobResultSchema, {}),
1472
+ sendFrame,
1473
+ );
1474
+ }
1475
+ }
1476
+
1477
+ function handleExecMessage(
1478
+ execMsg: ExecServerMessage,
1479
+ mcpTools: McpToolDefinition[],
1480
+ sendFrame: (data: Uint8Array) => void,
1481
+ onMcpExec: (exec: PendingExec) => void,
1482
+ ): void {
1483
+ const execCase = (execMsg as any).message.case;
1484
+ const REJECT_REASON =
1485
+ "Tool not available in this environment. Use the MCP tools provided instead.";
1486
+
1487
+ if (execCase === "requestContextArgs") {
1488
+ const requestContext = create(RequestContextSchema, {
1489
+ rules: [],
1490
+ repositoryInfo: [],
1491
+ tools: mcpTools,
1492
+ gitRepos: [],
1493
+ projectLayouts: [],
1494
+ mcpInstructions: [],
1495
+ fileContents: {},
1496
+ customSubagents: [],
1497
+ });
1498
+ const result = create(RequestContextResultSchema, {
1499
+ result: {
1500
+ case: "success",
1501
+ value: create(RequestContextSuccessSchema, { requestContext }),
1502
+ },
1503
+ });
1504
+ sendExecResult(execMsg, "requestContextResult", result, sendFrame);
1505
+ return;
1506
+ }
1507
+
1508
+ if (execCase === "mcpArgs") {
1509
+ const mcpArgs = (execMsg as any).message.value;
1510
+ const decoded = decodeMcpArgsMap(mcpArgs.args ?? {});
1511
+ onMcpExec({
1512
+ execId: (execMsg as any).execId,
1513
+ execMsgId: (execMsg as any).id,
1514
+ toolCallId: mcpArgs.toolCallId || crypto.randomUUID(),
1515
+ toolName: mcpArgs.toolName || mcpArgs.name,
1516
+ decodedArgs: JSON.stringify(decoded),
1517
+ });
1518
+ return;
1519
+ }
1520
+
1521
+ // Reject native Cursor tools so model falls back to MCP tools
1522
+ if (execCase === "readArgs") {
1523
+ const args = (execMsg as any).message.value;
1524
+ sendExecResult(
1525
+ execMsg,
1526
+ "readResult",
1527
+ create(ReadResultSchema, {
1528
+ result: {
1529
+ case: "rejected",
1530
+ value: create(ReadRejectedSchema, {
1531
+ path: args.path,
1532
+ reason: REJECT_REASON,
1533
+ }),
1534
+ },
1535
+ }),
1536
+ sendFrame,
1537
+ );
1538
+ return;
1539
+ }
1540
+ if (execCase === "lsArgs") {
1541
+ const args = (execMsg as any).message.value;
1542
+ sendExecResult(
1543
+ execMsg,
1544
+ "lsResult",
1545
+ create(LsResultSchema, {
1546
+ result: {
1547
+ case: "rejected",
1548
+ value: create(LsRejectedSchema, {
1549
+ path: args.path,
1550
+ reason: REJECT_REASON,
1551
+ }),
1552
+ },
1553
+ }),
1554
+ sendFrame,
1555
+ );
1556
+ return;
1557
+ }
1558
+ if (execCase === "grepArgs") {
1559
+ sendExecResult(
1560
+ execMsg,
1561
+ "grepResult",
1562
+ create(GrepResultSchema, {
1563
+ result: {
1564
+ case: "error",
1565
+ value: create(GrepErrorSchema, { error: REJECT_REASON }),
1566
+ },
1567
+ }),
1568
+ sendFrame,
1569
+ );
1570
+ return;
1571
+ }
1572
+ if (execCase === "writeArgs") {
1573
+ const args = (execMsg as any).message.value;
1574
+ sendExecResult(
1575
+ execMsg,
1576
+ "writeResult",
1577
+ create(WriteResultSchema, {
1578
+ result: {
1579
+ case: "rejected",
1580
+ value: create(WriteRejectedSchema, {
1581
+ path: args.path,
1582
+ reason: REJECT_REASON,
1583
+ }),
1584
+ },
1585
+ }),
1586
+ sendFrame,
1587
+ );
1588
+ return;
1589
+ }
1590
+ if (execCase === "deleteArgs") {
1591
+ const args = (execMsg as any).message.value;
1592
+ sendExecResult(
1593
+ execMsg,
1594
+ "deleteResult",
1595
+ create(DeleteResultSchema, {
1596
+ result: {
1597
+ case: "rejected",
1598
+ value: create(DeleteRejectedSchema, {
1599
+ path: args.path,
1600
+ reason: REJECT_REASON,
1601
+ }),
1602
+ },
1603
+ }),
1604
+ sendFrame,
1605
+ );
1606
+ return;
1607
+ }
1608
+ if (execCase === "shellArgs") {
1609
+ const args = (execMsg as any).message.value;
1610
+ sendExecResult(
1611
+ execMsg,
1612
+ "shellResult",
1613
+ create(ShellResultSchema, {
1614
+ result: {
1615
+ case: "rejected",
1616
+ value: create(ShellRejectedSchema, {
1617
+ command: args.command ?? "",
1618
+ workingDirectory: args.workingDirectory ?? "",
1619
+ reason: REJECT_REASON,
1620
+ isReadonly: false,
1621
+ }),
1622
+ },
1623
+ }),
1624
+ sendFrame,
1625
+ );
1626
+ return;
1627
+ }
1628
+ if (execCase === "shellStreamArgs") {
1629
+ const args = (execMsg as any).message.value;
1630
+ sendExecResult(
1631
+ execMsg,
1632
+ "shellStream",
1633
+ create(ShellStreamSchema, {
1634
+ event: {
1635
+ case: "rejected",
1636
+ value: create(ShellRejectedSchema, {
1637
+ command: args.command ?? "",
1638
+ workingDirectory: args.workingDirectory ?? "",
1639
+ reason: REJECT_REASON,
1640
+ isReadonly: false,
1641
+ }),
1642
+ },
1643
+ }),
1644
+ sendFrame,
1645
+ );
1646
+ return;
1647
+ }
1648
+ if (execCase === "backgroundShellSpawnArgs") {
1649
+ const args = (execMsg as any).message.value;
1650
+ sendExecResult(
1651
+ execMsg,
1652
+ "backgroundShellSpawnResult",
1653
+ create(BackgroundShellSpawnResultSchema, {
1654
+ result: {
1655
+ case: "rejected",
1656
+ value: create(ShellRejectedSchema, {
1657
+ command: args.command ?? "",
1658
+ workingDirectory: args.workingDirectory ?? "",
1659
+ reason: REJECT_REASON,
1660
+ isReadonly: false,
1661
+ }),
1662
+ },
1663
+ }),
1664
+ sendFrame,
1665
+ );
1666
+ return;
1667
+ }
1668
+ if (execCase === "writeShellStdinArgs") {
1669
+ sendExecResult(
1670
+ execMsg,
1671
+ "writeShellStdinResult",
1672
+ create(WriteShellStdinResultSchema, {
1673
+ result: {
1674
+ case: "error",
1675
+ value: create(WriteShellStdinErrorSchema, { error: REJECT_REASON }),
1676
+ },
1677
+ }),
1678
+ sendFrame,
1679
+ );
1680
+ return;
1681
+ }
1682
+ if (execCase === "fetchArgs") {
1683
+ const args = (execMsg as any).message.value;
1684
+ sendExecResult(
1685
+ execMsg,
1686
+ "fetchResult",
1687
+ create(FetchResultSchema, {
1688
+ result: {
1689
+ case: "error",
1690
+ value: create(FetchErrorSchema, {
1691
+ url: args.url ?? "",
1692
+ error: REJECT_REASON,
1693
+ }),
1694
+ },
1695
+ }),
1696
+ sendFrame,
1697
+ );
1698
+ return;
1699
+ }
1700
+ if (execCase === "diagnosticsArgs") {
1701
+ sendExecResult(
1702
+ execMsg,
1703
+ "diagnosticsResult",
1704
+ create(DiagnosticsResultSchema, {}),
1705
+ sendFrame,
1706
+ );
1707
+ return;
1708
+ }
1709
+
1710
+ // Unknown exec types
1711
+ const miscCaseMap: Record<string, string> = {
1712
+ listMcpResourcesExecArgs: "listMcpResourcesExecResult",
1713
+ readMcpResourceExecArgs: "readMcpResourceExecResult",
1714
+ recordScreenArgs: "recordScreenResult",
1715
+ computerUseArgs: "computerUseResult",
1716
+ };
1717
+ const resultCase = miscCaseMap[execCase as string];
1718
+ if (resultCase) {
1719
+ sendExecResult(execMsg, resultCase, create(McpResultSchema, {}), sendFrame);
1720
+ return;
1721
+ }
1722
+
1723
+ // Catch-all: log and attempt a generic rejection so the bridge doesn't hang
1724
+ console.error(
1725
+ `[cursor-provider] UNHANDLED exec case: "${execCase}". Bridge may stall.`,
1726
+ );
1727
+ // Try to derive the result case name from the args case name
1728
+ const guessedResult = (execCase as string)?.replace(/Args$/, "Result");
1729
+ if (guessedResult && guessedResult !== execCase) {
1730
+ sendExecResult(
1731
+ execMsg,
1732
+ guessedResult,
1733
+ create(McpResultSchema, {}),
1734
+ sendFrame,
1735
+ );
1736
+ }
1737
+ }
1738
+
1739
+ function sendExecResult(
1740
+ execMsg: ExecServerMessage,
1741
+ messageCase: string,
1742
+ value: unknown,
1743
+ sendFrame: (data: Uint8Array) => void,
1744
+ ): void {
1745
+ const execClientMessage = create(ExecClientMessageSchema, {
1746
+ id: (execMsg as any).id,
1747
+ execId: (execMsg as any).execId,
1748
+ message: { case: messageCase as any, value: value as any },
1749
+ });
1750
+ const clientMessage = create(AgentClientMessageSchema, {
1751
+ message: { case: "execClientMessage", value: execClientMessage },
1752
+ });
1753
+ sendFrame(
1754
+ frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)),
1755
+ );
1756
+ }
1757
+
1758
+ // ── Key derivation ──
1759
+
1760
+ export function derivePiSessionId(
1761
+ body: Pick<ChatCompletionRequest, "pi_session_id" | "user">,
1762
+ ): string | undefined {
1763
+ const raw = body.pi_session_id ?? body.user;
1764
+ if (typeof raw !== "string") return undefined;
1765
+ const trimmed = raw.trim();
1766
+ return trimmed ? trimmed : undefined;
1767
+ }
1768
+
1769
+ export function deriveBridgeKeyFromSessionId(sessionId: string): string {
1770
+ return createHash("sha256")
1771
+ .update(`bridge:${sessionId}`)
1772
+ .digest("hex")
1773
+ .slice(0, 16);
1774
+ }
1775
+
1776
+ export function deriveConversationKeyFromSessionId(sessionId: string): string {
1777
+ return createHash("sha256")
1778
+ .update(`conv:${sessionId}`)
1779
+ .digest("hex")
1780
+ .slice(0, 16);
1781
+ }
1782
+
1783
+ export function deriveBridgeKey(
1784
+ messages: OpenAIMessage[],
1785
+ sessionId?: string,
1786
+ ): string {
1787
+ if (sessionId) return deriveBridgeKeyFromSessionId(sessionId);
1788
+ const firstUserMsg = messages.find((m) => m.role === "user");
1789
+ const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1790
+ return createHash("sha256")
1791
+ .update(`bridge:${firstUserText.slice(0, 200)}`)
1792
+ .digest("hex")
1793
+ .slice(0, 16);
1794
+ }
1795
+
1796
+ export function deriveConversationKey(
1797
+ messages: OpenAIMessage[],
1798
+ sessionId?: string,
1799
+ ): string {
1800
+ if (sessionId) return deriveConversationKeyFromSessionId(sessionId);
1801
+ const firstUserMsg = messages.find((m) => m.role === "user");
1802
+ const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
1803
+ return createHash("sha256")
1804
+ .update(`conv:${firstUserText.slice(0, 200)}`)
1805
+ .digest("hex")
1806
+ .slice(0, 16);
1807
+ }
1808
+
1809
+ export function cleanupSessionState(sessionId?: string): void {
1810
+ if (!sessionId) return;
1811
+ const bridgeKey = deriveBridgeKeyFromSessionId(sessionId);
1812
+ const convKey = deriveConversationKeyFromSessionId(sessionId);
1813
+ const active = activeBridges.get(bridgeKey);
1814
+ debugLog("session.cleanup", {
1815
+ sessionId,
1816
+ bridgeKey,
1817
+ convKey,
1818
+ hasActiveBridge: !!active,
1819
+ hadConversation: conversationStates.has(convKey),
1820
+ });
1821
+ if (active) cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
1822
+ conversationStates.delete(convKey);
1823
+ }
1824
+
1825
+ export function deterministicConversationId(convKey: string): string {
1826
+ const hex = createHash("sha256")
1827
+ .update(`cursor-conv-id:${convKey}`)
1828
+ .digest("hex")
1829
+ .slice(0, 32);
1830
+ return [
1831
+ hex.slice(0, 8),
1832
+ hex.slice(8, 12),
1833
+ `4${hex.slice(13, 16)}`,
1834
+ `${(0x8 | (parseInt(hex[16], 16) & 0x3)).toString(16)}${hex.slice(17, 20)}`,
1835
+ hex.slice(20, 32),
1836
+ ].join("-");
1837
+ }
1838
+
1839
+ // ── Thinking tag filter ──
1840
+
1841
+ const THINKING_TAG_NAMES = [
1842
+ "think",
1843
+ "thinking",
1844
+ "reasoning",
1845
+ "thought",
1846
+ "think_intent",
1847
+ ];
1848
+ const MAX_THINKING_TAG_LEN = 16;
1849
+
1850
+ function createThinkingTagFilter() {
1851
+ let buffer = "";
1852
+ let inThinking = false;
1853
+ return {
1854
+ process(text: string) {
1855
+ const input = buffer + text;
1856
+ buffer = "";
1857
+ let content = "";
1858
+ let reasoning = "";
1859
+ let lastIdx = 0;
1860
+ const re = new RegExp(
1861
+ `<(/?)(?:${THINKING_TAG_NAMES.join("|")})\\s*>`,
1862
+ "gi",
1863
+ );
1864
+ let match: RegExpExecArray | null;
1865
+ while ((match = re.exec(input)) !== null) {
1866
+ const before = input.slice(lastIdx, match.index);
1867
+ if (inThinking) reasoning += before;
1868
+ else content += before;
1869
+ inThinking = match[1] !== "/";
1870
+ lastIdx = re.lastIndex;
1871
+ }
1872
+ const rest = input.slice(lastIdx);
1873
+ const ltPos = rest.lastIndexOf("<");
1874
+ if (
1875
+ ltPos >= 0 &&
1876
+ rest.length - ltPos < MAX_THINKING_TAG_LEN &&
1877
+ /^<\/?[a-z_]*$/i.test(rest.slice(ltPos))
1878
+ ) {
1879
+ buffer = rest.slice(ltPos);
1880
+ const before = rest.slice(0, ltPos);
1881
+ if (inThinking) reasoning += before;
1882
+ else content += before;
1883
+ } else {
1884
+ if (inThinking) reasoning += rest;
1885
+ else content += rest;
1886
+ }
1887
+ return { content, reasoning };
1888
+ },
1889
+ flush() {
1890
+ const b = buffer;
1891
+ buffer = "";
1892
+ if (!b) return { content: "", reasoning: "" };
1893
+ return inThinking
1894
+ ? { content: "", reasoning: b }
1895
+ : { content: b, reasoning: "" };
1896
+ },
1897
+ };
1898
+ }
1899
+
1900
+ // ── Connect frame parser ──
1901
+
1902
+ function createConnectFrameParser(
1903
+ onMessage: (bytes: Uint8Array) => void,
1904
+ onEndStream: (bytes: Uint8Array) => void,
1905
+ ): (incoming: Buffer) => void {
1906
+ let pending = Buffer.alloc(0);
1907
+ return (incoming: Buffer) => {
1908
+ pending = Buffer.concat([pending, incoming]);
1909
+ while (pending.length >= 5) {
1910
+ const flags = pending[0]!;
1911
+ const msgLen = pending.readUInt32BE(1);
1912
+ if (pending.length < 5 + msgLen) break;
1913
+ const messageBytes = pending.subarray(5, 5 + msgLen);
1914
+ pending = pending.subarray(5 + msgLen);
1915
+ if (flags & CONNECT_END_STREAM_FLAG) onEndStream(messageBytes);
1916
+ else onMessage(messageBytes);
1917
+ }
1918
+ };
1919
+ }
1920
+
1921
+ function parseConnectEndStream(data: Uint8Array): Error | null {
1922
+ if (data.length === 0) return null;
1923
+ try {
1924
+ const payload = JSON.parse(new TextDecoder().decode(data));
1925
+ const error = payload?.error;
1926
+ if (error)
1927
+ return new Error(
1928
+ `Connect error ${error.code ?? "unknown"}: ${error.message ?? "Unknown error"}`,
1929
+ );
1930
+ return null;
1931
+ } catch {
1932
+ return null;
1933
+ }
1934
+ }
1935
+
1936
+ function makeHeartbeatBytes(): Uint8Array {
1937
+ const heartbeat = create(AgentClientMessageSchema, {
1938
+ message: {
1939
+ case: "clientHeartbeat",
1940
+ value: create(ClientHeartbeatSchema, {}),
1941
+ },
1942
+ });
1943
+ return frameConnectMessage(toBinary(AgentClientMessageSchema, heartbeat));
1944
+ }
1945
+
1946
+ function computeUsage(state: StreamState) {
1947
+ const completion_tokens = state.outputTokens;
1948
+ const total_tokens = state.totalTokens || completion_tokens;
1949
+ const prompt_tokens = Math.max(0, total_tokens - completion_tokens);
1950
+ return { prompt_tokens, completion_tokens, total_tokens };
1951
+ }
1952
+
1953
+ function respondWithPendingToolCalls(
1954
+ modelId: string,
1955
+ pendingExecs: PendingExec[],
1956
+ stream: boolean,
1957
+ res: ServerResponse,
1958
+ ): void {
1959
+ const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
1960
+ const created = Math.floor(Date.now() / 1000);
1961
+ const toolCalls = pendingExecs.map((exec, index) => ({
1962
+ index,
1963
+ id: exec.toolCallId,
1964
+ type: "function" as const,
1965
+ function: { name: exec.toolName, arguments: exec.decodedArgs },
1966
+ }));
1967
+
1968
+ if (stream) {
1969
+ res.writeHead(200, {
1970
+ "Content-Type": "text/event-stream",
1971
+ "Cache-Control": "no-cache",
1972
+ Connection: "close",
1973
+ });
1974
+ for (const toolCall of toolCalls) {
1975
+ res.write(
1976
+ `data: ${JSON.stringify({
1977
+ id: completionId,
1978
+ object: "chat.completion.chunk",
1979
+ created,
1980
+ model: modelId,
1981
+ choices: [
1982
+ {
1983
+ index: 0,
1984
+ delta: { tool_calls: [toolCall] },
1985
+ finish_reason: null,
1986
+ },
1987
+ ],
1988
+ })}\n\n`,
1989
+ );
1990
+ }
1991
+ res.write(
1992
+ `data: ${JSON.stringify({
1993
+ id: completionId,
1994
+ object: "chat.completion.chunk",
1995
+ created,
1996
+ model: modelId,
1997
+ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
1998
+ })}\n\n`,
1999
+ );
2000
+ res.write("data: [DONE]\n\n");
2001
+ res.end();
2002
+ return;
2003
+ }
2004
+
2005
+ res.writeHead(200, { "Content-Type": "application/json" });
2006
+ res.end(
2007
+ JSON.stringify({
2008
+ id: completionId,
2009
+ object: "chat.completion",
2010
+ created,
2011
+ model: modelId,
2012
+ choices: [
2013
+ {
2014
+ index: 0,
2015
+ message: { role: "assistant", content: null, tool_calls: toolCalls },
2016
+ finish_reason: "tool_calls",
2017
+ },
2018
+ ],
2019
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
2020
+ }),
2021
+ );
2022
+ }
2023
+
2024
+ // ── Streaming response ──
2025
+
2026
+ function startBridge(accessToken: string, requestBytes: Uint8Array) {
2027
+ const bridge = bridgeFactory({
2028
+ accessToken,
2029
+ rpcPath: "/agent.v1.AgentService/Run",
2030
+ });
2031
+ debugLog("bridge.start_run", { requestBytes });
2032
+ bridge.write(frameConnectMessage(requestBytes));
2033
+ const heartbeatTimer = setInterval(
2034
+ () => bridge.write(makeHeartbeatBytes()),
2035
+ 5_000,
2036
+ );
2037
+ // Don't hold the event loop open between heartbeats.
2038
+ heartbeatTimer.unref();
2039
+ return { bridge, heartbeatTimer };
2040
+ }
2041
+
2042
+ function handleStreamingResponse(
2043
+ payload: CursorRequestPayload,
2044
+ accessToken: string,
2045
+ modelId: string,
2046
+ bridgeKey: string,
2047
+ convKey: string,
2048
+ completedTurns: ParsedTurn[],
2049
+ currentTurn: ParsedTurn,
2050
+ req: IncomingMessage,
2051
+ res: ServerResponse,
2052
+ requestId: string,
2053
+ ): void {
2054
+ debugLog("stream.start", { requestId, bridgeKey, convKey, modelId });
2055
+ const { bridge, heartbeatTimer } = startBridge(
2056
+ accessToken,
2057
+ payload.requestBytes,
2058
+ );
2059
+ writeSSEStream(
2060
+ bridge,
2061
+ heartbeatTimer,
2062
+ payload.blobStore,
2063
+ payload.mcpTools,
2064
+ modelId,
2065
+ bridgeKey,
2066
+ convKey,
2067
+ completedTurns,
2068
+ currentTurn,
2069
+ req,
2070
+ res,
2071
+ requestId,
2072
+ );
2073
+ }
2074
+
2075
+ function sendCancelAction(bridge: BridgeHandle): void {
2076
+ debugLog("bridge.cancel_action", {});
2077
+ const action = create(ConversationActionSchema, {
2078
+ action: { case: "cancelAction", value: create(CancelActionSchema, {}) },
2079
+ });
2080
+ const clientMessage = create(AgentClientMessageSchema, {
2081
+ message: { case: "conversationAction", value: action },
2082
+ });
2083
+ bridge.write(
2084
+ frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)),
2085
+ );
2086
+ }
2087
+
2088
+ function cleanupBridge(
2089
+ bridge: BridgeHandle,
2090
+ heartbeatTimer: ReturnType<typeof setInterval>,
2091
+ bridgeKey: string,
2092
+ ): void {
2093
+ debugLog("bridge.cleanup", { bridgeKey, alive: bridge.alive });
2094
+ clearInterval(heartbeatTimer);
2095
+ if (bridge.alive) {
2096
+ sendCancelAction(bridge);
2097
+ bridge.end();
2098
+ }
2099
+ activeBridges.delete(bridgeKey);
2100
+ }
2101
+
2102
+ function writeSSEStream(
2103
+ bridge: BridgeHandle,
2104
+ heartbeatTimer: ReturnType<typeof setInterval>,
2105
+ blobStore: Map<string, Uint8Array>,
2106
+ mcpTools: McpToolDefinition[],
2107
+ modelId: string,
2108
+ bridgeKey: string,
2109
+ convKey: string,
2110
+ completedTurns: ParsedTurn[],
2111
+ currentTurn: ParsedTurn,
2112
+ req: IncomingMessage,
2113
+ res: ServerResponse,
2114
+ requestId?: string,
2115
+ ): void {
2116
+ debugLog("stream.writer_start", {
2117
+ requestId,
2118
+ bridgeKey,
2119
+ convKey,
2120
+ modelId,
2121
+ completedTurnCount: completedTurns.length,
2122
+ currentTurn,
2123
+ });
2124
+ const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
2125
+ const created = Math.floor(Date.now() / 1000);
2126
+
2127
+ res.writeHead(200, {
2128
+ "Content-Type": "text/event-stream",
2129
+ "Cache-Control": "no-cache",
2130
+ Connection: "close",
2131
+ });
2132
+
2133
+ let closed = false;
2134
+ const sendSSE = (data: object) => {
2135
+ if (closed) return;
2136
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
2137
+ };
2138
+ const sendDone = () => {
2139
+ if (closed) return;
2140
+ res.write("data: [DONE]\n\n");
2141
+ };
2142
+ const closeResponse = () => {
2143
+ if (closed) return;
2144
+ closed = true;
2145
+ res.end();
2146
+ };
2147
+
2148
+ const makeChunk = (
2149
+ delta: Record<string, unknown>,
2150
+ finishReason: string | null = null,
2151
+ ) => ({
2152
+ id: completionId,
2153
+ object: "chat.completion.chunk",
2154
+ created,
2155
+ model: modelId,
2156
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
2157
+ });
2158
+
2159
+ const makeUsageChunk = () => {
2160
+ const { prompt_tokens, completion_tokens, total_tokens } =
2161
+ computeUsage(state);
2162
+ return {
2163
+ id: completionId,
2164
+ object: "chat.completion.chunk",
2165
+ created,
2166
+ model: modelId,
2167
+ choices: [],
2168
+ usage: { prompt_tokens, completion_tokens, total_tokens },
2169
+ };
2170
+ };
2171
+
2172
+ const state: StreamState = {
2173
+ toolCallIndex: 0,
2174
+ pendingExecs: [],
2175
+ outputTokens: 0,
2176
+ totalTokens: 0,
2177
+ };
2178
+ const tagFilter = createThinkingTagFilter();
2179
+ let mcpExecReceived = false;
2180
+ let cancelled = false;
2181
+ let latestCheckpoint: Uint8Array | null = null;
2182
+
2183
+ // Detect client disconnect (e.g. user pressed Escape in pi)
2184
+ const onClientClose = () => {
2185
+ if (cancelled || closed) return;
2186
+ debugLog("stream.client_close", { requestId, bridgeKey, convKey });
2187
+ cancelled = true;
2188
+ cleanupBridge(bridge, heartbeatTimer, bridgeKey);
2189
+ closeResponse();
2190
+ };
2191
+ req.on("close", onClientClose);
2192
+ res.on("close", onClientClose);
2193
+
2194
+ const processChunk = createConnectFrameParser(
2195
+ (messageBytes) => {
2196
+ try {
2197
+ const serverMessage = fromBinary(
2198
+ AgentServerMessageSchema,
2199
+ messageBytes,
2200
+ );
2201
+ processServerMessage(
2202
+ serverMessage,
2203
+ blobStore,
2204
+ mcpTools,
2205
+ (data) => bridge.write(data),
2206
+ state,
2207
+ (text, isThinking) => {
2208
+ if (isThinking) {
2209
+ sendSSE(makeChunk({ reasoning_content: text }));
2210
+ } else {
2211
+ const { content, reasoning } = tagFilter.process(text);
2212
+ if (reasoning)
2213
+ sendSSE(makeChunk({ reasoning_content: reasoning }));
2214
+ if (content) {
2215
+ appendAssistantTextToTurn(currentTurn, content);
2216
+ sendSSE(makeChunk({ content }));
2217
+ }
2218
+ }
2219
+ },
2220
+ (exec) => {
2221
+ state.pendingExecs.push(exec);
2222
+ mcpExecReceived = true;
2223
+
2224
+ const flushed = tagFilter.flush();
2225
+ if (flushed.reasoning)
2226
+ sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
2227
+ if (flushed.content) {
2228
+ appendAssistantTextToTurn(currentTurn, flushed.content);
2229
+ sendSSE(makeChunk({ content: flushed.content }));
2230
+ }
2231
+
2232
+ currentTurn.steps.push({
2233
+ kind: "toolCall",
2234
+ toolCallId: exec.toolCallId,
2235
+ toolName: exec.toolName,
2236
+ arguments: parseToolCallArguments(exec.decodedArgs),
2237
+ });
2238
+
2239
+ const toolCallIndex = state.toolCallIndex++;
2240
+ sendSSE(
2241
+ makeChunk({
2242
+ tool_calls: [
2243
+ {
2244
+ index: toolCallIndex,
2245
+ id: exec.toolCallId,
2246
+ type: "function",
2247
+ function: {
2248
+ name: exec.toolName,
2249
+ arguments: exec.decodedArgs,
2250
+ },
2251
+ },
2252
+ ],
2253
+ }),
2254
+ );
2255
+
2256
+ activeBridges.set(bridgeKey, {
2257
+ bridge,
2258
+ heartbeatTimer,
2259
+ blobStore,
2260
+ mcpTools,
2261
+ pendingExecs: state.pendingExecs,
2262
+ currentTurn,
2263
+ });
2264
+ debugLog("stream.tool_call_pause", {
2265
+ requestId,
2266
+ bridgeKey,
2267
+ exec,
2268
+ pendingExecs: state.pendingExecs,
2269
+ currentTurn,
2270
+ });
2271
+
2272
+ sendSSE(makeChunk({}, "tool_calls"));
2273
+ sendDone();
2274
+ closeResponse();
2275
+ },
2276
+ (checkpointBytes) => {
2277
+ latestCheckpoint = checkpointBytes;
2278
+ const stored = conversationStates.get(convKey);
2279
+ if (stored) {
2280
+ stored.checkpoint = checkpointBytes;
2281
+ for (const [k, v] of blobStore) stored.blobStore.set(k, v);
2282
+
2283
+ }
2284
+ debugLog("stream.checkpoint_buffered", {
2285
+ requestId,
2286
+ convKey,
2287
+ checkpointBytes,
2288
+ });
2289
+ },
2290
+ );
2291
+ } catch (err) {
2292
+ console.error(
2293
+ "[cursor-provider] Stream message processing error:",
2294
+ err instanceof Error ? err.message : err,
2295
+ );
2296
+ }
2297
+ },
2298
+ (endStreamBytes) => {
2299
+ const endError = parseConnectEndStream(endStreamBytes);
2300
+ // Always stop heartbeats and unref the bridge regardless of error/success
2301
+ // so the parent process is not kept alive waiting for HTTP/2 END_STREAM.
2302
+ clearInterval(heartbeatTimer);
2303
+ bridge.end();
2304
+ bridge.unref();
2305
+ if (endError) {
2306
+ console.error(
2307
+ `[cursor-provider] Cursor stream error (${modelId}):`,
2308
+ endError.message,
2309
+ );
2310
+ conversationStates.delete(convKey);
2311
+ sendSSE(makeChunk({ content: endError.message }, "error"));
2312
+ sendSSE(makeUsageChunk());
2313
+ sendDone();
2314
+ closeResponse();
2315
+ } else {
2316
+ // Cursor's Connect-level response is complete. Send the SSE response
2317
+ // immediately without waiting for HTTP/2 END_STREAM, which Cursor can
2318
+ // delay by several seconds after the Connect end-stream frame.
2319
+ const flushed = tagFilter.flush();
2320
+ if (flushed.reasoning)
2321
+ sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
2322
+ if (flushed.content) {
2323
+ appendAssistantTextToTurn(currentTurn, flushed.content);
2324
+ sendSSE(makeChunk({ content: flushed.content }));
2325
+ }
2326
+ sendSSE(makeChunk({}, "stop"));
2327
+ sendSSE(makeUsageChunk());
2328
+ sendDone();
2329
+ closeResponse();
2330
+ }
2331
+ },
2332
+ );
2333
+
2334
+ bridge.onData(processChunk);
2335
+
2336
+ bridge.onClose((code) => {
2337
+ debugLog("stream.bridge_close", {
2338
+ requestId,
2339
+ bridgeKey,
2340
+ convKey,
2341
+ code,
2342
+ cancelled,
2343
+ mcpExecReceived,
2344
+ currentTurn,
2345
+ latestCheckpoint,
2346
+ });
2347
+ clearInterval(heartbeatTimer);
2348
+ req.removeListener("close", onClientClose);
2349
+ res.removeListener("close", onClientClose);
2350
+ const stored = conversationStates.get(convKey);
2351
+ if (stored) {
2352
+ for (const [k, v] of blobStore) stored.blobStore.set(k, v);
2353
+ if (!cancelled && latestCheckpoint) {
2354
+ stored.checkpoint = latestCheckpoint;
2355
+ debugLog("stream.checkpoint_committed", { requestId, convKey, stored });
2356
+ }
2357
+ }
2358
+ if (cancelled) return;
2359
+ if (!mcpExecReceived) {
2360
+ const flushed = tagFilter.flush();
2361
+ if (flushed.reasoning)
2362
+ sendSSE(makeChunk({ reasoning_content: flushed.reasoning }));
2363
+ if (flushed.content) {
2364
+ appendAssistantTextToTurn(currentTurn, flushed.content);
2365
+ sendSSE(makeChunk({ content: flushed.content }));
2366
+ }
2367
+ sendSSE(makeChunk({}, "stop"));
2368
+ sendSSE(makeUsageChunk());
2369
+ sendDone();
2370
+ closeResponse();
2371
+ } else if (code !== 0) {
2372
+ sendSSE(makeChunk({ content: "Bridge connection lost" }, "error"));
2373
+ sendSSE(makeUsageChunk());
2374
+ sendDone();
2375
+ closeResponse();
2376
+ activeBridges.delete(bridgeKey);
2377
+ } else {
2378
+ // Bridge closed cleanly after a tool call pause. The HTTP response was
2379
+ // already ended by the MCP exec handler; just ensure cleanup.
2380
+ activeBridges.delete(bridgeKey);
2381
+ closeResponse();
2382
+ }
2383
+ });
2384
+ }
2385
+
2386
+ export function writeSSEStreamForTests(args: {
2387
+ bridge: BridgeHandle;
2388
+ heartbeatTimer: ReturnType<typeof setInterval>;
2389
+ blobStore?: Map<string, Uint8Array>;
2390
+ mcpTools?: McpToolDefinition[];
2391
+ modelId: string;
2392
+ bridgeKey: string;
2393
+ convKey: string;
2394
+ completedTurns: ParsedTurn[];
2395
+ currentTurn: ParsedTurn;
2396
+ req: IncomingMessage;
2397
+ res: ServerResponse;
2398
+ requestId?: string;
2399
+ }): void {
2400
+ writeSSEStream(
2401
+ args.bridge,
2402
+ args.heartbeatTimer,
2403
+ args.blobStore ?? new Map(),
2404
+ args.mcpTools ?? [],
2405
+ args.modelId,
2406
+ args.bridgeKey,
2407
+ args.convKey,
2408
+ args.completedTurns,
2409
+ args.currentTurn,
2410
+ args.req,
2411
+ args.res,
2412
+ args.requestId,
2413
+ );
2414
+ }
2415
+
2416
+ // ── Tool result resume ──
2417
+
2418
+ function handleToolResultResume(
2419
+ active: ActiveBridge,
2420
+ toolResults: ToolResultInfo[],
2421
+ modelId: string,
2422
+ bridgeKey: string,
2423
+ convKey: string,
2424
+ completedTurns: ParsedTurn[],
2425
+ req: IncomingMessage,
2426
+ res: ServerResponse,
2427
+ stream: boolean,
2428
+ requestId?: string,
2429
+ ): void {
2430
+ const {
2431
+ bridge,
2432
+ heartbeatTimer,
2433
+ blobStore,
2434
+ mcpTools,
2435
+ pendingExecs,
2436
+ currentTurn,
2437
+ } = active;
2438
+ debugLog("tool_resume.start", {
2439
+ requestId,
2440
+ bridgeKey,
2441
+ convKey,
2442
+ toolResults,
2443
+ pendingExecs,
2444
+ currentTurn,
2445
+ });
2446
+
2447
+ for (const result of toolResults) {
2448
+ const turnToolStep = currentTurn.steps.find(
2449
+ (step) =>
2450
+ step.kind === "toolCall" && step.toolCallId === result.toolCallId,
2451
+ );
2452
+ if (turnToolStep) {
2453
+ turnToolStep.result = { content: result.content, isError: false };
2454
+ }
2455
+ }
2456
+
2457
+ const turnResults = getTurnToolCallResults(currentTurn);
2458
+ const unresolvedExecs = pendingExecs.filter(
2459
+ (exec) => !turnResults.has(exec.toolCallId),
2460
+ );
2461
+ if (unresolvedExecs.length > 0) {
2462
+ activeBridges.set(bridgeKey, {
2463
+ bridge,
2464
+ heartbeatTimer,
2465
+ blobStore,
2466
+ mcpTools,
2467
+ pendingExecs,
2468
+ currentTurn,
2469
+ });
2470
+ debugLog("tool_resume.partial_wait", {
2471
+ requestId,
2472
+ bridgeKey,
2473
+ unresolvedExecs,
2474
+ currentTurn,
2475
+ });
2476
+ respondWithPendingToolCalls(modelId, unresolvedExecs, stream, res);
2477
+ return;
2478
+ }
2479
+
2480
+ for (const exec of pendingExecs) {
2481
+ const result = turnResults.get(exec.toolCallId);
2482
+ if (!result) continue;
2483
+ const mcpResult = create(McpResultSchema, {
2484
+ result: {
2485
+ case: "success",
2486
+ value: create(McpSuccessSchema, {
2487
+ content: [
2488
+ create(McpToolResultContentItemSchema, {
2489
+ content: {
2490
+ case: "text",
2491
+ value: create(McpTextContentSchema, { text: result.content }),
2492
+ },
2493
+ }),
2494
+ ],
2495
+ isError: false,
2496
+ }),
2497
+ },
2498
+ });
2499
+
2500
+ const execClientMessage = create(ExecClientMessageSchema, {
2501
+ id: exec.execMsgId,
2502
+ execId: exec.execId,
2503
+ message: { case: "mcpResult" as any, value: mcpResult as any },
2504
+ });
2505
+ const clientMessage = create(AgentClientMessageSchema, {
2506
+ message: { case: "execClientMessage", value: execClientMessage },
2507
+ });
2508
+ bridge.write(
2509
+ frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)),
2510
+ );
2511
+ debugLog("tool_resume.sent_result", { requestId, exec, result });
2512
+ }
2513
+
2514
+ // Tool results belong to the same user turn that initiated the tool calls.
2515
+ // parseMessages keeps tool continuations out of completed history, so completedTurns
2516
+ // already reflects the correct history covered before this in-flight turn.
2517
+ writeSSEStream(
2518
+ bridge,
2519
+ heartbeatTimer,
2520
+ blobStore,
2521
+ mcpTools,
2522
+ modelId,
2523
+ bridgeKey,
2524
+ convKey,
2525
+ completedTurns,
2526
+ currentTurn,
2527
+ req,
2528
+ res,
2529
+ requestId,
2530
+ );
2531
+ }
2532
+
2533
+ // ── Non-streaming response ──
2534
+
2535
+ async function handleNonStreamingResponse(
2536
+ payload: CursorRequestPayload,
2537
+ accessToken: string,
2538
+ modelId: string,
2539
+ convKey: string,
2540
+ completedTurns: ParsedTurn[],
2541
+ currentTurn: ParsedTurn,
2542
+ req: IncomingMessage,
2543
+ res: ServerResponse,
2544
+ requestId?: string,
2545
+ ): Promise<void> {
2546
+ debugLog("nonstream.start", {
2547
+ requestId,
2548
+ convKey,
2549
+ modelId,
2550
+ currentTurn,
2551
+ completedTurnCount: completedTurns.length,
2552
+ });
2553
+ const completionId = `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 28)}`;
2554
+ const created = Math.floor(Date.now() / 1000);
2555
+
2556
+ const { bridge, heartbeatTimer } = startBridge(
2557
+ accessToken,
2558
+ payload.requestBytes,
2559
+ );
2560
+ let cancelled = false;
2561
+
2562
+ const onClientClose = () => {
2563
+ if (cancelled) return;
2564
+ debugLog("nonstream.client_close", { requestId, convKey });
2565
+ cancelled = true;
2566
+ clearInterval(heartbeatTimer);
2567
+ if (bridge.alive) {
2568
+ sendCancelAction(bridge);
2569
+ bridge.end();
2570
+ }
2571
+ };
2572
+ req.on("close", onClientClose);
2573
+ res.on("close", onClientClose);
2574
+ const state: StreamState = {
2575
+ toolCallIndex: 0,
2576
+ pendingExecs: [],
2577
+ outputTokens: 0,
2578
+ totalTokens: 0,
2579
+ };
2580
+ const tagFilter = createThinkingTagFilter();
2581
+ let fullText = "";
2582
+ let nonStreamError: Error | null = null;
2583
+ let latestCheckpoint: Uint8Array | null = null;
2584
+
2585
+ return new Promise((resolve) => {
2586
+ bridge.onData(
2587
+ createConnectFrameParser(
2588
+ (messageBytes) => {
2589
+ try {
2590
+ const serverMessage = fromBinary(
2591
+ AgentServerMessageSchema,
2592
+ messageBytes,
2593
+ );
2594
+ processServerMessage(
2595
+ serverMessage,
2596
+ payload.blobStore,
2597
+ payload.mcpTools,
2598
+ (data) => bridge.write(data),
2599
+ state,
2600
+ (text, isThinking) => {
2601
+ if (isThinking) return;
2602
+ const { content } = tagFilter.process(text);
2603
+ fullText += content;
2604
+ appendAssistantTextToTurn(currentTurn, content);
2605
+ },
2606
+ (exec) => {
2607
+ // Non-streaming mode cannot pause for tool calls. Reject each
2608
+ // exec request immediately so Cursor can complete the turn.
2609
+ const execClientMessage = create(ExecClientMessageSchema, {
2610
+ id: exec.execMsgId,
2611
+ execId: exec.execId,
2612
+ message: {
2613
+ case: "mcpResult" as any,
2614
+ value: create(McpResultSchema, {
2615
+ result: {
2616
+ case: "error",
2617
+ value: create(McpErrorSchema, {
2618
+ error:
2619
+ "Tool calls not supported in non-streaming mode",
2620
+ }),
2621
+ },
2622
+ }) as any,
2623
+ },
2624
+ });
2625
+ const clientMessage = create(AgentClientMessageSchema, {
2626
+ message: {
2627
+ case: "execClientMessage",
2628
+ value: execClientMessage,
2629
+ },
2630
+ });
2631
+ bridge.write(
2632
+ frameConnectMessage(
2633
+ toBinary(AgentClientMessageSchema, clientMessage),
2634
+ ),
2635
+ );
2636
+ debugLog("nonstream.exec_rejected", { requestId, exec });
2637
+ },
2638
+ (checkpointBytes) => {
2639
+ latestCheckpoint = checkpointBytes;
2640
+ const stored = conversationStates.get(convKey);
2641
+ if (stored) {
2642
+ stored.checkpoint = checkpointBytes;
2643
+ for (const [k, v] of payload.blobStore)
2644
+ stored.blobStore.set(k, v);
2645
+
2646
+ }
2647
+ debugLog("nonstream.checkpoint_buffered", {
2648
+ requestId,
2649
+ convKey,
2650
+ checkpointBytes,
2651
+ });
2652
+ },
2653
+ );
2654
+ } catch (err) {
2655
+ console.error(
2656
+ "[cursor-provider] Non-stream message processing error:",
2657
+ err instanceof Error ? err.message : err,
2658
+ );
2659
+ }
2660
+ },
2661
+ (endStreamBytes) => {
2662
+ const endError = parseConnectEndStream(endStreamBytes);
2663
+ // Always unref regardless of error/success.
2664
+ clearInterval(heartbeatTimer);
2665
+ bridge.end();
2666
+ bridge.unref();
2667
+ if (endError) {
2668
+ console.error(
2669
+ `[cursor-provider] Cursor non-stream error (${modelId}):`,
2670
+ endError.message,
2671
+ );
2672
+ conversationStates.delete(convKey);
2673
+ nonStreamError = endError;
2674
+ }
2675
+ },
2676
+ ),
2677
+ );
2678
+
2679
+ bridge.onClose(() => {
2680
+ debugLog("nonstream.bridge_close", {
2681
+ requestId,
2682
+ convKey,
2683
+ cancelled,
2684
+ nonStreamError: nonStreamError?.message,
2685
+ currentTurn,
2686
+ latestCheckpoint,
2687
+ });
2688
+ clearInterval(heartbeatTimer);
2689
+ req.removeListener("close", onClientClose);
2690
+ res.removeListener("close", onClientClose);
2691
+ const stored = conversationStates.get(convKey);
2692
+ if (stored) {
2693
+ for (const [k, v] of payload.blobStore) stored.blobStore.set(k, v);
2694
+ if (!cancelled && !nonStreamError && latestCheckpoint) {
2695
+ stored.checkpoint = latestCheckpoint;
2696
+ debugLog("nonstream.checkpoint_committed", {
2697
+ requestId,
2698
+ convKey,
2699
+ stored,
2700
+ });
2701
+ }
2702
+ }
2703
+
2704
+ if (cancelled) {
2705
+ if (!res.headersSent) {
2706
+ res.writeHead(499, { "Content-Type": "application/json" });
2707
+ res.end(
2708
+ JSON.stringify({
2709
+ error: {
2710
+ message: "Client closed request",
2711
+ type: "aborted",
2712
+ code: "client_closed",
2713
+ },
2714
+ }),
2715
+ );
2716
+ }
2717
+ resolve();
2718
+ return;
2719
+ }
2720
+
2721
+ if (nonStreamError) {
2722
+ res.writeHead(502, { "Content-Type": "application/json" });
2723
+ res.end(
2724
+ JSON.stringify({
2725
+ error: {
2726
+ message: nonStreamError.message,
2727
+ type: "upstream_error",
2728
+ code: "cursor_error",
2729
+ },
2730
+ }),
2731
+ );
2732
+ resolve();
2733
+ return;
2734
+ }
2735
+
2736
+ const flushed = tagFilter.flush();
2737
+ fullText += flushed.content;
2738
+ appendAssistantTextToTurn(currentTurn, flushed.content);
2739
+ const usage = computeUsage(state);
2740
+
2741
+ res.writeHead(200, { "Content-Type": "application/json" });
2742
+ res.end(
2743
+ JSON.stringify({
2744
+ id: completionId,
2745
+ object: "chat.completion",
2746
+ created,
2747
+ model: modelId,
2748
+ choices: [
2749
+ {
2750
+ index: 0,
2751
+ message: { role: "assistant", content: fullText },
2752
+ finish_reason: "stop",
2753
+ },
2754
+ ],
2755
+ usage,
2756
+ }),
2757
+ );
2758
+ resolve();
2759
+ });
2760
+ });
2761
+ }