pi-provider-cursor-ask 0.1.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/README.zh-CN.md +87 -0
  5. package/UPSTREAM_CHANGELOG.md +368 -0
  6. package/UPSTREAM_SOURCE.md +23 -0
  7. package/dist/index.js +54 -0
  8. package/package.json +97 -0
  9. package/src/auth/cli-credentials.ts +275 -0
  10. package/src/auth/consent.ts +25 -0
  11. package/src/auth/index.ts +23 -0
  12. package/src/auth/oauth.ts +282 -0
  13. package/src/auth/refresh-guard.ts +93 -0
  14. package/src/client/bridge.ts +673 -0
  15. package/src/client/cursor-wire.ts +213 -0
  16. package/src/client/h2-unary.ts +142 -0
  17. package/src/client/index.ts +18 -0
  18. package/src/config/index.ts +69 -0
  19. package/src/diagnostics/diagnostics.ts +116 -0
  20. package/src/diagnostics/index.ts +1 -0
  21. package/src/extension/auth.ts +99 -0
  22. package/src/extension/commands.ts +163 -0
  23. package/src/extension/compaction-guard.ts +86 -0
  24. package/src/extension/debug-hooks.ts +359 -0
  25. package/src/extension/index.ts +8 -0
  26. package/src/extension/provider.ts +277 -0
  27. package/src/extension/quota-adapter.ts +175 -0
  28. package/src/extension/report-dashboard.ts +133 -0
  29. package/src/identity.ts +16 -0
  30. package/src/index.ts +186 -0
  31. package/src/models/ask-catalog.ts +384 -0
  32. package/src/models/catalog.json +1163 -0
  33. package/src/models/cost.ts +126 -0
  34. package/src/models/index.ts +6 -0
  35. package/src/models/limits.ts +36 -0
  36. package/src/models/parameterized.ts +416 -0
  37. package/src/models/processing.ts +313 -0
  38. package/src/proto/agent_pb.ts +14577 -0
  39. package/src/stream/bridge-session.ts +215 -0
  40. package/src/stream/client-transcript.ts +51 -0
  41. package/src/stream/config.ts +5 -0
  42. package/src/stream/context-normalize.ts +308 -0
  43. package/src/stream/context-usage.ts +168 -0
  44. package/src/stream/debug-log.ts +316 -0
  45. package/src/stream/drift.ts +122 -0
  46. package/src/stream/images.ts +201 -0
  47. package/src/stream/index.ts +68 -0
  48. package/src/stream/interaction-query.ts +369 -0
  49. package/src/stream/message-parsing.ts +402 -0
  50. package/src/stream/model-cache.ts +100 -0
  51. package/src/stream/model-discovery.ts +242 -0
  52. package/src/stream/model-routing.ts +100 -0
  53. package/src/stream/native-core.ts +2121 -0
  54. package/src/stream/pi-adapter.ts +414 -0
  55. package/src/stream/protocol.ts +63 -0
  56. package/src/stream/recovery.ts +494 -0
  57. package/src/stream/request-build.ts +668 -0
  58. package/src/stream/root-prompt.ts +184 -0
  59. package/src/stream/run-journal.ts +474 -0
  60. package/src/stream/run-usage.ts +107 -0
  61. package/src/stream/server-messages.ts +777 -0
  62. package/src/stream/session-state.ts +499 -0
  63. package/src/stream/stream-writer.ts +211 -0
  64. package/src/stream/thinking-filter.ts +63 -0
  65. package/src/stream/tool-schema.ts +185 -0
  66. package/src/stream/transport-errors.ts +150 -0
  67. package/src/stream/tuning.ts +250 -0
  68. package/src/stream/types.ts +330 -0
  69. package/src/types/enums.ts +103 -0
  70. package/src/types/index.ts +4 -0
  71. package/src/usage.ts +262 -0
  72. package/src/utils/cache-dir.ts +39 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/security.ts +68 -0
  75. package/src/utils/util.ts +43 -0
