@offbynan/pi-cursor-provider 0.5.1 → 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.
Files changed (3) hide show
  1. package/h2-bridge.mjs +18 -1
  2. package/package.json +1 -1
  3. package/proxy.ts +149 -15
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(1);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@offbynan/pi-cursor-provider",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Pi extension providing access to Cursor models via OAuth and a local OpenAI-compatible gRPC proxy",
5
5
  "type": "module",
6
6
  "license": "MIT",
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", "ignore"],
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
- clearInterval(activeBridge.heartbeatTimer);
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,
@@ -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
- return new Error(
2175
- `Connect error ${error.code ?? "unknown"}: ${error.message ?? "Unknown error"}`,
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
- return { bridge, heartbeatTimer };
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
- sendSSE(makeChunk({ content: `Cursor bridge terminated (exit ${code}) before response — try again or shorten the conversation` }, "error"));
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: `Cursor bridge terminated (exit ${code}) before response — try again or shorten the conversation`,
3176
+ message: failureMsg,
3043
3177
  type: "upstream_error",
3044
3178
  code: "bridge_terminated",
3045
3179
  },