@offbynan/pi-cursor-provider 0.5.0 → 0.5.2
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/h2-bridge.mjs +18 -1
- package/package.json +1 -1
- package/proxy.ts +150 -16
package/h2-bridge.mjs
CHANGED
|
@@ -105,12 +105,14 @@ function resetTimeout() {
|
|
|
105
105
|
|
|
106
106
|
function killBridge() {
|
|
107
107
|
clearTimeout(timeout);
|
|
108
|
+
process.stderr.write(JSON.stringify({ type: "exit_reason", reason: "timeout" }) + "\n");
|
|
108
109
|
client.destroy();
|
|
109
|
-
process.exit(
|
|
110
|
+
process.exit(2);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
client.on("error", () => {
|
|
113
114
|
clearTimeout(timeout);
|
|
115
|
+
process.stderr.write(JSON.stringify({ type: "exit_reason", reason: "connection_error" }) + "\n");
|
|
114
116
|
process.exit(1);
|
|
115
117
|
});
|
|
116
118
|
|
|
@@ -130,6 +132,20 @@ if (!unary) {
|
|
|
130
132
|
}
|
|
131
133
|
const h2Stream = client.request(headers);
|
|
132
134
|
|
|
135
|
+
// Read response headers: switch to activity timeout and forward status to stderr
|
|
136
|
+
h2Stream.on("response", (headers) => {
|
|
137
|
+
resetTimeout();
|
|
138
|
+
const status = headers[":status"] ?? null;
|
|
139
|
+
const grpcStatus = headers["grpc-status"] ?? null;
|
|
140
|
+
process.stderr.write(
|
|
141
|
+
JSON.stringify({
|
|
142
|
+
type: "response_headers",
|
|
143
|
+
status: status !== null ? Number(status) : null,
|
|
144
|
+
grpcStatus: grpcStatus !== null ? Number(grpcStatus) : null,
|
|
145
|
+
}) + "\n",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
133
149
|
// Forward H2 response data → stdout (length-prefixed)
|
|
134
150
|
h2Stream.on("data", (chunk) => {
|
|
135
151
|
resetTimeout();
|
|
@@ -145,6 +161,7 @@ h2Stream.on("end", () => {
|
|
|
145
161
|
|
|
146
162
|
h2Stream.on("error", () => {
|
|
147
163
|
clearTimeout(timeout);
|
|
164
|
+
process.stderr.write(JSON.stringify({ type: "exit_reason", reason: "stream_error" }) + "\n");
|
|
148
165
|
client.close();
|
|
149
166
|
process.exit(1);
|
|
150
167
|
});
|
package/package.json
CHANGED
package/proxy.ts
CHANGED
|
@@ -154,6 +154,11 @@ interface PendingExec {
|
|
|
154
154
|
decodedArgs: string;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
interface StderrData {
|
|
158
|
+
responseHeaders?: { status: number; grpcStatus: number | null };
|
|
159
|
+
exitReason?: "timeout" | "connection_error" | "stream_error";
|
|
160
|
+
}
|
|
161
|
+
|
|
157
162
|
interface BridgeHandle {
|
|
158
163
|
proc: Pick<ChildProcess, "kill">;
|
|
159
164
|
readonly alive: boolean;
|
|
@@ -162,6 +167,7 @@ interface BridgeHandle {
|
|
|
162
167
|
unref(): void;
|
|
163
168
|
onData(cb: (chunk: Buffer) => void): void;
|
|
164
169
|
onClose(cb: (code: number) => void): void;
|
|
170
|
+
getStderr(): StderrData;
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
export type BridgeFactory = (options: SpawnBridgeOptions) => BridgeHandle;
|
|
@@ -263,6 +269,7 @@ interface ParsedMessages {
|
|
|
263
269
|
// ── State ──
|
|
264
270
|
|
|
265
271
|
const activeBridges = new Map<string, ActiveBridge>();
|
|
272
|
+
const sessionBridges = new Map<string, BridgeHandle>();
|
|
266
273
|
const conversationStates = new Map<string, StoredConversation>();
|
|
267
274
|
let bridgeFactory: BridgeFactory = spawnBridge;
|
|
268
275
|
let debugRequestCounter = 0;
|
|
@@ -353,6 +360,7 @@ function nextDebugRequestId(): string {
|
|
|
353
360
|
|
|
354
361
|
export const __testInternals = {
|
|
355
362
|
activeBridges,
|
|
363
|
+
sessionBridges,
|
|
356
364
|
conversationStates,
|
|
357
365
|
};
|
|
358
366
|
|
|
@@ -395,7 +403,33 @@ function spawnBridge(options: SpawnBridgeOptions): BridgeHandle {
|
|
|
395
403
|
unary: options.unary ?? false,
|
|
396
404
|
});
|
|
397
405
|
const proc = spawn("node", [BRIDGE_PATH], {
|
|
398
|
-
stdio: ["pipe", "pipe", "
|
|
406
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const stderrData: StderrData = {};
|
|
410
|
+
let stderrBuf = "";
|
|
411
|
+
proc.stderr!.on("data", (chunk: Buffer) => {
|
|
412
|
+
stderrBuf += chunk.toString("utf8");
|
|
413
|
+
let nl: number;
|
|
414
|
+
while ((nl = stderrBuf.indexOf("\n")) !== -1) {
|
|
415
|
+
const line = stderrBuf.slice(0, nl).trim();
|
|
416
|
+
stderrBuf = stderrBuf.slice(nl + 1);
|
|
417
|
+
if (!line) continue;
|
|
418
|
+
debugLog("bridge.stderr", { rpcPath: options.rpcPath, line });
|
|
419
|
+
try {
|
|
420
|
+
const parsed = JSON.parse(line) as Record<string, unknown>;
|
|
421
|
+
if (parsed["type"] === "response_headers") {
|
|
422
|
+
stderrData.responseHeaders = {
|
|
423
|
+
status: parsed["status"] as number,
|
|
424
|
+
grpcStatus: parsed["grpcStatus"] as number | null,
|
|
425
|
+
};
|
|
426
|
+
} else if (parsed["type"] === "exit_reason") {
|
|
427
|
+
stderrData.exitReason = parsed["reason"] as StderrData["exitReason"];
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
// not structured JSON — ignore
|
|
431
|
+
}
|
|
432
|
+
}
|
|
399
433
|
});
|
|
400
434
|
|
|
401
435
|
const config = JSON.stringify({
|
|
@@ -433,6 +467,7 @@ function spawnBridge(options: SpawnBridgeOptions): BridgeHandle {
|
|
|
433
467
|
// loop alive after the bridge exits (critical for `pi -p` to exit cleanly).
|
|
434
468
|
try { proc.stdout!.destroy(); } catch {}
|
|
435
469
|
try { proc.stdin!.destroy(); } catch {}
|
|
470
|
+
try { proc.stderr!.destroy(); } catch {}
|
|
436
471
|
debugLog("bridge.exit", { rpcPath: options.rpcPath, exitCode });
|
|
437
472
|
cbs.close?.(exitCode);
|
|
438
473
|
});
|
|
@@ -469,9 +504,43 @@ function spawnBridge(options: SpawnBridgeOptions): BridgeHandle {
|
|
|
469
504
|
cbs.close = cb;
|
|
470
505
|
}
|
|
471
506
|
},
|
|
507
|
+
getStderr() {
|
|
508
|
+
return stderrData;
|
|
509
|
+
},
|
|
472
510
|
};
|
|
473
511
|
}
|
|
474
512
|
|
|
513
|
+
// ── Bridge failure classification ──
|
|
514
|
+
|
|
515
|
+
function classifyBridgeFailure(code: number, stderr: StderrData): string {
|
|
516
|
+
const hadHeaders = !!stderr.responseHeaders;
|
|
517
|
+
const status = stderr.responseHeaders?.status;
|
|
518
|
+
const reason = stderr.exitReason;
|
|
519
|
+
|
|
520
|
+
// Timeout (exit 2 or explicit reason)
|
|
521
|
+
if (code === 2 || reason === "timeout") {
|
|
522
|
+
return hadHeaders
|
|
523
|
+
? "Cursor did not respond within 5 minutes — try again"
|
|
524
|
+
: "Could not reach Cursor's API within 2 minutes — check your network";
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Connection or stream error (exit 1)
|
|
528
|
+
if (status === 401 || status === 403) {
|
|
529
|
+
return "Cursor authentication expired — run /login cursor to re-authenticate";
|
|
530
|
+
}
|
|
531
|
+
if (status === 429) {
|
|
532
|
+
return "Cursor rate limited — try again shortly";
|
|
533
|
+
}
|
|
534
|
+
if (status !== undefined && status >= 500 && status < 600) {
|
|
535
|
+
return `Cursor server error (${status}) — try again`;
|
|
536
|
+
}
|
|
537
|
+
if (!hadHeaders) {
|
|
538
|
+
return "Could not connect to Cursor's API — check your network";
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return `Cursor bridge terminated (exit ${code}) before response — try again or shorten the conversation`;
|
|
542
|
+
}
|
|
543
|
+
|
|
475
544
|
// ── Unary RPC (for model discovery) ──
|
|
476
545
|
|
|
477
546
|
export async function callCursorUnaryRpc(options: {
|
|
@@ -758,6 +827,7 @@ export function cleanupAllSessionState(): void {
|
|
|
758
827
|
for (const [bridgeKey, active] of activeBridges) {
|
|
759
828
|
cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
|
|
760
829
|
}
|
|
830
|
+
sessionBridges.clear();
|
|
761
831
|
conversationStates.clear();
|
|
762
832
|
}
|
|
763
833
|
|
|
@@ -886,9 +956,7 @@ async function handleChatCompletion(
|
|
|
886
956
|
}
|
|
887
957
|
|
|
888
958
|
if (activeBridge && activeBridges.has(bridgeKey)) {
|
|
889
|
-
|
|
890
|
-
activeBridge.bridge.end();
|
|
891
|
-
activeBridges.delete(bridgeKey);
|
|
959
|
+
cleanupBridge(activeBridge.bridge, activeBridge.heartbeatTimer, bridgeKey);
|
|
892
960
|
}
|
|
893
961
|
|
|
894
962
|
let stored = conversationStates.get(convKey);
|
|
@@ -942,11 +1010,12 @@ async function handleChatCompletion(
|
|
|
942
1010
|
};
|
|
943
1011
|
|
|
944
1012
|
if (body.stream === false) {
|
|
945
|
-
debugLog("chat.dispatch_nonstream", { requestId, convKey });
|
|
1013
|
+
debugLog("chat.dispatch_nonstream", { requestId, bridgeKey, convKey });
|
|
946
1014
|
await handleNonStreamingResponse(
|
|
947
1015
|
payload,
|
|
948
1016
|
accessToken,
|
|
949
1017
|
modelId,
|
|
1018
|
+
bridgeKey,
|
|
950
1019
|
convKey,
|
|
951
1020
|
turns,
|
|
952
1021
|
currentTurn,
|
|
@@ -1584,7 +1653,7 @@ export function buildCursorRequest(
|
|
|
1584
1653
|
turns: turnBlobIds,
|
|
1585
1654
|
todos: [],
|
|
1586
1655
|
pendingToolCalls: [],
|
|
1587
|
-
previousWorkspaceUris: [
|
|
1656
|
+
previousWorkspaceUris: [],
|
|
1588
1657
|
mode: 1,
|
|
1589
1658
|
fileStates: {},
|
|
1590
1659
|
fileStatesV2: {},
|
|
@@ -2165,15 +2234,42 @@ function createConnectFrameParser(
|
|
|
2165
2234
|
};
|
|
2166
2235
|
}
|
|
2167
2236
|
|
|
2237
|
+
const CONTEXT_OVERFLOW_MSG =
|
|
2238
|
+
"context length exceeded — Cursor rejected the request as too large";
|
|
2239
|
+
|
|
2240
|
+
function isContextOverflowMessage(msg: string): boolean {
|
|
2241
|
+
return /context|token|length|overflow|too.?long|too.?large/i.test(msg);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
function mapConnectErrorCode(code: string, message: string): string {
|
|
2245
|
+
switch (code) {
|
|
2246
|
+
case "unauthenticated":
|
|
2247
|
+
return "Cursor authentication expired — run /login cursor";
|
|
2248
|
+
case "resource_exhausted":
|
|
2249
|
+
return CONTEXT_OVERFLOW_MSG;
|
|
2250
|
+
case "deadline_exceeded":
|
|
2251
|
+
return "Cursor request timed out server-side — try again";
|
|
2252
|
+
case "unavailable":
|
|
2253
|
+
return "Cursor service unavailable — try again";
|
|
2254
|
+
case "internal":
|
|
2255
|
+
return "Cursor internal error — try again";
|
|
2256
|
+
case "invalid_argument":
|
|
2257
|
+
return isContextOverflowMessage(message) ? CONTEXT_OVERFLOW_MSG : message;
|
|
2258
|
+
default:
|
|
2259
|
+
return message;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2168
2263
|
function parseConnectEndStream(data: Uint8Array): Error | null {
|
|
2169
2264
|
if (data.length === 0) return null;
|
|
2170
2265
|
try {
|
|
2171
2266
|
const payload = JSON.parse(new TextDecoder().decode(data));
|
|
2172
2267
|
const error = payload?.error;
|
|
2173
|
-
if (error)
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
);
|
|
2268
|
+
if (error) {
|
|
2269
|
+
const code = String(error.code ?? "unknown");
|
|
2270
|
+
const rawMessage = String(error.message ?? "Unknown error");
|
|
2271
|
+
return new Error(mapConnectErrorCode(code, rawMessage));
|
|
2272
|
+
}
|
|
2177
2273
|
return null;
|
|
2178
2274
|
} catch {
|
|
2179
2275
|
return null;
|
|
@@ -2285,12 +2381,25 @@ function respondWithPendingToolCalls(
|
|
|
2285
2381
|
|
|
2286
2382
|
// ── Streaming response ──
|
|
2287
2383
|
|
|
2288
|
-
function startBridge(accessToken: string, requestBytes: Uint8Array) {
|
|
2384
|
+
function startBridge(accessToken: string, requestBytes: Uint8Array, bridgeKey: string) {
|
|
2385
|
+
let staleBridgeKilled = false;
|
|
2386
|
+
const existing = sessionBridges.get(bridgeKey);
|
|
2387
|
+
if (existing) {
|
|
2388
|
+
if (existing.alive) {
|
|
2389
|
+
console.error(
|
|
2390
|
+
`[cursor-provider] Stale bridge detected for session ${bridgeKey} — force-killing and replacing`,
|
|
2391
|
+
);
|
|
2392
|
+
staleBridgeKilled = true;
|
|
2393
|
+
try { existing.proc.kill(); } catch {}
|
|
2394
|
+
}
|
|
2395
|
+
sessionBridges.delete(bridgeKey);
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2289
2398
|
const bridge = bridgeFactory({
|
|
2290
2399
|
accessToken,
|
|
2291
2400
|
rpcPath: "/agent.v1.AgentService/Run",
|
|
2292
2401
|
});
|
|
2293
|
-
debugLog("bridge.start_run", { requestBytes });
|
|
2402
|
+
debugLog("bridge.start_run", { requestBytes, bridgeKey, staleBridgeKilled });
|
|
2294
2403
|
bridge.write(frameConnectMessage(requestBytes));
|
|
2295
2404
|
const heartbeatTimer = setInterval(
|
|
2296
2405
|
() => bridge.write(makeHeartbeatBytes()),
|
|
@@ -2298,7 +2407,8 @@ function startBridge(accessToken: string, requestBytes: Uint8Array) {
|
|
|
2298
2407
|
);
|
|
2299
2408
|
// Don't hold the event loop open between heartbeats.
|
|
2300
2409
|
heartbeatTimer.unref();
|
|
2301
|
-
|
|
2410
|
+
sessionBridges.set(bridgeKey, bridge);
|
|
2411
|
+
return { bridge, heartbeatTimer, staleBridgeKilled };
|
|
2302
2412
|
}
|
|
2303
2413
|
|
|
2304
2414
|
function handleStreamingResponse(
|
|
@@ -2314,9 +2424,10 @@ function handleStreamingResponse(
|
|
|
2314
2424
|
requestId: string,
|
|
2315
2425
|
): void {
|
|
2316
2426
|
debugLog("stream.start", { requestId, bridgeKey, convKey, modelId });
|
|
2317
|
-
const { bridge, heartbeatTimer } = startBridge(
|
|
2427
|
+
const { bridge, heartbeatTimer, staleBridgeKilled } = startBridge(
|
|
2318
2428
|
accessToken,
|
|
2319
2429
|
payload.requestBytes,
|
|
2430
|
+
bridgeKey,
|
|
2320
2431
|
);
|
|
2321
2432
|
writeSSEStream(
|
|
2322
2433
|
bridge,
|
|
@@ -2331,6 +2442,7 @@ function handleStreamingResponse(
|
|
|
2331
2442
|
req,
|
|
2332
2443
|
res,
|
|
2333
2444
|
requestId,
|
|
2445
|
+
staleBridgeKilled,
|
|
2334
2446
|
);
|
|
2335
2447
|
}
|
|
2336
2448
|
|
|
@@ -2357,7 +2469,9 @@ function cleanupBridge(
|
|
|
2357
2469
|
if (bridge.alive) {
|
|
2358
2470
|
sendCancelAction(bridge);
|
|
2359
2471
|
bridge.end();
|
|
2472
|
+
setTimeout(() => { try { bridge.proc.kill(); } catch {} }, 10_000);
|
|
2360
2473
|
}
|
|
2474
|
+
if (sessionBridges.get(bridgeKey) === bridge) sessionBridges.delete(bridgeKey);
|
|
2361
2475
|
activeBridges.delete(bridgeKey);
|
|
2362
2476
|
}
|
|
2363
2477
|
|
|
@@ -2374,6 +2488,7 @@ function writeSSEStream(
|
|
|
2374
2488
|
req: IncomingMessage,
|
|
2375
2489
|
res: ServerResponse,
|
|
2376
2490
|
requestId?: string,
|
|
2491
|
+
staleBridgeKilled = false,
|
|
2377
2492
|
): void {
|
|
2378
2493
|
debugLog("stream.writer_start", {
|
|
2379
2494
|
requestId,
|
|
@@ -2391,6 +2506,17 @@ function writeSSEStream(
|
|
|
2391
2506
|
"Cache-Control": "no-cache",
|
|
2392
2507
|
Connection: "close",
|
|
2393
2508
|
});
|
|
2509
|
+
if (staleBridgeKilled) {
|
|
2510
|
+
res.write(
|
|
2511
|
+
`data: ${JSON.stringify({
|
|
2512
|
+
id: completionId,
|
|
2513
|
+
object: "chat.completion.chunk",
|
|
2514
|
+
created,
|
|
2515
|
+
model: modelId,
|
|
2516
|
+
choices: [{ index: 0, delta: { content: "\u26a0 A previous request for this session was still running and has been cancelled.\n" }, finish_reason: null }],
|
|
2517
|
+
})}\n\n`,
|
|
2518
|
+
);
|
|
2519
|
+
}
|
|
2394
2520
|
|
|
2395
2521
|
let closed = false;
|
|
2396
2522
|
let keepAliveTimer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -2620,6 +2746,7 @@ function writeSSEStream(
|
|
|
2620
2746
|
latestCheckpoint,
|
|
2621
2747
|
});
|
|
2622
2748
|
clearInterval(heartbeatTimer);
|
|
2749
|
+
if (sessionBridges.get(bridgeKey) === bridge) sessionBridges.delete(bridgeKey);
|
|
2623
2750
|
req.removeListener("close", onClientClose);
|
|
2624
2751
|
res.removeListener("close", onClientClose);
|
|
2625
2752
|
const stored = conversationStates.get(convKey);
|
|
@@ -2643,7 +2770,8 @@ function writeSSEStream(
|
|
|
2643
2770
|
console.error(
|
|
2644
2771
|
`[cursor-provider] Bridge exited (code ${code}) before receiving response (${modelId})`,
|
|
2645
2772
|
);
|
|
2646
|
-
|
|
2773
|
+
const failureMsg = classifyBridgeFailure(code, bridge.getStderr());
|
|
2774
|
+
sendSSE(makeChunk({ content: failureMsg }, "error"));
|
|
2647
2775
|
sendSSE(makeUsageChunk());
|
|
2648
2776
|
sendDone();
|
|
2649
2777
|
closeResponse();
|
|
@@ -2828,6 +2956,7 @@ async function handleNonStreamingResponse(
|
|
|
2828
2956
|
payload: CursorRequestPayload,
|
|
2829
2957
|
accessToken: string,
|
|
2830
2958
|
modelId: string,
|
|
2959
|
+
bridgeKey: string,
|
|
2831
2960
|
convKey: string,
|
|
2832
2961
|
completedTurns: ParsedTurn[],
|
|
2833
2962
|
currentTurn: ParsedTurn,
|
|
@@ -2837,6 +2966,7 @@ async function handleNonStreamingResponse(
|
|
|
2837
2966
|
): Promise<void> {
|
|
2838
2967
|
debugLog("nonstream.start", {
|
|
2839
2968
|
requestId,
|
|
2969
|
+
bridgeKey,
|
|
2840
2970
|
convKey,
|
|
2841
2971
|
modelId,
|
|
2842
2972
|
currentTurn,
|
|
@@ -2848,6 +2978,7 @@ async function handleNonStreamingResponse(
|
|
|
2848
2978
|
const { bridge, heartbeatTimer } = startBridge(
|
|
2849
2979
|
accessToken,
|
|
2850
2980
|
payload.requestBytes,
|
|
2981
|
+
bridgeKey,
|
|
2851
2982
|
);
|
|
2852
2983
|
let cancelled = false;
|
|
2853
2984
|
|
|
@@ -2973,6 +3104,7 @@ async function handleNonStreamingResponse(
|
|
|
2973
3104
|
bridge.onClose((code) => {
|
|
2974
3105
|
debugLog("nonstream.bridge_close", {
|
|
2975
3106
|
requestId,
|
|
3107
|
+
bridgeKey,
|
|
2976
3108
|
convKey,
|
|
2977
3109
|
code,
|
|
2978
3110
|
cancelled,
|
|
@@ -2981,6 +3113,7 @@ async function handleNonStreamingResponse(
|
|
|
2981
3113
|
latestCheckpoint,
|
|
2982
3114
|
});
|
|
2983
3115
|
clearInterval(heartbeatTimer);
|
|
3116
|
+
if (sessionBridges.get(bridgeKey) === bridge) sessionBridges.delete(bridgeKey);
|
|
2984
3117
|
req.removeListener("close", onClientClose);
|
|
2985
3118
|
res.removeListener("close", onClientClose);
|
|
2986
3119
|
const stored = conversationStates.get(convKey);
|
|
@@ -3035,11 +3168,12 @@ async function handleNonStreamingResponse(
|
|
|
3035
3168
|
console.error(
|
|
3036
3169
|
`[cursor-provider] Bridge exited (code ${code}) before non-stream response (${modelId})`,
|
|
3037
3170
|
);
|
|
3171
|
+
const failureMsg = classifyBridgeFailure(code, bridge.getStderr());
|
|
3038
3172
|
res.writeHead(502, { "Content-Type": "application/json" });
|
|
3039
3173
|
res.end(
|
|
3040
3174
|
JSON.stringify({
|
|
3041
3175
|
error: {
|
|
3042
|
-
message:
|
|
3176
|
+
message: failureMsg,
|
|
3043
3177
|
type: "upstream_error",
|
|
3044
3178
|
code: "bridge_terminated",
|
|
3045
3179
|
},
|