@@ -0,0 +1,673 @@
1
+ import http2 from "node:http2";
2
+ import { randomUUID } from "node:crypto";
3
+
4
+ import { getCursorClientVersion } from "../config/index.js";
5
+ import { ConnectFlag } from "../types/enums.js";
6
+
7
+ const CURSOR_API_URL = "https://api2.cursor.sh";
8
+ const DEFAULT_RPC_PATH = "/agent.v1.AgentService/Run";
9
+ const CONNECT_END_STREAM_FLAG = ConnectFlag.EndStream;
10
+ const MAX_ERROR_BODY_BYTES = 1024 * 1024;
11
+ const DEFAULT_CLOSE_GRACE_MS = 100;
12
+ export const MAX_BRIDGE_MESSAGE_BYTES = 64 * 1024 * 1024;
13
+ export const MAX_CONNECT_MESSAGE_BYTES = 64 * 1024 * 1024;
14
+
15
+ export interface CreateBridgeOptions {
16
+ accessToken: string;
17
+ rpcPath: string;
18
+ url?: string;
19
+ /** Cursor tool types allowed for this Run. Undefined omits the header; [] disables tools. */
20
+ allowedTools?: readonly string[];
21
+ /** Initial HTTP/2 connection timeout (ms). Default 30s. */
22
+ connectTimeoutMs?: number;
23
+ /** Activity idle timeout after connection (ms). Default disabled. */
24
+ idleTimeoutMs?: number;
25
+ /** HTTP/2 ping interval (ms). Default 20s. Intended primarily for tests. */
26
+ pingIntervalMs?: number;
27
+ /** Grace period before a clean close is force-destroyed. Default 100ms. Intended for tests. */
28
+ closeGraceMs?: number;
29
+ }
30
+
31
+ export interface BridgeHandle {
32
+ readonly alive: boolean;
33
+ /** Whether this HTTP/2 session can accept another Run stream. */
34
+ readonly reusable: boolean;
35
+ /** Trailing in-process transport diagnostics (for diagnostics / recovery). */
36
+ lastStderr(): string;
37
+ write(data: Uint8Array): void;
38
+ /** Gracefully end the current stream and close the HTTP/2 session with code 0. */
39
+ end(): void;
40
+ /** Immediately tear down the HTTP/2 session with code 1. */
41
+ kill(): void;
42
+ onData(cb: (chunk: Buffer) => void): void;
43
+ onClose(cb: (code: number) => void): void;
44
+ /** Open another Connect stream on the same HTTP/2 session. */
45
+ openStream(accessToken: string, allowedTools?: readonly string[]): void;
46
+ /** Fires when the current stream receives a normal 2xx response end. */
47
+ onStreamDone(cb: () => void): void;
48
+ }
49
+
50
+ export type BridgeFactory = (options: CreateBridgeOptions) => BridgeHandle;
51
+ export type BridgeDebugLog = (event: string, data?: Record<string, unknown>) => void;
52
+
53
+ function noopDebugLog(): void {}
54
+
55
+ function optionalMs(value: number | undefined, fallback: number): number {
56
+ if (value === undefined) return fallback;
57
+ if (!Number.isFinite(value) || value < 0) return fallback;
58
+ return value === 0 ? 0 : Math.floor(value);
59
+ }
60
+
61
+ function connectEndStreamError(code: string, message: string): Buffer {
62
+ return frameConnectMessage(
63
+ Buffer.from(JSON.stringify({ error: { code, message } }), "utf8"),
64
+ CONNECT_END_STREAM_FLAG,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Accumulates incoming chunks without re-concatenating the whole backlog on every chunk.
70
+ * Buffering chunks in an array and only concatenating once enough bytes are available keeps
71
+ * total frame-reassembly work O(n).
72
+ */
73
+ class FrameAccumulator {
74
+ private chunks: Buffer[] = [];
75
+ private totalLength = 0;
76
+
77
+ push(chunk: Buffer): void {
78
+ this.chunks.push(chunk);
79
+ this.totalLength += chunk.length;
80
+ }
81
+
82
+ get length(): number {
83
+ return this.totalLength;
84
+ }
85
+
86
+ reset(): void {
87
+ this.chunks = [];
88
+ this.totalLength = 0;
89
+ }
90
+
91
+ private frontBytes(n: number): Buffer {
92
+ const first = this.chunks[0];
93
+ if (!first || first.length >= n) return (first ?? Buffer.alloc(0)).subarray(0, n);
94
+ let covered = 0;
95
+ let count = 0;
96
+ while (covered < n && count < this.chunks.length) {
97
+ covered += this.chunks[count]!.length;
98
+ count += 1;
99
+ }
100
+ const merged = Buffer.concat(this.chunks.slice(0, count), covered);
101
+ this.chunks.splice(0, count, merged);
102
+ return merged.subarray(0, n);
103
+ }
104
+
105
+ peek(n: number): Buffer {
106
+ return this.frontBytes(n);
107
+ }
108
+
109
+ consume(n: number): Buffer {
110
+ if (n === 0) return Buffer.alloc(0);
111
+ const result = this.frontBytes(n);
112
+ const first = this.chunks[0]!;
113
+ if (first.length === n) this.chunks.shift();
114
+ else this.chunks[0] = first.subarray(n);
115
+ this.totalLength -= n;
116
+ return result;
117
+ }
118
+ }
119
+
120
+ export function frameConnectMessage(data: Uint8Array, flags = 0): Buffer {
121
+ if (data.byteLength > MAX_CONNECT_MESSAGE_BYTES) {
122
+ throw new Error(
123
+ `Connect message exceeds ${MAX_CONNECT_MESSAGE_BYTES} bytes (outgoing, ${data.byteLength} bytes). ` +
124
+ `Run /cursor.doctor and check lastRequestSize — this conversation's checkpoint or blob store has likely ` +
125
+ `grown too large; starting a new session usually clears it.`,
126
+ );
127
+ }
128
+ const frame = Buffer.alloc(5 + data.length);
129
+ frame[0] = flags;
130
+ frame.writeUInt32BE(data.length, 1);
131
+ frame.set(data, 5);
132
+ return frame;
133
+ }
134
+
135
+ /** Create a persistent in-process HTTP/2 streaming transport. */
136
+ export function createBridge(
137
+ options: CreateBridgeOptions,
138
+ debugLog: BridgeDebugLog = noopDebugLog,
139
+ ): BridgeHandle {
140
+ const origin = options.url ?? CURSOR_API_URL;
141
+ const connectTimeoutMs = optionalMs(options.connectTimeoutMs, 30_000);
142
+ const idleTimeoutMs = optionalMs(options.idleTimeoutMs, 0);
143
+ const pingIntervalMs = optionalMs(options.pingIntervalMs, 20_000);
144
+ const closeGraceMs = optionalMs(options.closeGraceMs, DEFAULT_CLOSE_GRACE_MS);
145
+
146
+ debugLog("bridge.create", {
147
+ rpcPath: options.rpcPath,
148
+ url: origin,
149
+ cursorClientVersion: getCursorClientVersion(),
150
+ });
151
+
152
+ const callbacks = {
153
+ data: null as ((chunk: Buffer) => void) | null,
154
+ close: null as ((code: number) => void) | null,
155
+ streamDone: null as (() => void) | null,
156
+ };
157
+ const queuedData: Buffer[] = [];
158
+ let queuedDataBytes = 0;
159
+ let queuedStreamDone = 0;
160
+ let diagnostics = "";
161
+ let alive = true;
162
+ let reusable = true;
163
+ let connected = false;
164
+ let exitCode = 1;
165
+ let currentStream: http2.ClientHttp2Stream | undefined;
166
+ let streamGeneration = 0;
167
+ let connectTimer: NodeJS.Timeout | undefined;
168
+ let idleTimer: NodeJS.Timeout | undefined;
169
+ let pingTimer: NodeJS.Timeout | undefined;
170
+ let closeTimer: NodeJS.Timeout | undefined;
171
+ let session: http2.ClientHttp2Session;
172
+
173
+ const appendDiagnostic = (message: string): void => {
174
+ diagnostics = `${diagnostics}${diagnostics ? "\n" : ""}${message}`.slice(-8_000);
175
+ };
176
+
177
+ const invokeClose = (): void => {
178
+ try {
179
+ callbacks.close?.(exitCode);
180
+ } catch (error) {
181
+ debugLog("bridge.close_callback_error", {
182
+ message: error instanceof Error ? error.message : String(error),
183
+ });
184
+ }
185
+ };
186
+
187
+ const clearTransportTimers = (): void => {
188
+ if (connectTimer) clearTimeout(connectTimer);
189
+ connectTimer = undefined;
190
+ if (idleTimer) clearTimeout(idleTimer);
191
+ idleTimer = undefined;
192
+ if (pingTimer) clearInterval(pingTimer);
193
+ pingTimer = undefined;
194
+ };
195
+
196
+ const setSessionReferenced = (referenced: boolean): void => {
197
+ const referenceable = session as http2.ClientHttp2Session & {
198
+ ref?: () => void;
199
+ unref?: () => void;
200
+ };
201
+ try {
202
+ if (referenced) referenceable.ref?.();
203
+ else referenceable.unref?.();
204
+ } catch {
205
+ // Older Node typings/runtimes may not expose session ref management.
206
+ }
207
+ };
208
+
209
+ const finalize = (code: number, destroy: boolean): void => {
210
+ if (!alive) return;
211
+ alive = false;
212
+ exitCode = code;
213
+ clearTransportTimers();
214
+ const stream = currentStream;
215
+ currentStream = undefined;
216
+ if (destroy) {
217
+ try {
218
+ stream?.close(http2.constants.NGHTTP2_CANCEL);
219
+ } catch {
220
+ // The stream may already be closed.
221
+ }
222
+ try {
223
+ session.destroy();
224
+ } catch {
225
+ // The session may already be destroyed.
226
+ }
227
+ } else {
228
+ try {
229
+ stream?.end();
230
+ } catch {
231
+ // The stream may already be closed.
232
+ }
233
+ try {
234
+ session.close();
235
+ } catch {
236
+ // The session may already be closed.
237
+ }
238
+ setSessionReferenced(false);
239
+ if (!session.destroyed) {
240
+ closeTimer = setTimeout(() => {
241
+ try {
242
+ session.destroy();
243
+ } catch {
244
+ // The session may have closed during the grace period.
245
+ }
246
+ }, closeGraceMs);
247
+ closeTimer.unref?.();
248
+ }
249
+ }
250
+ debugLog("bridge.close", { rpcPath: options.rpcPath, exitCode: code });
251
+ invokeClose();
252
+ };
253
+
254
+ const fail = (event: string, error: unknown, code = 1): void => {
255
+ if (!alive) return;
256
+ const message = error instanceof Error ? error.message : String(error);
257
+ appendDiagnostic(`[${event}] ${message}`);
258
+ debugLog(event, { message });
259
+ finalize(code, true);
260
+ };
261
+
262
+ const armConnectTimeout = (): void => {
263
+ if (connectTimer) clearTimeout(connectTimer);
264
+ connectTimer = undefined;
265
+ if (connectTimeoutMs <= 0 || !alive || connected) return;
266
+ connectTimer = setTimeout(() => {
267
+ fail(
268
+ "bridge.connect_timeout",
269
+ new Error(`Cursor HTTP/2 connect timed out after ${connectTimeoutMs}ms`),
270
+ );
271
+ }, connectTimeoutMs);
272
+ connectTimer.unref?.();
273
+ };
274
+
275
+ const resetIdleTimeout = (): void => {
276
+ if (!connected) return;
277
+ if (idleTimer) clearTimeout(idleTimer);
278
+ idleTimer = undefined;
279
+ if (idleTimeoutMs <= 0 || !alive) return;
280
+ idleTimer = setTimeout(() => {
281
+ fail(
282
+ "bridge.idle_timeout",
283
+ new Error(`Cursor HTTP/2 idle timed out after ${idleTimeoutMs}ms`),
284
+ );
285
+ }, idleTimeoutMs);
286
+ idleTimer.unref?.();
287
+ };
288
+
289
+ const deliverData = (payload: Buffer, callbackFailureCode = 1): boolean => {
290
+ if (!callbacks.data) {
291
+ queuedDataBytes += payload.byteLength;
292
+ if (queuedDataBytes > MAX_BRIDGE_MESSAGE_BYTES) {
293
+ fail(
294
+ "bridge.prelistener_buffer_limit",
295
+ new Error(`Buffered transport output exceeds ${MAX_BRIDGE_MESSAGE_BYTES} bytes`),
296
+ );
297
+ return false;
298
+ }
299
+ queuedData.push(payload);
300
+ return true;
301
+ }
302
+ try {
303
+ callbacks.data(payload);
304
+ return true;
305
+ } catch (error) {
306
+ fail("bridge.data_callback_error", error, callbackFailureCode);
307
+ return false;
308
+ }
309
+ };
310
+
311
+ const deliverStreamDone = (): boolean => {
312
+ if (!callbacks.streamDone) {
313
+ queuedStreamDone += 1;
314
+ return true;
315
+ }
316
+ try {
317
+ callbacks.streamDone();
318
+ return true;
319
+ } catch (error) {
320
+ fail("bridge.stream_done_callback_error", error);
321
+ return false;
322
+ }
323
+ };
324
+
325
+ try {
326
+ session = http2.connect(origin);
327
+ } catch (error) {
328
+ // Keep callback registration semantics consistent even when connect throws synchronously.
329
+ session = Object.create(null) as http2.ClientHttp2Session;
330
+ queueMicrotask(() => fail("bridge.session_error", error));
331
+ }
332
+
333
+ armConnectTimeout();
334
+
335
+ session.on?.("connect", () => {
336
+ debugLog("bridge.socket_connected", { rpcPath: options.rpcPath, url: origin });
337
+ });
338
+ session.on?.("remoteSettings", () => {
339
+ connected = true;
340
+ if (connectTimer) clearTimeout(connectTimer);
341
+ connectTimer = undefined;
342
+ debugLog("bridge.connected", { rpcPath: options.rpcPath, url: origin });
343
+ resetIdleTimeout();
344
+ });
345
+ session.on?.("error", (error) => fail("bridge.session_error", error));
346
+ session.on?.("goaway", (errorCode, lastStreamId, opaqueData) => {
347
+ if (!alive) return;
348
+ const opaque = opaqueData ? opaqueData.toString("utf8").slice(0, 200) : "";
349
+ const currentStreamId = currentStream?.id;
350
+ appendDiagnostic(
351
+ `[bridge.goaway] errorCode=${errorCode} lastStreamId=${lastStreamId}${opaque ? ` opaque=${opaque}` : ""}`,
352
+ );
353
+ debugLog("bridge.goaway", { errorCode, lastStreamId, currentStreamId, opaque });
354
+ reusable = false;
355
+ const acceptedCurrentStream =
356
+ errorCode === http2.constants.NGHTTP2_NO_ERROR &&
357
+ typeof currentStreamId === "number" &&
358
+ currentStreamId <= lastStreamId;
359
+ if (acceptedCurrentStream) return;
360
+ // Keep rejected streams on the transport-close path. A synthetic Connect error would set
361
+ // native-core's streamError and bypass its checkpoint/history retry handling.
362
+ finalize(2, true);
363
+ });
364
+ session.on?.("close", () => {
365
+ if (closeTimer) clearTimeout(closeTimer);
366
+ closeTimer = undefined;
367
+ if (alive) fail("bridge.session_close", new Error("Cursor HTTP/2 session closed"));
368
+ });
369
+
370
+ if (pingIntervalMs > 0) {
371
+ pingTimer = setInterval(() => {
372
+ if (!alive || session.destroyed || session.closed) return;
373
+ try {
374
+ session.ping((error) => {
375
+ if (!error) return;
376
+ appendDiagnostic(`[bridge.ping_error] ${error.message}`);
377
+ debugLog("bridge.ping_error", { message: error.message });
378
+ });
379
+ } catch (error) {
380
+ const message = error instanceof Error ? error.message : String(error);
381
+ appendDiagnostic(`[bridge.ping_error] ${message}`);
382
+ debugLog("bridge.ping_error", { message });
383
+ }
384
+ }, pingIntervalMs);
385
+ pingTimer.unref?.();
386
+ }
387
+
388
+ const requestHeaders = (
389
+ accessToken: string,
390
+ allowedTools?: readonly string[],
391
+ ): http2.OutgoingHttpHeaders => ({
392
+ ":method": "POST",
393
+ ":path": options.rpcPath || DEFAULT_RPC_PATH,
394
+ "content-type": "application/connect+proto",
395
+ "connect-protocol-version": "1",
396
+ te: "trailers",
397
+ authorization: `Bearer ${accessToken}`,
398
+ "x-ghost-mode": "true",
399
+ "x-cursor-client-version": getCursorClientVersion(),
400
+ "x-cursor-client-type": "cli",
401
+ "x-request-id": randomUUID(),
402
+ ...(allowedTools !== undefined
403
+ ? { "x-cursor-agent-allowed-tools": allowedTools.join(",") }
404
+ : {}),
405
+ });
406
+
407
+ const openStream = (
408
+ accessToken: string,
409
+ resetCallbacks: boolean,
410
+ allowedTools = options.allowedTools,
411
+ ): void => {
412
+ if (!alive) return;
413
+ if (!reusable || session.destroyed || session.closed) {
414
+ fail("bridge.open_stream_error", new Error("Cursor HTTP/2 session is not reusable"), 2);
415
+ return;
416
+ }
417
+ if (resetCallbacks) {
418
+ callbacks.data = null;
419
+ callbacks.close = null;
420
+ callbacks.streamDone = null;
421
+ queuedData.length = 0;
422
+ queuedDataBytes = 0;
423
+ queuedStreamDone = 0;
424
+ }
425
+
426
+ const previousStream = currentStream;
427
+ const generation = ++streamGeneration;
428
+ try {
429
+ previousStream?.close(http2.constants.NGHTTP2_CANCEL);
430
+ } catch {
431
+ // A completed previous stream is already closed.
432
+ }
433
+
434
+ let stream: http2.ClientHttp2Stream;
435
+ try {
436
+ stream = session.request(requestHeaders(accessToken || options.accessToken, allowedTools));
437
+ } catch (error) {
438
+ fail("bridge.open_stream_error", error);
439
+ return;
440
+ }
441
+ currentStream = stream;
442
+ setSessionReferenced(true);
443
+ resetIdleTimeout();
444
+
445
+ let responseStatus = 0;
446
+ let responseStatusText = "";
447
+ let responseEnded = false;
448
+ const errorChunks: Buffer[] = [];
449
+ let errorBodyBytes = 0;
450
+ const isCurrent = () => alive && currentStream === stream && streamGeneration === generation;
451
+ const isErrorStatus = () =>
452
+ responseStatus !== 0 && (responseStatus < 200 || responseStatus >= 300);
453
+
454
+ stream.on("response", (headers) => {
455
+ if (!isCurrent()) return;
456
+ resetIdleTimeout();
457
+ responseStatus = Number(headers[":status"] ?? 0);
458
+ responseStatusText = String(
459
+ headers["grpc-message"] ?? headers["connect-error-message"] ?? "",
460
+ );
461
+ });
462
+ stream.on("data", (chunk: Buffer) => {
463
+ if (!isCurrent()) return;
464
+ resetIdleTimeout();
465
+ const payload = Buffer.from(chunk);
466
+ if (isErrorStatus()) {
467
+ const remaining = MAX_ERROR_BODY_BYTES - errorBodyBytes;
468
+ if (remaining > 0) {
469
+ const kept = payload.subarray(0, remaining);
470
+ errorChunks.push(kept);
471
+ errorBodyBytes += kept.byteLength;
472
+ }
473
+ return;
474
+ }
475
+ if (payload.byteLength > MAX_BRIDGE_MESSAGE_BYTES) {
476
+ fail(
477
+ "bridge.output_limit",
478
+ new Error(`Transport output exceeds ${MAX_BRIDGE_MESSAGE_BYTES} bytes`),
479
+ );
480
+ return;
481
+ }
482
+ deliverData(payload);
483
+ });
484
+ stream.on("end", () => {
485
+ if (!isCurrent()) return;
486
+ responseEnded = true;
487
+ resetIdleTimeout();
488
+ if (isErrorStatus()) {
489
+ const body = Buffer.concat(errorChunks).toString("utf8").trim();
490
+ const detail = responseStatusText || body || "HTTP/2 upstream request failed";
491
+ deliverData(
492
+ connectEndStreamError(
493
+ `http_${responseStatus}`,
494
+ `Cursor HTTP ${responseStatus}: ${detail}`,
495
+ ),
496
+ );
497
+ fail("bridge.http_error", new Error(`Cursor HTTP ${responseStatus}: ${detail}`));
498
+ return;
499
+ }
500
+ currentStream = undefined;
501
+ try {
502
+ stream.end();
503
+ } catch {
504
+ // The remote may already have closed the writable side.
505
+ }
506
+ setSessionReferenced(false);
507
+ deliverStreamDone();
508
+ });
509
+ stream.on("error", (error) => {
510
+ if (isCurrent()) fail("bridge.stream_error", error);
511
+ });
512
+ stream.on("close", () => {
513
+ if (isCurrent() && !responseEnded) {
514
+ fail("bridge.stream_close", new Error("Cursor HTTP/2 stream closed before response end"));
515
+ }
516
+ });
517
+ };
518
+
519
+ // The first Run stream exists immediately so callers can write request frames synchronously.
520
+ openStream(options.accessToken, false);
521
+
522
+ return {
523
+ get alive() {
524
+ return alive;
525
+ },
526
+ get reusable() {
527
+ return reusable;
528
+ },
529
+ lastStderr() {
530
+ return diagnostics.trim();
531
+ },
532
+ write(data: Uint8Array) {
533
+ const stream = currentStream;
534
+ if (!alive || !stream || stream.closed || stream.destroyed) return;
535
+ if (data.byteLength > MAX_BRIDGE_MESSAGE_BYTES) {
536
+ fail(
537
+ "bridge.input_limit",
538
+ new Error(`Transport input exceeds ${MAX_BRIDGE_MESSAGE_BYTES} bytes`),
539
+ );
540
+ return;
541
+ }
542
+ const queuedBytes = (stream as http2.ClientHttp2Stream & { writableLength?: number })
543
+ .writableLength;
544
+ if ((queuedBytes ?? 0) + data.byteLength > MAX_BRIDGE_MESSAGE_BYTES) {
545
+ fail(
546
+ "bridge.input_backpressure_limit",
547
+ new Error(`Queued transport input exceeds ${MAX_BRIDGE_MESSAGE_BYTES} bytes`),
548
+ );
549
+ return;
550
+ }
551
+ try {
552
+ stream.write(Buffer.from(data));
553
+ resetIdleTimeout();
554
+ } catch (error) {
555
+ fail("bridge.write_error", error);
556
+ }
557
+ },
558
+ openStream(accessToken: string, allowedTools?: readonly string[]) {
559
+ openStream(accessToken, true, allowedTools);
560
+ },
561
+ end() {
562
+ finalize(0, false);
563
+ },
564
+ kill() {
565
+ fail("bridge.killed", new Error("Cursor HTTP/2 transport killed"));
566
+ },
567
+ onData(cb: (chunk: Buffer) => void) {
568
+ callbacks.data = cb;
569
+ while (queuedData.length > 0) {
570
+ const payload = queuedData.shift()!;
571
+ queuedDataBytes -= payload.byteLength;
572
+ if (!deliverData(payload)) break;
573
+ }
574
+ },
575
+ onStreamDone(cb: () => void) {
576
+ callbacks.streamDone = cb;
577
+ while (queuedStreamDone > 0 && alive) {
578
+ queuedStreamDone -= 1;
579
+ if (!deliverStreamDone()) break;
580
+ }
581
+ },
582
+ onClose(cb: (code: number) => void) {
583
+ if (!alive) {
584
+ queueMicrotask(() => {
585
+ try {
586
+ cb(exitCode);
587
+ } catch (error) {
588
+ debugLog("bridge.close_callback_error", {
589
+ message: error instanceof Error ? error.message : String(error),
590
+ });
591
+ }
592
+ });
593
+ } else {
594
+ callbacks.close = cb;
595
+ }
596
+ },
597
+ };
598
+ }
599
+
600
+ /** Attached to the error thrown for a declared-length overflow so callers can log forensics
601
+ * (behind PI_CURSOR_PROVIDER_DEBUG) without needing to re-derive parser-internal state. */
602
+ export interface ConnectFrameDesyncDiagnostics {
603
+ /** Bytes successfully parsed into complete frames before the desync, across this parser's life. */
604
+ bytesConsumedBeforeDesync: number;
605
+ /** Number of complete frames successfully parsed before the desync. */
606
+ framesParsedBeforeDesync: number;
607
+ /** Hex of the 5-byte header read as the (bogus) next frame. */
608
+ headerHex: string;
609
+ /** Hex of up to 32 bytes immediately following the header, for context. */
610
+ trailingContextHex: string;
611
+ }
612
+
613
+ export function createConnectFrameParser(
614
+ onMessage: (bytes: Uint8Array) => void,
615
+ onEndStream: (bytes: Uint8Array) => void,
616
+ ): (incoming: Buffer) => void {
617
+ const pending = new FrameAccumulator();
618
+ let bytesConsumed = 0;
619
+ let framesParsed = 0;
620
+ return (incoming: Buffer) => {
621
+ pending.push(incoming);
622
+ while (pending.length >= 5) {
623
+ const header = pending.peek(5);
624
+ const flags = header[0]!;
625
+ const msgLen = header.readUInt32BE(1);
626
+ if (msgLen > MAX_CONNECT_MESSAGE_BYTES) {
627
+ const contextLen = Math.min(32, pending.length - 5);
628
+ const trailingContextHex =
629
+ contextLen > 0
630
+ ? pending
631
+ .peek(5 + contextLen)
632
+ .subarray(5)
633
+ .toString("hex")
634
+ : "";
635
+ const diagnostics: ConnectFrameDesyncDiagnostics = {
636
+ bytesConsumedBeforeDesync: bytesConsumed,
637
+ framesParsedBeforeDesync: framesParsed,
638
+ headerHex: header.toString("hex"),
639
+ trailingContextHex,
640
+ };
641
+ pending.reset();
642
+ throw Object.assign(
643
+ new Error(
644
+ `Connect message exceeds ${MAX_CONNECT_MESSAGE_BYTES} bytes (incoming, declared length ${msgLen}). ` +
645
+ `Enable PI_CURSOR_PROVIDER_DEBUG=1 and check the lifecycle log for the frame preceding this error.`,
646
+ ),
647
+ { connectFrameDesync: diagnostics },
648
+ );
649
+ }
650
+ if (pending.length < 5 + msgLen) break;
651
+ pending.consume(5);
652
+ const messageBytes = pending.consume(msgLen);
653
+ bytesConsumed += 5 + msgLen;
654
+ framesParsed += 1;
655
+ if (flags & CONNECT_END_STREAM_FLAG) onEndStream(messageBytes);
656
+ else onMessage(messageBytes);
657
+ }
658
+ };
659
+ }
660
+
661
+ export function parseConnectEndStream(data: Uint8Array): Error | null {
662
+ try {
663
+ const payload = JSON.parse(new TextDecoder().decode(data));
664
+ const error = payload?.error;
665
+ if (error)
666
+ return new Error(
667
+ `Connect error ${error.code ?? "unknown"}: ${error.message ?? "Unknown error"}`,
668
+ );
669
+ return null;
670
+ } catch {
671
+ return new Error("Failed to parse Connect end stream");
672
+ }
673
+ }