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,2121 @@
1
+ /**
2
+ * Cursor native provider runtime: translates Pi streamSimple context to Cursor's
3
+ * protobuf/HTTP2 Connect protocol.
4
+ *
5
+ * Based on https://github.com/ephraimduncan/opencode-cursor by Ephraim Duncan.
6
+ * Uses Node's in-process http2 client with persistent streaming sessions.
7
+ */
8
+ import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
9
+ import {
10
+ createAssistantMessageEventStream,
11
+ type Api,
12
+ type AssistantMessageEventStream,
13
+ type Context,
14
+ type Model,
15
+ type SimpleStreamOptions,
16
+ } from "@earendil-works/pi-ai";
17
+ import {
18
+ AgentClientMessageSchema,
19
+ AgentServerMessageSchema,
20
+ ConversationStateStructureSchema,
21
+ ExecClientMessageSchema,
22
+ McpResultSchema,
23
+ McpSuccessSchema,
24
+ type McpToolDefinition,
25
+ } from "../proto/agent_pb.js";
26
+ import {
27
+ createConnectFrameParser,
28
+ frameConnectMessage,
29
+ parseConnectEndStream,
30
+ type BridgeHandle,
31
+ type ConnectFrameDesyncDiagnostics,
32
+ } from "../client/bridge.js";
33
+ import type { CursorModelParameter } from "../client/cursor-wire.js";
34
+ export type {
35
+ CursorModelParameter,
36
+ CursorParameterizedModel,
37
+ CursorParameterizedVariant,
38
+ } from "../client/cursor-wire.js";
39
+
40
+ import { processServerMessage } from "./server-messages.js";
41
+ import { reportRunUsageBoundary, retainRunReceipt, takePendingRunReceipts } from "./run-usage.js";
42
+ import { positiveContextTokens } from "./context-usage.js";
43
+ import { createThinkingTagFilter } from "./thinking-filter.js";
44
+ import {
45
+ contextToCursorChatCompletionRequest,
46
+ nativeRequestParameterError,
47
+ resolveNativeReasoningEffort,
48
+ resolveToolsForToolChoice,
49
+ } from "./pi-adapter.js";
50
+ import {
51
+ clearStoredMidPauseMetadata,
52
+ commitStoredCheckpoint,
53
+ commitStoredCheckpointMidPause,
54
+ conversationStates,
55
+ deriveBridgeKey,
56
+ deriveConversationKey,
57
+ derivePiSessionId,
58
+ deriveRequestLockKey,
59
+ deterministicConversationId,
60
+ discardStaleCheckpointIfNeeded,
61
+ evictStaleConversations,
62
+ fingerprintCompletedTurns,
63
+ getOrHydrateConversation,
64
+ handleBridgeCloseMidPause,
65
+ mergeBlobStore,
66
+ persistAbortedConversationState,
67
+ trimBlobStore,
68
+ withSessionLock,
69
+ } from "./session-state.js";
70
+ export {
71
+ cleanupAllSessionState,
72
+ cleanupSessionState,
73
+ commitStoredCheckpointMidPause,
74
+ deriveBridgeKey,
75
+ deriveBridgeKeyFromSessionId,
76
+ deriveConversationKey,
77
+ deriveConversationKeyFromSessionId,
78
+ derivePiSessionId,
79
+ deterministicConversationId,
80
+ evictStaleConversations,
81
+ fingerprintCompletedTurns,
82
+ handleBridgeCloseMidPause,
83
+ type HandleBridgeCloseMidPauseInput,
84
+ } from "./session-state.js";
85
+ import {
86
+ buildCursorRequest,
87
+ buildMcpSuccessContent,
88
+ buildMcpToolDefinitions,
89
+ isIdentityConversationalTurn,
90
+ isTrivialConversationalTurn,
91
+ normalizeToolResultForTransport,
92
+ summarizeRequestSize,
93
+ } from "./request-build.js";
94
+ export {
95
+ buildCursorRequest,
96
+ isIdentityConversationalTurn,
97
+ isSlimToolsEnabled,
98
+ isTrivialConversationalTurn,
99
+ slimOpenAIToolsForCursor,
100
+ summarizeRequestSize,
101
+ type BuildCursorRequestOptions,
102
+ } from "./request-build.js";
103
+ import { hashSystemPrompt } from "./root-prompt.js";
104
+ export {
105
+ buildRootPromptMessages,
106
+ hashSystemPrompt,
107
+ isPromptHistoryEnabled,
108
+ turnRootMessages,
109
+ } from "./root-prompt.js";
110
+ import {
111
+ appendAssistantTextToTurn,
112
+ getTurnToolCallResults,
113
+ parseMessages,
114
+ parseToolCallArguments,
115
+ stripInFlightResults,
116
+ systemPromptHasSessionMemory,
117
+ } from "./message-parsing.js";
118
+ export {
119
+ frameContextModeSideChannel,
120
+ isContextModeSideChannelText,
121
+ normalizeMessagesForCursor,
122
+ parseMessages,
123
+ systemPromptHasSessionMemory,
124
+ } from "./message-parsing.js";
125
+ export {
126
+ callCursorUnaryRpc,
127
+ discoverCursorCatalog,
128
+ getCursorModels,
129
+ getCursorParameterizedModels,
130
+ inferCursorContextWindow,
131
+ type CursorCatalog,
132
+ type CursorModel,
133
+ } from "./model-discovery.js";
134
+ export { readCachedCatalog, writeCachedCatalog } from "./model-cache.js";
135
+ import {
136
+ activeBridges,
137
+ cleanupBridge,
138
+ parkIdleBridge,
139
+ removeActiveBridge,
140
+ setActiveBridge,
141
+ startBridge,
142
+ } from "./bridge-session.js";
143
+ export { setBridgeFactoryForTests } from "./bridge-session.js";
144
+ import {
145
+ canBlindIdleRestart,
146
+ canRecoverAfterTransportLoss,
147
+ createStreamIdleWatchdog,
148
+ DEFAULT_STREAM_PARK_TIMEOUT_MS,
149
+ interactionUpdateProgress,
150
+ resolveH2ConnectTimeoutMs,
151
+ resolveH2IdleTimeoutMs,
152
+ resolveMidPauseRebuildMaxAgeMs,
153
+ resolveResumeIdleTimeoutMs,
154
+ resolveStreamIdleMaxRetries,
155
+ resolveStreamIdleTimeoutMs,
156
+ } from "./tuning.js";
157
+ export {
158
+ canBlindIdleRestart,
159
+ canRecoverAfterTransportLoss,
160
+ interactionUpdateProgress,
161
+ resolveActiveBridgeTtlMs,
162
+ resolveH2ConnectTimeoutMs,
163
+ resolveH2IdleTimeoutMs,
164
+ resolveResumeIdleTimeoutMs,
165
+ resolveStreamIdleMaxRetries,
166
+ resolveStreamIdleTimeoutMs,
167
+ } from "./tuning.js";
168
+ import {
169
+ CHECKPOINT_CONTINUATION_PROMPT,
170
+ classifyBridgeExit,
171
+ formatTransportFailure,
172
+ } from "./transport-errors.js";
173
+ export {
174
+ CHECKPOINT_CONTINUATION_PROMPT,
175
+ classifyBridgeExit,
176
+ formatTransportFailure,
177
+ type TransportFailure,
178
+ } from "./transport-errors.js";
179
+ import {
180
+ debugBase64ImageSummary,
181
+ debugLog,
182
+ decodeRequestForTests,
183
+ lifecycleLog,
184
+ nextDebugRequestId,
185
+ redactForDebug,
186
+ reportCursorAnomaly,
187
+ setMetricEmitter,
188
+ type MetricEmitter,
189
+ } from "./debug-log.js";
190
+ import { cloneParsedImage } from "./images.js";
191
+ import {
192
+ resolveModelId as resolveModelIdImpl,
193
+ resolveRequestedModelId as resolveRequestedModelIdImpl,
194
+ type CursorNativeModelRouting as ExtractedCursorNativeModelRouting,
195
+ type CursorResolvableModel as ExtractedCursorResolvableModel,
196
+ type ResolvedCursorModelRouting as ExtractedResolvedCursorModelRouting,
197
+ } from "./model-routing.js";
198
+ import { liveTranscript, withSyntheticCurrentTurn } from "./client-transcript.js";
199
+ import {
200
+ planRecovery as planRecoveryImpl,
201
+ wrapRecoveredToolResults as wrapRecoveredToolResultsImpl,
202
+ collapseToolResultsById as collapseToolResultsByIdImpl,
203
+ lostToolContinuationErrorBody as lostToolContinuationErrorBodyImpl,
204
+ formatLostToolContinuationDiagnostic as formatLostToolContinuationDiagnosticImpl,
205
+ lostToolContinuationMessage as lostToolContinuationMessageImpl,
206
+ bridgeKeyPrefix as bridgeKeyPrefixImpl,
207
+ type RecoveryDecision as ExtractedRecoveryDecision,
208
+ type PlanRecoveryInput as ExtractedPlanRecoveryInput,
209
+ type LostToolContinuationDiagnosticInput as ExtractedLostToolContinuationDiagnosticInput,
210
+ } from "./recovery.js";
211
+ import { enhanceCursorStreamError, isAuthErrorMessage } from "./protocol.js";
212
+ import {
213
+ setLastIdleTimeout,
214
+ setLastRecoverySkipReason,
215
+ setLastRequestSize,
216
+ setLastStreamEvent,
217
+ } from "../diagnostics/diagnostics.js";
218
+
219
+ // URL resolution lives in ./config.ts
220
+ export { getCursorAgentUrl } from "./config.js";
221
+
222
+ // ── Types ──
223
+ //
224
+ // The shared structural types live in ./types.ts so recovery/parsing/building
225
+ // modules can reference them without importing this runtime.
226
+
227
+ import type {
228
+ ActiveBridge,
229
+ ChatCompletionRequest,
230
+ CheckpointRef,
231
+ ClientTranscript,
232
+ CursorNativeStreamConfig,
233
+ CursorNativeStreamOptions,
234
+ IdleRestartContext,
235
+ NativeStreamAttemptInput,
236
+ NativeStreamWriter,
237
+ ParsedImageContent,
238
+ ParsedMessages,
239
+ ParsedTurn,
240
+ ParsedToolCallStep,
241
+ PendingExec,
242
+ StreamIdleRetryController,
243
+ StreamState,
244
+ ToolResultInfo,
245
+ } from "./types.js";
246
+
247
+ export type {
248
+ CursorNativeStreamConfig,
249
+ ParsedAssistantTextStep,
250
+ ParsedImageContent,
251
+ ParsedToolCallStep,
252
+ ParsedToolResult,
253
+ ParsedTurn,
254
+ ParsedTurnStep,
255
+ StoredConversation,
256
+ } from "./types.js";
257
+
258
+ // ── State ──
259
+
260
+ export const __testInternals = {
261
+ activeBridges,
262
+ conversationStates,
263
+ createStreamIdleWatchdog,
264
+ canBlindIdleRestart,
265
+ canRecoverAfterTransportLoss,
266
+ clearStoredMidPauseMetadata,
267
+ collectToolResultImages,
268
+ debugBase64ImageSummary,
269
+ decodeRequestForTests,
270
+ discardStaleCheckpointIfNeeded,
271
+ fingerprintCompletedTurns,
272
+ getOrHydrateConversation,
273
+ interactionUpdateProgress,
274
+ redactForDebug,
275
+ resolveH2ConnectTimeoutMs,
276
+ resolveH2IdleTimeoutMs,
277
+ resolveMidPauseRebuildMaxAgeMs,
278
+ resolveNativeReasoningEffort,
279
+ resolveResumeIdleTimeoutMs,
280
+ resolveStreamIdleMaxRetries,
281
+ resolveStreamIdleTimeoutMs,
282
+ persistAbortedConversationState,
283
+ trimBlobStore,
284
+ classifyBridgeExit,
285
+ writeNativeStream,
286
+ logFullHistoryRebuild,
287
+ setMetricEmitterForTests(factory?: MetricEmitter) {
288
+ setMetricEmitter(factory);
289
+ },
290
+ };
291
+
292
+ // ── Native pi streamSimple provider ──
293
+
294
+ export type CursorNativeModelRouting = ExtractedCursorNativeModelRouting;
295
+ import { createNativeStreamWriter } from "./stream-writer.js";
296
+ export { createNativeStreamWriter } from "./stream-writer.js";
297
+
298
+ function lostToolContinuationMessage(): string {
299
+ return lostToolContinuationMessageImpl();
300
+ }
301
+
302
+ export type LostToolContinuationDiagnosticInput = ExtractedLostToolContinuationDiagnosticInput;
303
+
304
+ export function lostToolContinuationErrorBody(input: LostToolContinuationDiagnosticInput): {
305
+ error: Record<string, unknown>;
306
+ } {
307
+ return lostToolContinuationErrorBodyImpl(input);
308
+ }
309
+
310
+ function bridgeKeyPrefix(bridgeKey: string): string {
311
+ return bridgeKeyPrefixImpl(bridgeKey);
312
+ }
313
+
314
+ export function formatLostToolContinuationDiagnostic(
315
+ input: LostToolContinuationDiagnosticInput,
316
+ ): string {
317
+ return formatLostToolContinuationDiagnosticImpl(input);
318
+ }
319
+
320
+ export function wrapRecoveredToolResults(
321
+ toolResults: Array<Pick<ToolResultInfo, "toolCallId" | "content">>,
322
+ recoveryId: string = crypto.randomUUID(),
323
+ ): string {
324
+ return wrapRecoveredToolResultsImpl(toolResults, recoveryId);
325
+ }
326
+
327
+ function collectToolResultImages(toolResults: ToolResultInfo[]): ParsedImageContent[] {
328
+ return collapseToolResultsByIdImpl(toolResults).flatMap((result) =>
329
+ (result.images ?? []).map(cloneParsedImage),
330
+ );
331
+ }
332
+
333
+ function toolResultsContainRecoverySentinel(
334
+ toolResults: Array<Pick<ToolResultInfo, "content">>,
335
+ ): boolean {
336
+ return toolResults.some(
337
+ (result) =>
338
+ result.content.includes("[Recovered tool output after upstream bridge loss") ||
339
+ result.content.includes("[End recovered tool output"),
340
+ );
341
+ }
342
+
343
+ function parsedTurnHasImages(turn: ParsedTurn): boolean {
344
+ return (turn.userImages?.length ?? 0) > 0;
345
+ }
346
+
347
+ type FullHistoryRebuildDecision = Extract<RecoveryDecision, { kind: "rebuild_full_history" }>;
348
+
349
+ function logFullHistoryRebuild(
350
+ event: "native.rebuild_full_history" | "chat.rebuild_full_history",
351
+ input: {
352
+ requestId?: string;
353
+ bridgeKey: string;
354
+ convKey: string;
355
+ modelId: string;
356
+ decision: FullHistoryRebuildDecision;
357
+ },
358
+ ): void {
359
+ const fields = {
360
+ requestId: input.requestId,
361
+ bridgeKeyPrefix: bridgeKeyPrefix(input.bridgeKey),
362
+ convKey: input.convKey,
363
+ modelId: input.modelId,
364
+ rebuildReason: input.decision.rebuildReason,
365
+ completedTurnCount: input.decision.completedTurns.length,
366
+ inFlightTurnHasImages: parsedTurnHasImages(input.decision.inFlightTurn),
367
+ toolResultCount: input.decision.toolResults.length,
368
+ pendingToolCallIds: input.decision.toolResults.map((result) => result.toolCallId),
369
+ sentinelInjectionDetected: toolResultsContainRecoverySentinel(input.decision.toolResults),
370
+ };
371
+ debugLog(event, fields);
372
+ const lifecycleFields = {
373
+ reason: input.decision.rebuildReason,
374
+ modelId: input.modelId,
375
+ convKey: input.convKey,
376
+ requestId: input.requestId,
377
+ bridgeKeyPrefix: bridgeKeyPrefix(input.bridgeKey),
378
+ };
379
+ debugLog("metric.cursor_provider.rebuild_full_history", lifecycleFields);
380
+ reportCursorAnomaly(
381
+ "rebuild_full_history",
382
+ `Cursor rebuilt conversation history (${input.decision.rebuildReason})`,
383
+ lifecycleFields,
384
+ );
385
+ }
386
+
387
+ export type RecoveryDecision = ExtractedRecoveryDecision;
388
+ export type PlanRecoveryInput = ExtractedPlanRecoveryInput;
389
+
390
+ export function planRecovery(input: PlanRecoveryInput): RecoveryDecision {
391
+ return planRecoveryImpl({
392
+ ...input,
393
+ discardStaleCheckpoint: discardStaleCheckpointIfNeeded,
394
+ });
395
+ }
396
+
397
+ export function createCursorNativeStream(
398
+ config: CursorNativeStreamConfig,
399
+ ): (
400
+ model: Model<Api>,
401
+ context: Context,
402
+ options?: SimpleStreamOptions,
403
+ ) => AssistantMessageEventStream {
404
+ return (model, context, options) => {
405
+ const stream = createAssistantMessageEventStream();
406
+ const writer = createNativeStreamWriter(stream, model, context, options);
407
+ writer.start();
408
+
409
+ (async () => {
410
+ let body = contextToCursorChatCompletionRequest(
411
+ model,
412
+ context,
413
+ options as CursorNativeStreamOptions | undefined,
414
+ config,
415
+ );
416
+
417
+ if (options?.onPayload) {
418
+ const replacement = await options.onPayload(body, model);
419
+ if (replacement && typeof replacement === "object")
420
+ body = replacement as ChatCompletionRequest;
421
+ }
422
+
423
+ await withSessionLock(deriveRequestLockKey(body), async () => {
424
+ if (writer.closed) return;
425
+ const accessToken = await config.getAccessToken();
426
+ await handleCursorNativeRequest(
427
+ body,
428
+ accessToken,
429
+ model,
430
+ options as CursorNativeStreamOptions | undefined,
431
+ writer,
432
+ nextDebugRequestId(),
433
+ config.getAccessToken,
434
+ );
435
+ });
436
+ })().catch((error) => {
437
+ writer.error(error instanceof Error ? error.message : String(error), "error");
438
+ });
439
+
440
+ return stream;
441
+ };
442
+ }
443
+
444
+ async function handleCursorNativeRequest(
445
+ body: ChatCompletionRequest,
446
+ accessToken: string,
447
+ model: Model<Api>,
448
+ options: CursorNativeStreamOptions | undefined,
449
+ writer: NativeStreamWriter,
450
+ requestId: string,
451
+ getAccessToken?: (options?: { forceRefresh?: boolean }) => Promise<string>,
452
+ ): Promise<void> {
453
+ let parsedMessages: ParsedMessages;
454
+ try {
455
+ parsedMessages = parseMessages(body.messages, body.cursor_tool_result_images);
456
+ } catch (error) {
457
+ writer.error(error instanceof Error ? error.message : String(error), "error");
458
+ return;
459
+ }
460
+
461
+ const parameterError = nativeRequestParameterError(body);
462
+ if (parameterError) {
463
+ debugLog("native.unsupported_parameters", { requestId, message: parameterError });
464
+ writer.error(parameterError, "error");
465
+ return;
466
+ }
467
+
468
+ const toolResolution = resolveToolsForToolChoice(body.tools ?? [], body.tool_choice);
469
+ if ("error" in toolResolution) {
470
+ debugLog("native.unsupported_tool_choice", { requestId, tool_choice: body.tool_choice });
471
+ writer.error(toolResolution.error, "error");
472
+ return;
473
+ }
474
+
475
+ const { systemPrompt, userText, userImages, turns, toolResults, inFlightTurn } = parsedMessages;
476
+ const omitToolsForTrivialTurn =
477
+ toolResolution.tools.length > 0 &&
478
+ toolResults.length === 0 &&
479
+ userImages.length === 0 &&
480
+ isTrivialConversationalTurn(userText);
481
+ const selectedTools = omitToolsForTrivialTurn ? [] : toolResolution.tools;
482
+ // Greetings do not need Pi's large agent prompt: sending it can cost tens of
483
+ // thousands of input tokens before the user text is even considered. Keep the
484
+ // full prompt for anything actionable, for identity/capability questions the
485
+ // prompt itself answers, and whenever it carries folded session/compaction
486
+ // memory.
487
+ const PI_MCP_TOOLS_ONLY =
488
+ "You are running inside Pi, not the Cursor IDE. " +
489
+ "Cursor-native tools (read, write, ls, grep, shell, fetch, delete) are not available. " +
490
+ "Use only the MCP tools listed in this request. " +
491
+ "Do not re-list the workspace or re-read files to recover context unless the latest user message asks you to.";
492
+ // Even a dropped prompt leaves this much behind: without it the model answers
493
+ // a greeting as Cursor's IDE assistant.
494
+ const PI_IDENTITY_ONLY = "You are running inside Pi, not the Cursor IDE.";
495
+ const dropSystemPrompt =
496
+ omitToolsForTrivialTurn &&
497
+ !isIdentityConversationalTurn(userText) &&
498
+ !systemPromptHasSessionMemory(systemPrompt);
499
+ let effectiveSystemPrompt = dropSystemPrompt ? PI_IDENTITY_ONLY : systemPrompt;
500
+ if (selectedTools.length > 0) {
501
+ effectiveSystemPrompt = effectiveSystemPrompt
502
+ ? `${effectiveSystemPrompt}\n\n${PI_MCP_TOOLS_ONLY}`
503
+ : PI_MCP_TOOLS_ONLY;
504
+ }
505
+ if (omitToolsForTrivialTurn) {
506
+ setLastStreamEvent("tools_omitted_trivial_turn");
507
+ lifecycleLog("tools_omitted", {
508
+ requestId,
509
+ reason: "trivial_conversational_turn",
510
+ originalToolCount: toolResolution.tools.length,
511
+ systemPromptDropped: dropSystemPrompt,
512
+ });
513
+ }
514
+ const modelId = resolveRequestedModelId(body.model, body.reasoning_effort, body.cursor_model_id);
515
+ const maxMode =
516
+ typeof body.cursor_model_max_mode === "boolean"
517
+ ? body.cursor_model_max_mode
518
+ : body.cursor_requires_max_mode === true;
519
+ const sessionId = derivePiSessionId(body);
520
+ const bridgeKey = deriveBridgeKey(body.messages, sessionId);
521
+ const convKey = deriveConversationKey(body.messages, sessionId);
522
+ const activeBridge = activeBridges.get(bridgeKey);
523
+ if (writer.carryUsage) {
524
+ for (const receipt of takePendingRunReceipts(conversationStates.get(convKey), model.id))
525
+ writer.carryUsage(receipt);
526
+ }
527
+
528
+ debugLog("native.request", {
529
+ requestId,
530
+ sessionId,
531
+ bridgeKey,
532
+ convKey,
533
+ model: body.model,
534
+ resolvedModelId: modelId,
535
+ cursorModelId: body.cursor_model_id,
536
+ cursorModelParameters: body.cursor_model_parameters,
537
+ cursorRequiresMaxMode: body.cursor_requires_max_mode,
538
+ cursorModelMaxMode: body.cursor_model_max_mode,
539
+ maxMode,
540
+ messageCount: body.messages.length,
541
+ turnCount: turns.length,
542
+ userText,
543
+ toolResults,
544
+ inFlightTurn,
545
+ hasActiveBridge: !!activeBridge,
546
+ });
547
+
548
+ if (!userText && userImages.length === 0 && toolResults.length === 0) {
549
+ writer.error("No user message found", "error");
550
+ return;
551
+ }
552
+
553
+ if (toolResults.length > 0) {
554
+ const resumeIdleTimeoutMs = resolveResumeIdleTimeoutMs(
555
+ process.env.PI_CURSOR_RESUME_IDLE_TIMEOUT_MS,
556
+ );
557
+ if (activeBridge) {
558
+ removeActiveBridge(bridgeKey);
559
+ // Without a Pi session id the bridge key is only a hash of the opening user message, so two
560
+ // conversations that start alike land on the same key. Resuming the wrong bridge would splice
561
+ // one conversation's tool results into another; the history fingerprint is what tells them
562
+ // apart. Recovery already fingerprints — this closes the same hole on the live path.
563
+ //
564
+ // Scoped to sessionless keys on purpose. A session-derived key cannot collide, so there the
565
+ // check could only ever produce false negatives — tearing down a healthy bridge if the client
566
+ // reshapes its history mid-turn — with no collision to protect against.
567
+ const currentHistoryFingerprint = fingerprintCompletedTurns(turns);
568
+ const historyMatches =
569
+ !!sessionId || activeBridge.historyFingerprint === currentHistoryFingerprint;
570
+ if (!historyMatches) {
571
+ debugLog("bridge.active_history_mismatch", {
572
+ requestId,
573
+ bridgeKey,
574
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
575
+ convKey,
576
+ storedFingerprint: activeBridge.historyFingerprint,
577
+ currentFingerprint: currentHistoryFingerprint,
578
+ });
579
+ setLastStreamEvent("active_bridge_history_mismatch");
580
+ }
581
+ if (activeBridge.bridge.alive && historyMatches) {
582
+ handleNativeToolResultResume(
583
+ activeBridge,
584
+ toolResults,
585
+ {
586
+ accessToken,
587
+ systemPrompt,
588
+ model,
589
+ modelId,
590
+ bridgeKey,
591
+ convKey,
592
+ sessionId,
593
+ completedTurns: turns,
594
+ inFlightTurn,
595
+ maxMode,
596
+ cursorModelParameters: body.cursor_model_parameters ?? [],
597
+ getAccessToken,
598
+ },
599
+ writer,
600
+ options,
601
+ requestId,
602
+ );
603
+ return;
604
+ }
605
+ clearInterval(activeBridge.heartbeatTimer);
606
+ activeBridge.bridge.end();
607
+ }
608
+ const recoveryStored = getOrHydrateConversation(convKey);
609
+ const decision = planRecovery({
610
+ stored: recoveryStored,
611
+ toolResults,
612
+ completedTurns: turns,
613
+ inFlightTurn,
614
+ sessionId,
615
+ requestId,
616
+ convKey,
617
+ });
618
+ if (decision.kind === "recover") {
619
+ setLastStreamEvent("recovered_via_checkpoint");
620
+ debugLog("bridge.recovered_via_checkpoint", {
621
+ requestId,
622
+ bridgeKey,
623
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
624
+ convKey,
625
+ recoveryPath: "stored_checkpoint",
626
+ pendingToolCallIds: toolResults.map((r) => r.toolCallId),
627
+ });
628
+ const mcpTools = buildMcpToolDefinitions(selectedTools);
629
+ // Images ride the recovered user turn on this path too — dropping them here silently lost
630
+ // screenshots that the rebuild path preserves.
631
+ const recoveredUserImages = collectToolResultImages(toolResults);
632
+ const recoveredCurrentTurn: ParsedTurn = {
633
+ userText: decision.wrappedText,
634
+ steps: [],
635
+ ...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
636
+ };
637
+ const payload = buildCursorRequest({
638
+ modelId,
639
+ systemPrompt,
640
+ userText: decision.wrappedText,
641
+ userImages: recoveredUserImages,
642
+ turns,
643
+ conversationId: decision.conversationId,
644
+ checkpoint: decision.checkpoint,
645
+ existingBlobStore: decision.blobStore,
646
+ maxMode,
647
+ cursorModelParameters: body.cursor_model_parameters,
648
+ mcpTools,
649
+ });
650
+ payload.mcpTools = mcpTools;
651
+ startNativeStreamWithIdleRetries({
652
+ accessToken,
653
+ requestBytes: payload.requestBytes,
654
+ blobStore: payload.blobStore,
655
+ mcpTools: payload.mcpTools,
656
+ model,
657
+ modelId,
658
+ bridgeKey,
659
+ convKey,
660
+ completedTurns: turns,
661
+ contextCheckpoint: payload.contextCheckpoint,
662
+ currentTurn: recoveredCurrentTurn,
663
+ writer,
664
+ options,
665
+ requestId,
666
+ streamIdleTimeoutMs: resumeIdleTimeoutMs,
667
+ getAccessToken,
668
+ systemPrompt,
669
+ conversationId: decision.conversationId,
670
+ maxMode,
671
+ cursorModelParameters: body.cursor_model_parameters ?? [],
672
+ });
673
+ return;
674
+ }
675
+ if (decision.kind === "rebuild_full_history") {
676
+ setLastStreamEvent("rebuild_full_history");
677
+ logFullHistoryRebuild("native.rebuild_full_history", {
678
+ requestId,
679
+ bridgeKey,
680
+ convKey,
681
+ modelId,
682
+ decision,
683
+ });
684
+ const mcpTools = buildMcpToolDefinitions(selectedTools);
685
+ const rebuiltCompletedTurns = [...decision.completedTurns, decision.inFlightTurn];
686
+ const recoveredUserImages = collectToolResultImages(decision.toolResults);
687
+ const recoveredCurrentTurn: ParsedTurn = {
688
+ userText: decision.wrappedText,
689
+ steps: [],
690
+ ...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
691
+ };
692
+ const payload = buildCursorRequest({
693
+ modelId,
694
+ systemPrompt,
695
+ userText: decision.wrappedText,
696
+ userImages: recoveredUserImages,
697
+ turns: rebuiltCompletedTurns,
698
+ conversationId: decision.conversationId,
699
+ checkpoint: null,
700
+ existingBlobStore: decision.blobStore,
701
+ maxMode,
702
+ cursorModelParameters: body.cursor_model_parameters,
703
+ mcpTools,
704
+ });
705
+ payload.mcpTools = mcpTools;
706
+ if (recoveryStored) recoveryStored.lastAccessMs = Date.now();
707
+ startNativeStreamWithIdleRetries({
708
+ accessToken,
709
+ requestBytes: payload.requestBytes,
710
+ blobStore: payload.blobStore,
711
+ mcpTools: payload.mcpTools,
712
+ model,
713
+ modelId,
714
+ bridgeKey,
715
+ convKey,
716
+ completedTurns: rebuiltCompletedTurns,
717
+ contextCheckpoint: payload.contextCheckpoint,
718
+ currentTurn: recoveredCurrentTurn,
719
+ writer,
720
+ options,
721
+ requestId,
722
+ streamIdleTimeoutMs: resumeIdleTimeoutMs,
723
+ getAccessToken,
724
+ systemPrompt,
725
+ conversationId: decision.conversationId,
726
+ maxMode,
727
+ cursorModelParameters: body.cursor_model_parameters ?? [],
728
+ });
729
+ return;
730
+ }
731
+ setLastRecoverySkipReason(decision.reason);
732
+ setLastStreamEvent(`recovery_skipped:${decision.reason}`);
733
+ debugLog("bridge.recovery_skipped", {
734
+ requestId,
735
+ bridgeKey,
736
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
737
+ convKey,
738
+ skipReason: decision.reason,
739
+ hadStoredCheckpoint: decision.hadStoredCheckpoint,
740
+ ...(decision.expected !== undefined ? { expected: decision.expected } : {}),
741
+ ...(decision.received !== undefined ? { received: decision.received } : {}),
742
+ });
743
+ const message = `${lostToolContinuationMessage()} ${formatLostToolContinuationDiagnostic({
744
+ bridgeKey,
745
+ hadStoredCheckpoint: decision.hadStoredCheckpoint,
746
+ skipReason: decision.reason,
747
+ })}`;
748
+ debugLog("native.lost_tool_continuation", {
749
+ requestId,
750
+ bridgeKey,
751
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
752
+ convKey,
753
+ skipReason: decision.reason,
754
+ toolResults,
755
+ message,
756
+ });
757
+ writer.error(message, "error");
758
+ return;
759
+ }
760
+
761
+ if (activeBridge && activeBridges.has(bridgeKey)) {
762
+ clearInterval(activeBridge.heartbeatTimer);
763
+ activeBridge.bridge.end();
764
+ removeActiveBridge(bridgeKey);
765
+ }
766
+
767
+ let stored = getOrHydrateConversation(convKey);
768
+ if (!stored) {
769
+ stored = {
770
+ conversationId: deterministicConversationId(convKey),
771
+ checkpoint: null,
772
+ sessionScoped: !!sessionId,
773
+ ...(sessionId ? { sessionId } : {}),
774
+ blobStore: new Map(),
775
+ lastAccessMs: Date.now(),
776
+ };
777
+ conversationStates.set(convKey, stored);
778
+ }
779
+ stored.lastAccessMs = Date.now();
780
+ evictStaleConversations();
781
+ discardStaleCheckpointIfNeeded(stored, turns, requestId, convKey);
782
+
783
+ const mcpTools = buildMcpToolDefinitions(selectedTools);
784
+ const effectiveUserText = userText;
785
+ const effectiveUserImages = userText || userImages.length > 0 ? userImages : [];
786
+ // Pi rewrites its system prompt as a session evolves (context-mode folds
787
+ // session memory into it). A checkpoint carries the prompt recorded when the
788
+ // conversation started, so a changed prompt has to be re-published.
789
+ const systemPromptHash = hashSystemPrompt(effectiveSystemPrompt);
790
+ const refreshSystemPrompt = !!stored.checkpoint && stored.systemPromptHash !== systemPromptHash;
791
+ const payload = buildCursorRequest({
792
+ modelId,
793
+ systemPrompt: effectiveSystemPrompt,
794
+ userText: effectiveUserText,
795
+ turns,
796
+ conversationId: stored.conversationId,
797
+ checkpoint: stored.checkpoint,
798
+ existingBlobStore: stored.blobStore,
799
+ maxMode,
800
+ cursorModelParameters: body.cursor_model_parameters,
801
+ mcpTools,
802
+ userImages: effectiveUserImages,
803
+ refreshSystemPrompt,
804
+ });
805
+ stored.systemPromptHash = systemPromptHash;
806
+ payload.mcpTools = mcpTools;
807
+
808
+ const currentTurn: ParsedTurn = {
809
+ userText: effectiveUserText,
810
+ steps: [],
811
+ ...(effectiveUserImages.length > 0 ? { userImages: effectiveUserImages } : {}),
812
+ };
813
+
814
+ const size = summarizeRequestSize({
815
+ systemPrompt: effectiveSystemPrompt,
816
+ userText: effectiveUserText,
817
+ tools: selectedTools,
818
+ mcpTools,
819
+ requestBytes: payload.requestBytes,
820
+ blobStore: payload.blobStore,
821
+ turnCount: turns.length,
822
+ });
823
+ const sizeSummary =
824
+ `approxTokens=${size.approxInputTokens} systemChars=${size.systemChars} ` +
825
+ `userChars=${size.userChars} tools=${size.toolCount} toolJsonChars=${size.toolJsonChars} ` +
826
+ `mcpSchemaBytes=${size.mcpSchemaBytes} requestBytes=${size.requestBytes} ` +
827
+ `blobBytes=${size.blobBytes} wireBytes=${size.wireBytes} turns=${size.turnCount}`;
828
+ setLastRequestSize(sizeSummary);
829
+ lifecycleLog("request_size", {
830
+ requestId,
831
+ bridgeKey: bridgeKeyPrefix(bridgeKey),
832
+ convKey,
833
+ modelId,
834
+ ...size,
835
+ });
836
+
837
+ debugLog("native.dispatch_stream", {
838
+ requestId,
839
+ bridgeKey,
840
+ convKey,
841
+ conversationId: stored.conversationId,
842
+ hasCheckpoint: !!stored.checkpoint,
843
+ requestSize: size,
844
+ payload,
845
+ });
846
+ startNativeStreamWithIdleRetries({
847
+ accessToken,
848
+ requestBytes: payload.requestBytes,
849
+ blobStore: payload.blobStore,
850
+ mcpTools: payload.mcpTools,
851
+ model,
852
+ modelId,
853
+ bridgeKey,
854
+ convKey,
855
+ completedTurns: turns,
856
+ contextCheckpoint: payload.contextCheckpoint,
857
+ currentTurn,
858
+ writer,
859
+ options,
860
+ requestId,
861
+ getAccessToken,
862
+ recoverBeforeRetry: true,
863
+ systemPrompt: effectiveSystemPrompt,
864
+ conversationId: stored.conversationId,
865
+ maxMode,
866
+ cursorModelParameters: body.cursor_model_parameters ?? [],
867
+ });
868
+ }
869
+
870
+ function writeNativeStream(
871
+ bridge: BridgeHandle,
872
+ heartbeatTimer: ReturnType<typeof setInterval>,
873
+ blobStore: Map<string, Uint8Array>,
874
+ mcpTools: McpToolDefinition[],
875
+ _model: Model<Api>,
876
+ modelId: string,
877
+ bridgeKey: string,
878
+ convKey: string,
879
+ completedTurns: ParsedTurn[],
880
+ currentTurn: ParsedTurn,
881
+ writer: NativeStreamWriter,
882
+ options?: CursorNativeStreamOptions,
883
+ requestId?: string,
884
+ idleRetry?: StreamIdleRetryController,
885
+ streamIdleTimeoutMs = resolveStreamIdleTimeoutMs(process.env.PI_CURSOR_STREAM_IDLE_TIMEOUT_MS),
886
+ checkpointRef: CheckpointRef = { current: null },
887
+ preservedMidPauseExecs: PendingExec[] = [],
888
+ clientTranscript: ClientTranscript = liveTranscript(completedTurns),
889
+ ): void {
890
+ const persistenceTurns = clientTranscript.completedTurns;
891
+ debugLog("native.stream.start", {
892
+ requestId,
893
+ bridgeKey,
894
+ convKey,
895
+ modelId,
896
+ attempt: idleRetry?.currentAttempt ?? 1,
897
+ maxRetries: idleRetry?.maxRetries ?? 0,
898
+ });
899
+ lifecycleLog("stream_start", {
900
+ requestId,
901
+ bridgeKey: bridgeKeyPrefix(bridgeKey),
902
+ convKey,
903
+ modelId,
904
+ attempt: idleRetry?.currentAttempt ?? 1,
905
+ });
906
+ const runUsage = (checkpointRef.usage ??= {});
907
+ runUsage.modelId ??= _model.id;
908
+ runUsage.rates ??= _model.cost;
909
+ const onUsageBoundary = (reason: string) => {
910
+ retainRunReceipt(conversationStates.get(convKey), runUsage);
911
+ reportRunUsageBoundary(runUsage, reason);
912
+ };
913
+ const state: StreamState = {
914
+ runUsage,
915
+ toolCallIndex: 0,
916
+ pendingExecs: [],
917
+ outputTokens: 0,
918
+ totalTokens: checkpointRef.contextTokens ?? 0,
919
+ turnEnded: false,
920
+ };
921
+ const tagFilter = createThinkingTagFilter();
922
+ let mcpExecReceived = false;
923
+ let cancelled = false;
924
+ let frameParseFailed = false;
925
+ let streamError: Error | null = null;
926
+ let emittedUserVisibleContent = false;
927
+ // Only execs the client was actually told about may be recorded as pending: recovery matches the
928
+ // snapshot against the tool results the client sends back, and it can only send back what it saw.
929
+ const emittedExecs: PendingExec[] = [];
930
+ // Cursor can emit several execs in one chunk. Closing the writer on the first would hide the
931
+ // rest, so the pause is deferred until the whole chunk has been parsed.
932
+ let pauseRequested = false;
933
+ let cachedHistoryFingerprint: string | undefined;
934
+ // An exec Cursor asked for and we could not answer. The run is waiting on a reply
935
+ // it will never recognize, so heartbeats stop counting as progress and the watchdog
936
+ // switches to the shorter park deadline until real work resumes.
937
+ let parkedExecCase: string | undefined;
938
+ // A park deadline can only be shorter than the silence deadline, and an explicitly
939
+ // disabled watchdog stays disabled.
940
+ const parkTimeoutMs =
941
+ streamIdleTimeoutMs <= 0 ? 0 : Math.min(streamIdleTimeoutMs, DEFAULT_STREAM_PARK_TIMEOUT_MS);
942
+ // Completed turns are fixed for the life of a stream, so this is hashed at most once.
943
+ const historyFingerprint = () =>
944
+ (cachedHistoryFingerprint ??= fingerprintCompletedTurns(completedTurns));
945
+ const idleWatchdog = createStreamIdleWatchdog({
946
+ timeoutMs: streamIdleTimeoutMs,
947
+ onTimeout: () => {
948
+ if (cancelled || writer.closed) return;
949
+ cancelled = true;
950
+ idleWatchdog.clear();
951
+ const attempt = idleRetry?.currentAttempt ?? 1;
952
+ const maxRetries = idleRetry?.maxRetries ?? 0;
953
+ const restartContext: IdleRestartContext = {
954
+ emittedUserVisibleContent,
955
+ latestCheckpoint: checkpointRef.current,
956
+ blobStore,
957
+ completedTurns,
958
+ currentTurn,
959
+ };
960
+ debugLog("native.stream.idle_timeout", {
961
+ requestId,
962
+ bridgeKey,
963
+ convKey,
964
+ modelId,
965
+ timeoutMs: streamIdleTimeoutMs,
966
+ attempt,
967
+ maxRetries,
968
+ emittedUserVisibleContent,
969
+ hasCheckpoint: !!checkpointRef.current,
970
+ });
971
+ setLastIdleTimeout({
972
+ timeoutMs: parkedExecCase === undefined ? streamIdleTimeoutMs : parkTimeoutMs,
973
+ attempt,
974
+ event: parkedExecCase === undefined ? "idle_timeout" : "park_timeout",
975
+ });
976
+ persistAbortedConversationState(
977
+ convKey,
978
+ checkpointRef.current,
979
+ blobStore,
980
+ persistenceTurns,
981
+ currentTurn,
982
+ emittedExecs.length > 0 ? emittedExecs : preservedMidPauseExecs,
983
+ );
984
+ cleanupBridge(bridge, heartbeatTimer, bridgeKey);
985
+ options?.signal?.removeEventListener("abort", abort);
986
+
987
+ // An unanswered exec is a schema gap, not silence. Retrying the same
988
+ // request re-issues the exec we still cannot decode.
989
+ if (parkedExecCase !== undefined) {
990
+ writer.error(formatStreamParkMessage(parkedExecCase, parkTimeoutMs), "error", state);
991
+ return;
992
+ }
993
+
994
+ // Blind restart is only safe with zero streamed content. Checkpoint
995
+ // continuation is safe even after partial text: Cursor resumes server
996
+ // state and emits only new tokens, which Pi appends to the writer.
997
+ const allowRestart = canRecoverAfterTransportLoss({
998
+ emittedUserVisibleContent,
999
+ hasCheckpoint: !!checkpointRef.current,
1000
+ });
1001
+
1002
+ // Recovery is not a retry, so it can run even when maxRetries is zero.
1003
+ if (idleRetry?.recoverBeforeRetry && allowRestart) {
1004
+ debugLog("native.stream.idle_recovery_before_retry", {
1005
+ requestId,
1006
+ bridgeKey,
1007
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
1008
+ convKey,
1009
+ modelId,
1010
+ attempt,
1011
+ maxRetries,
1012
+ hasCheckpoint: !!checkpointRef.current,
1013
+ emittedUserVisibleContent,
1014
+ });
1015
+ setLastStreamEvent("idle_recovery_before_retry");
1016
+ try {
1017
+ if (idleRetry.restart(attempt, restartContext)) return;
1018
+ } catch (error) {
1019
+ // Recovery errors fall through into the normal retry-budget path below.
1020
+ debugLog("native.stream.idle_recovery_before_retry_error", {
1021
+ requestId,
1022
+ bridgeKey,
1023
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
1024
+ convKey,
1025
+ modelId,
1026
+ message: error instanceof Error ? error.message : String(error),
1027
+ });
1028
+ }
1029
+ }
1030
+ let finalAttempt = attempt;
1031
+ if (idleRetry && attempt <= maxRetries && allowRestart) {
1032
+ const nextAttempt = attempt + 1;
1033
+ finalAttempt = nextAttempt;
1034
+ debugLog("native.stream.idle_retry", {
1035
+ requestId,
1036
+ bridgeKey,
1037
+ convKey,
1038
+ modelId,
1039
+ attempt,
1040
+ nextAttempt,
1041
+ maxRetries,
1042
+ hasCheckpoint: !!checkpointRef.current,
1043
+ emittedUserVisibleContent,
1044
+ });
1045
+ setLastStreamEvent("idle_retry");
1046
+ try {
1047
+ if (idleRetry.restart(nextAttempt, restartContext)) return;
1048
+ } catch (error) {
1049
+ debugLog("native.stream.idle_retry_error", {
1050
+ requestId,
1051
+ bridgeKey,
1052
+ convKey,
1053
+ modelId,
1054
+ message: error instanceof Error ? error.message : String(error),
1055
+ });
1056
+ }
1057
+ }
1058
+ writer.error(
1059
+ formatStreamIdleTimeoutMessage(
1060
+ streamIdleTimeoutMs,
1061
+ finalAttempt,
1062
+ maxRetries,
1063
+ emittedUserVisibleContent,
1064
+ ),
1065
+ "error",
1066
+ state,
1067
+ );
1068
+ },
1069
+ });
1070
+
1071
+ const abort = () => {
1072
+ if (cancelled || writer.closed) return;
1073
+ cancelled = true;
1074
+ persistAbortedConversationState(
1075
+ convKey,
1076
+ checkpointRef.current,
1077
+ blobStore,
1078
+ persistenceTurns,
1079
+ currentTurn,
1080
+ emittedExecs.length > 0 ? emittedExecs : preservedMidPauseExecs,
1081
+ );
1082
+ debugLog("native.stream.abort", {
1083
+ requestId,
1084
+ bridgeKey,
1085
+ convKey,
1086
+ hasCheckpoint: !!checkpointRef.current,
1087
+ });
1088
+ idleWatchdog.clear();
1089
+ cleanupBridge(bridge, heartbeatTimer, bridgeKey);
1090
+ writer.error("Aborted", "aborted", state);
1091
+ };
1092
+ options?.signal?.addEventListener("abort", abort, { once: true });
1093
+ if (options?.signal?.aborted) {
1094
+ abort();
1095
+ return;
1096
+ }
1097
+ idleWatchdog.start();
1098
+
1099
+ let streamFinalized = false;
1100
+
1101
+ const emitText = (text: string, isThinking?: boolean) => {
1102
+ if (writer.closed) return;
1103
+ // A staged pause means the tool-call block is already on the response and `toolUse` is about to
1104
+ // close it. Text emitted now would land *after* that block, which breaks the invariant that a
1105
+ // tool-use turn ends on its tool call. Before the pause was deferred the closed writer dropped
1106
+ // this text anyway, so nothing regresses by dropping it explicitly.
1107
+ if (pauseRequested) return;
1108
+ if (isThinking) {
1109
+ emittedUserVisibleContent = true;
1110
+ writer.thinking(text);
1111
+ return;
1112
+ }
1113
+ const { content, reasoning } = tagFilter.process(text);
1114
+ if (reasoning) {
1115
+ emittedUserVisibleContent = true;
1116
+ writer.thinking(reasoning);
1117
+ }
1118
+ if (content) {
1119
+ emittedUserVisibleContent = true;
1120
+ appendAssistantTextToTurn(currentTurn, content);
1121
+ writer.text(content);
1122
+ }
1123
+ };
1124
+
1125
+ const emitFlushed = () => {
1126
+ // Same ordering rule as emitText: once a tool-call block is on the response, nothing may be
1127
+ // appended after it. The first exec of a chunk flushes before staging its pause, so pending
1128
+ // text still reaches the client ahead of the tool call.
1129
+ if (pauseRequested) return;
1130
+ const flushed = tagFilter.flush();
1131
+ if (flushed.reasoning) {
1132
+ emittedUserVisibleContent = true;
1133
+ writer.thinking(flushed.reasoning);
1134
+ }
1135
+ if (flushed.content) {
1136
+ emittedUserVisibleContent = true;
1137
+ appendAssistantTextToTurn(currentTurn, flushed.content);
1138
+ writer.text(flushed.content);
1139
+ }
1140
+ };
1141
+
1142
+ const finalizeSuccessfulStream = () => {
1143
+ if (cancelled || streamFinalized) return;
1144
+ streamFinalized = true;
1145
+ queueMicrotask(() => onUsageBoundary("stream_end"));
1146
+ idleWatchdog.clear();
1147
+ clearInterval(heartbeatTimer);
1148
+ options?.signal?.removeEventListener("abort", abort);
1149
+ const stored = conversationStates.get(convKey);
1150
+ if (mcpExecReceived) {
1151
+ handleBridgeCloseMidPause({
1152
+ stored,
1153
+ latestCheckpoint: checkpointRef.current,
1154
+ blobStore,
1155
+ completedTurns: persistenceTurns,
1156
+ pendingExecs: emittedExecs,
1157
+ convKey,
1158
+ });
1159
+ removeActiveBridge(bridgeKey);
1160
+ return;
1161
+ }
1162
+ emitFlushed();
1163
+ if (stored) {
1164
+ if (checkpointRef.current) {
1165
+ commitStoredCheckpoint(
1166
+ stored,
1167
+ checkpointRef.current,
1168
+ blobStore,
1169
+ completedTurns,
1170
+ currentTurn,
1171
+ convKey,
1172
+ );
1173
+ debugLog("native.stream.checkpoint_committed", { requestId, convKey, stored });
1174
+ } else {
1175
+ mergeBlobStore(stored, blobStore);
1176
+ }
1177
+ }
1178
+ writer.done("stop", state);
1179
+ parkIdleBridge(bridgeKey, bridge);
1180
+ };
1181
+ bridge.onStreamDone?.(finalizeSuccessfulStream);
1182
+
1183
+ const processChunk = createConnectFrameParser(
1184
+ (messageBytes) => {
1185
+ try {
1186
+ const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
1187
+ const progress = processServerMessage(
1188
+ serverMessage,
1189
+ blobStore,
1190
+ mcpTools,
1191
+ (data) => bridge.write(data),
1192
+ state,
1193
+ emitText,
1194
+ (exec) => {
1195
+ idleWatchdog.pause();
1196
+ state.pendingExecs.push(exec);
1197
+ mcpExecReceived = true;
1198
+ emitFlushed();
1199
+ currentTurn.steps.push({
1200
+ kind: "toolCall",
1201
+ toolCallId: exec.toolCallId,
1202
+ toolName: exec.toolName,
1203
+ arguments: parseToolCallArguments(exec.decodedArgs),
1204
+ });
1205
+
1206
+ // Emit before snapshotting: an exec the writer refuses is one the client will never
1207
+ // answer, so it must not be recorded as pending.
1208
+ if (!writer.closed) {
1209
+ writer.toolCall(exec);
1210
+ emittedExecs.push(exec);
1211
+ pauseRequested = true;
1212
+ }
1213
+
1214
+ const stored = conversationStates.get(convKey);
1215
+ // Nothing reached the client, so there is no continuation to snapshot — and writing an
1216
+ // empty one here would clear a checkpoint this stream may still need.
1217
+ if (stored && emittedExecs.length > 0) {
1218
+ commitStoredCheckpointMidPause(
1219
+ stored,
1220
+ checkpointRef.current,
1221
+ blobStore,
1222
+ persistenceTurns,
1223
+ emittedExecs,
1224
+ convKey,
1225
+ );
1226
+ debugLog(
1227
+ checkpointRef.current
1228
+ ? "native.stream.tool_call_checkpoint_saved"
1229
+ : "native.stream.tool_call_snapshot_saved",
1230
+ {
1231
+ requestId,
1232
+ bridgeKey,
1233
+ convKey,
1234
+ checkpointSource: checkpointRef.current ? "upstream" : "absent",
1235
+ pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
1236
+ },
1237
+ );
1238
+ }
1239
+
1240
+ setActiveBridge(bridgeKey, {
1241
+ bridge,
1242
+ heartbeatTimer,
1243
+ blobStore,
1244
+ mcpTools,
1245
+ pendingExecs: state.pendingExecs,
1246
+ currentTurn,
1247
+ checkpointRef,
1248
+ state,
1249
+ historyFingerprint: historyFingerprint(),
1250
+ clientTranscript,
1251
+ });
1252
+ debugLog("native.stream.tool_call_pause", {
1253
+ requestId,
1254
+ bridgeKey,
1255
+ exec,
1256
+ pendingExecs: state.pendingExecs,
1257
+ emittedToolCallIds: emittedExecs.map((e) => e.toolCallId),
1258
+ currentTurn,
1259
+ });
1260
+ },
1261
+ (checkpointBytes, contextTokens) => {
1262
+ checkpointRef.current = checkpointBytes;
1263
+ if (contextTokens !== undefined) {
1264
+ checkpointRef.contextTokens = contextTokens;
1265
+ writer.contextSnapshot?.(contextTokens, checkpointBytes);
1266
+ }
1267
+ debugLog("native.stream.checkpoint_buffered", { requestId, convKey, checkpointBytes });
1268
+ },
1269
+ (execCase) => {
1270
+ parkedExecCase = execCase ?? "unknown";
1271
+ debugLog("native.stream.exec_park", { requestId, bridgeKey, convKey, execCase });
1272
+ idleWatchdog.setTimeoutMs(parkTimeoutMs);
1273
+ idleWatchdog.reset();
1274
+ },
1275
+ convKey,
1276
+ );
1277
+ if (progress === "work") {
1278
+ if (parkedExecCase !== undefined) {
1279
+ parkedExecCase = undefined;
1280
+ idleWatchdog.setTimeoutMs(streamIdleTimeoutMs);
1281
+ }
1282
+ idleWatchdog.reset();
1283
+ } else if (progress === "liveness" && parkedExecCase === undefined) {
1284
+ idleWatchdog.reset();
1285
+ }
1286
+ } catch (err) {
1287
+ const message = err instanceof Error ? err.message : String(err);
1288
+ debugLog("native.stream.process_error", { requestId, message });
1289
+ if (!cancelled) {
1290
+ cancelled = true;
1291
+ idleWatchdog.clear();
1292
+ options?.signal?.removeEventListener("abort", abort);
1293
+ cleanupBridge(bridge, heartbeatTimer, bridgeKey);
1294
+ if (!writer.closed) writer.error(message, "error", state);
1295
+ }
1296
+ }
1297
+ },
1298
+ (endStreamBytes) => {
1299
+ const endError = parseConnectEndStream(endStreamBytes);
1300
+ if (endError) {
1301
+ // Cursor closes the connection right after `turnEnded`. That close ends a completed
1302
+ // turn; reporting it as an error made Pi retry and duplicate the answer (upstream #3).
1303
+ if (state.turnEnded && !pauseRequested) {
1304
+ debugLog("native.stream.post_turn_close", {
1305
+ requestId,
1306
+ modelId,
1307
+ message: endError.message,
1308
+ });
1309
+ return;
1310
+ }
1311
+ streamError = endError;
1312
+ const enhanced = enhanceCursorStreamError(endError.message);
1313
+ debugLog("native.stream.cursor_error", {
1314
+ requestId,
1315
+ modelId,
1316
+ message: endError.message,
1317
+ enhanced,
1318
+ isAuthError: isAuthErrorMessage(endError.message),
1319
+ deferredToToolPause: pauseRequested,
1320
+ });
1321
+ // A tool pause staged earlier in this same chunk wins: the client needs the tool call to
1322
+ // continue, and `streamError` still routes the close through mid-pause snapshotting so the
1323
+ // returning results land in recovery rather than on a bridge nobody is holding.
1324
+ if (!pauseRequested) writer.error(enhanced, "error", state);
1325
+ }
1326
+ },
1327
+ );
1328
+
1329
+ bridge.onData((chunk) => {
1330
+ // Watchdog reset moved into the framed-message handler above so non-progress chunks
1331
+ // (notably `interactionUpdate{tokenDelta}`-only frames) cannot keep the stream alive
1332
+ // forever.
1333
+ try {
1334
+ processChunk(chunk);
1335
+ } catch (error) {
1336
+ const message = error instanceof Error ? error.message : String(error);
1337
+ const desync =
1338
+ error instanceof Error
1339
+ ? (error as Error & { connectFrameDesync?: ConnectFrameDesyncDiagnostics })
1340
+ .connectFrameDesync
1341
+ : undefined;
1342
+ debugLog("native.stream.frame_error", { requestId, message, desync });
1343
+ // A corrupted/misaligned Connect frame boundary can't be recovered within this
1344
+ // connection, but the desync is local per-connection state, not a permanent condition —
1345
+ // killing the transport (rather than failing the stream outright) routes this through the
1346
+ // same bridge.onClose retry path as any other transport loss (GOAWAY, ECONNRESET, ...),
1347
+ // so a fresh connection + checkpoint/history recovery can continue the turn instead of
1348
+ // the whole turn failing on what may be a one-off glitch.
1349
+ if (!cancelled && !frameParseFailed) {
1350
+ frameParseFailed = true;
1351
+ try {
1352
+ bridge.kill();
1353
+ } catch {
1354
+ // Transport may already be closing.
1355
+ }
1356
+ }
1357
+ return;
1358
+ }
1359
+ // Closing the response is deferred to here so that every exec framed in this chunk reaches
1360
+ // the client, not just the first. Parallel tool calls arrive as sibling frames.
1361
+ if (pauseRequested) {
1362
+ pauseRequested = false;
1363
+ if (!writer.closed) writer.done("toolUse", state);
1364
+ }
1365
+ });
1366
+
1367
+ bridge.onClose((code) => {
1368
+ // Let this callback finalize/claim any receipt first; then diagnose genuinely unsettled runs.
1369
+ queueMicrotask(() => onUsageBoundary("transport_close"));
1370
+ debugLog("native.stream.bridge_close", {
1371
+ requestId,
1372
+ bridgeKey,
1373
+ convKey,
1374
+ code,
1375
+ cancelled,
1376
+ mcpExecReceived,
1377
+ streamFinalized,
1378
+ currentTurn,
1379
+ latestCheckpoint: checkpointRef.current,
1380
+ });
1381
+ lifecycleLog("bridge_close", {
1382
+ requestId,
1383
+ bridgeKey: bridgeKeyPrefix(bridgeKey),
1384
+ convKey,
1385
+ code,
1386
+ cancelled,
1387
+ mcpExecReceived,
1388
+ emittedUserVisibleContent,
1389
+ hasCheckpoint: !!checkpointRef.current,
1390
+ });
1391
+ if (streamFinalized) return;
1392
+ idleWatchdog.clear();
1393
+ clearInterval(heartbeatTimer);
1394
+ options?.signal?.removeEventListener("abort", abort);
1395
+
1396
+ if (cancelled) return;
1397
+ const stored = conversationStates.get(convKey);
1398
+ if (streamError) {
1399
+ if (mcpExecReceived) {
1400
+ const midPauseResult = handleBridgeCloseMidPause({
1401
+ stored,
1402
+ latestCheckpoint: checkpointRef.current,
1403
+ blobStore,
1404
+ completedTurns: persistenceTurns,
1405
+ pendingExecs: emittedExecs,
1406
+ convKey,
1407
+ });
1408
+ debugLog(
1409
+ midPauseResult.committed
1410
+ ? "bridge.died_mid_pause_checkpoint_saved"
1411
+ : "bridge.died_mid_pause_no_checkpoint",
1412
+ {
1413
+ requestId,
1414
+ bridgeKey,
1415
+ convKey,
1416
+ cause: "stream_error",
1417
+ pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
1418
+ },
1419
+ );
1420
+ }
1421
+ removeActiveBridge(bridgeKey);
1422
+ return;
1423
+ }
1424
+
1425
+ // Same completed-turn rule as the end-stream frame: the non-zero exit that follows a
1426
+ // GOAWAY after `turnEnded` must finalize the turn, not restart it (upstream #3).
1427
+ const completedTurnClose = state.turnEnded && !mcpExecReceived;
1428
+
1429
+ if (code !== 0 && !completedTurnClose) {
1430
+ const failure = classifyBridgeExit({
1431
+ exitCode: code,
1432
+ stderr: typeof bridge.lastStderr === "function" ? bridge.lastStderr() : "",
1433
+ });
1434
+ const allowRestart =
1435
+ failure.retryable &&
1436
+ canRecoverAfterTransportLoss({
1437
+ emittedUserVisibleContent,
1438
+ hasCheckpoint: !!checkpointRef.current,
1439
+ });
1440
+ if (allowRestart && idleRetry) {
1441
+ const attempt = idleRetry.currentAttempt;
1442
+ const maxRetries = idleRetry.maxRetries;
1443
+ if (attempt <= maxRetries) {
1444
+ debugLog("native.stream.transport_retry", {
1445
+ requestId,
1446
+ bridgeKey,
1447
+ convKey,
1448
+ modelId,
1449
+ attempt,
1450
+ maxRetries,
1451
+ failureKind: failure.kind,
1452
+ hasCheckpoint: !!checkpointRef.current,
1453
+ emittedUserVisibleContent,
1454
+ });
1455
+ setLastStreamEvent(`transport_retry:${failure.kind}`);
1456
+ persistAbortedConversationState(
1457
+ convKey,
1458
+ checkpointRef.current,
1459
+ blobStore,
1460
+ persistenceTurns,
1461
+ currentTurn,
1462
+ emittedExecs.length > 0 ? emittedExecs : preservedMidPauseExecs,
1463
+ );
1464
+ cleanupBridge(bridge, heartbeatTimer, bridgeKey);
1465
+ options?.signal?.removeEventListener("abort", abort);
1466
+ try {
1467
+ if (
1468
+ idleRetry.restart(attempt + 1, {
1469
+ emittedUserVisibleContent,
1470
+ latestCheckpoint: checkpointRef.current,
1471
+ blobStore,
1472
+ completedTurns,
1473
+ currentTurn,
1474
+ })
1475
+ )
1476
+ return;
1477
+ } catch {
1478
+ // Fall through to error
1479
+ }
1480
+ }
1481
+ }
1482
+ if (mcpExecReceived) {
1483
+ const midPauseResult = handleBridgeCloseMidPause({
1484
+ stored,
1485
+ latestCheckpoint: checkpointRef.current,
1486
+ blobStore,
1487
+ completedTurns: persistenceTurns,
1488
+ pendingExecs: emittedExecs,
1489
+ convKey,
1490
+ });
1491
+ debugLog(
1492
+ midPauseResult.committed
1493
+ ? "bridge.died_mid_pause_checkpoint_saved"
1494
+ : "bridge.died_mid_pause_no_checkpoint",
1495
+ {
1496
+ requestId,
1497
+ bridgeKey,
1498
+ convKey,
1499
+ code,
1500
+ failureKind: failure.kind,
1501
+ pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
1502
+ },
1503
+ );
1504
+ }
1505
+ writer.error(formatTransportFailure(failure), "error", state);
1506
+ removeActiveBridge(bridgeKey);
1507
+ return;
1508
+ }
1509
+
1510
+ if (!mcpExecReceived) {
1511
+ emitFlushed();
1512
+ if (stored) {
1513
+ if (checkpointRef.current) {
1514
+ commitStoredCheckpoint(
1515
+ stored,
1516
+ checkpointRef.current,
1517
+ blobStore,
1518
+ completedTurns,
1519
+ currentTurn,
1520
+ convKey,
1521
+ );
1522
+ debugLog("native.stream.checkpoint_committed", { requestId, convKey, stored });
1523
+ } else {
1524
+ mergeBlobStore(stored, blobStore);
1525
+ }
1526
+ }
1527
+ writer.done("stop", state);
1528
+ } else {
1529
+ const midPauseResult = handleBridgeCloseMidPause({
1530
+ stored,
1531
+ latestCheckpoint: checkpointRef.current,
1532
+ blobStore,
1533
+ completedTurns: persistenceTurns,
1534
+ pendingExecs: emittedExecs,
1535
+ convKey,
1536
+ });
1537
+ debugLog(
1538
+ midPauseResult.committed
1539
+ ? "bridge.died_mid_pause_checkpoint_saved"
1540
+ : "bridge.died_mid_pause_no_checkpoint",
1541
+ {
1542
+ requestId,
1543
+ bridgeKey,
1544
+ convKey,
1545
+ pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
1546
+ },
1547
+ );
1548
+ removeActiveBridge(bridgeKey);
1549
+ }
1550
+ });
1551
+ }
1552
+
1553
+ interface ResumeContext {
1554
+ accessToken: string;
1555
+ systemPrompt: string;
1556
+ model: Model<Api>;
1557
+ modelId: string;
1558
+ bridgeKey: string;
1559
+ convKey: string;
1560
+ sessionId: string | undefined;
1561
+ completedTurns: ParsedTurn[];
1562
+ /** Pi's in-flight turn from the resume request, not the bridge's currentTurn suffix. */
1563
+ inFlightTurn?: ParsedTurn;
1564
+ maxMode: boolean;
1565
+ cursorModelParameters: CursorModelParameter[];
1566
+ getAccessToken?: (options?: { forceRefresh?: boolean }) => Promise<string>;
1567
+ }
1568
+
1569
+ function handleNativeToolResultResume(
1570
+ active: ActiveBridge,
1571
+ toolResults: ToolResultInfo[],
1572
+ ctx: ResumeContext,
1573
+ writer: NativeStreamWriter,
1574
+ options?: CursorNativeStreamOptions,
1575
+ requestId?: string,
1576
+ ): void {
1577
+ const {
1578
+ accessToken,
1579
+ systemPrompt,
1580
+ model,
1581
+ modelId,
1582
+ bridgeKey,
1583
+ convKey,
1584
+ sessionId,
1585
+ completedTurns,
1586
+ maxMode,
1587
+ cursorModelParameters,
1588
+ getAccessToken,
1589
+ } = ctx;
1590
+ const {
1591
+ bridge,
1592
+ heartbeatTimer,
1593
+ blobStore,
1594
+ mcpTools,
1595
+ pendingExecs,
1596
+ currentTurn,
1597
+ checkpointRef,
1598
+ state: pausedState,
1599
+ historyFingerprint,
1600
+ clientTranscript: parkedTranscript,
1601
+ } = active;
1602
+ writer.contextMode?.("live", checkpointRef.contextTokens, checkpointRef.current ?? undefined);
1603
+ const resumeTranscript = parkedTranscript ?? liveTranscript(completedTurns);
1604
+ const recoveredClientTranscript = withSyntheticCurrentTurn(
1605
+ resumeTranscript,
1606
+ ctx.inFlightTurn ?? currentTurn,
1607
+ );
1608
+ const resumeIdleTimeoutMs = resolveResumeIdleTimeoutMs(
1609
+ process.env.PI_CURSOR_RESUME_IDLE_TIMEOUT_MS,
1610
+ );
1611
+ const transportResults = toolResults.map((result) => ({
1612
+ ...result,
1613
+ ...normalizeToolResultForTransport(result),
1614
+ }));
1615
+ debugLog("native.tool_resume.start", {
1616
+ requestId,
1617
+ bridgeKey,
1618
+ convKey,
1619
+ toolResults: transportResults.map((result) => ({
1620
+ toolCallId: result.toolCallId,
1621
+ contentBytes: Buffer.byteLength(result.content, "utf8"),
1622
+ imageCount: result.images?.length ?? 0,
1623
+ imageBytes: result.images?.reduce((sum, image) => sum + image.data.byteLength, 0) ?? 0,
1624
+ isError: result.isError === true,
1625
+ })),
1626
+ pendingExecs,
1627
+ currentTurn,
1628
+ });
1629
+
1630
+ for (const result of transportResults) {
1631
+ const turnToolStep = currentTurn.steps.find(
1632
+ (step): step is ParsedToolCallStep =>
1633
+ step.kind === "toolCall" && step.toolCallId === result.toolCallId,
1634
+ );
1635
+ if (turnToolStep) {
1636
+ turnToolStep.result = {
1637
+ content: result.content,
1638
+ images: result.images,
1639
+ isError: result.isError === true,
1640
+ };
1641
+ }
1642
+ }
1643
+
1644
+ const turnResults = getTurnToolCallResults(currentTurn);
1645
+ const unresolvedExecs = pendingExecs.filter((exec) => !turnResults.has(exec.toolCallId));
1646
+ if (unresolvedExecs.length > 0) {
1647
+ setActiveBridge(bridgeKey, {
1648
+ bridge,
1649
+ heartbeatTimer,
1650
+ blobStore,
1651
+ mcpTools,
1652
+ pendingExecs,
1653
+ currentTurn,
1654
+ checkpointRef,
1655
+ state: pausedState,
1656
+ historyFingerprint,
1657
+ });
1658
+ debugLog("native.tool_resume.partial_wait", {
1659
+ requestId,
1660
+ bridgeKey,
1661
+ unresolvedExecs,
1662
+ currentTurn,
1663
+ });
1664
+ // Re-emitting here is the only way the client learns about execs that arrived after its
1665
+ // response closed, so the snapshot has to grow with them.
1666
+ const stored = conversationStates.get(convKey);
1667
+ if (stored) {
1668
+ commitStoredCheckpointMidPause(
1669
+ stored,
1670
+ checkpointRef.current,
1671
+ blobStore,
1672
+ completedTurns,
1673
+ [...pendingExecs],
1674
+ convKey,
1675
+ );
1676
+ }
1677
+ for (const exec of unresolvedExecs) writer.toolCall(exec);
1678
+ writer.done("toolUse", pausedState);
1679
+ return;
1680
+ }
1681
+
1682
+ for (const exec of pendingExecs) {
1683
+ const result = turnResults.get(exec.toolCallId);
1684
+ if (!result) continue;
1685
+ const mcpResult = create(McpResultSchema, {
1686
+ result: {
1687
+ case: "success",
1688
+ value: create(McpSuccessSchema, {
1689
+ content: buildMcpSuccessContent(result),
1690
+ isError: result.isError === true,
1691
+ }),
1692
+ },
1693
+ });
1694
+
1695
+ const execClientMessage = create(ExecClientMessageSchema, {
1696
+ id: exec.execMsgId,
1697
+ execId: exec.execId,
1698
+ message: { case: "mcpResult" as any, value: mcpResult as any },
1699
+ });
1700
+ const clientMessage = create(AgentClientMessageSchema, {
1701
+ message: { case: "execClientMessage", value: execClientMessage },
1702
+ });
1703
+ bridge.write(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
1704
+ debugLog("native.tool_resume.sent_result", {
1705
+ requestId,
1706
+ exec,
1707
+ contentBytes: Buffer.byteLength(result.content, "utf8"),
1708
+ imageCount: result.images?.length ?? 0,
1709
+ });
1710
+ }
1711
+
1712
+ const idleRetry: StreamIdleRetryController = {
1713
+ currentAttempt: 1,
1714
+ maxRetries: resolveStreamIdleMaxRetries(process.env.PI_CURSOR_STREAM_IDLE_MAX_RETRIES),
1715
+ // Phase 0 found mcpArgs-before-checkpoint across composer/gemini/gpt-5.4, so this stays model-agnostic.
1716
+ recoverBeforeRetry: true,
1717
+ restart(nextAttempt: number, _context: IdleRestartContext) {
1718
+ idleRetry.currentAttempt = nextAttempt;
1719
+ const stored = conversationStates.get(convKey);
1720
+ const decision = planRecovery({
1721
+ stored,
1722
+ toolResults,
1723
+ completedTurns,
1724
+ inFlightTurn: stripInFlightResults(ctx.inFlightTurn ?? currentTurn),
1725
+ rebuildReason: "synthesized_after_idle",
1726
+ sessionId,
1727
+ requestId: requestId ?? "native-tool-idle-retry",
1728
+ convKey,
1729
+ });
1730
+ if (decision.kind === "rebuild_full_history") {
1731
+ setLastStreamEvent("rebuild_full_history");
1732
+ logFullHistoryRebuild("native.rebuild_full_history", {
1733
+ requestId,
1734
+ bridgeKey,
1735
+ convKey,
1736
+ modelId,
1737
+ decision,
1738
+ });
1739
+ const rebuiltCompletedTurns = [...decision.completedTurns, decision.inFlightTurn];
1740
+ const recoveredUserImages = collectToolResultImages(decision.toolResults);
1741
+ const recoveredCurrentTurn: ParsedTurn = {
1742
+ userText: decision.wrappedText,
1743
+ steps: [],
1744
+ ...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
1745
+ };
1746
+ const payload = buildCursorRequest({
1747
+ modelId,
1748
+ systemPrompt,
1749
+ userText: decision.wrappedText,
1750
+ userImages: recoveredUserImages,
1751
+ turns: rebuiltCompletedTurns,
1752
+ conversationId: decision.conversationId,
1753
+ checkpoint: null,
1754
+ existingBlobStore: decision.blobStore,
1755
+ maxMode,
1756
+ cursorModelParameters,
1757
+ mcpTools,
1758
+ });
1759
+ payload.mcpTools = mcpTools;
1760
+ if (stored) stored.lastAccessMs = Date.now();
1761
+ startNativeStreamWithIdleRetries({
1762
+ accessToken,
1763
+ requestBytes: payload.requestBytes,
1764
+ blobStore: payload.blobStore,
1765
+ mcpTools: payload.mcpTools,
1766
+ model,
1767
+ modelId,
1768
+ bridgeKey,
1769
+ convKey,
1770
+ completedTurns: rebuiltCompletedTurns,
1771
+ contextCheckpoint: payload.contextCheckpoint,
1772
+ currentTurn: recoveredCurrentTurn,
1773
+ clientTranscript: recoveredClientTranscript,
1774
+ writer,
1775
+ options,
1776
+ requestId,
1777
+ maxIdleRetries: idleRetry.maxRetries,
1778
+ streamIdleTimeoutMs: resumeIdleTimeoutMs,
1779
+ getAccessToken,
1780
+ systemPrompt,
1781
+ conversationId: decision.conversationId,
1782
+ maxMode,
1783
+ cursorModelParameters,
1784
+ });
1785
+ return true;
1786
+ }
1787
+ if (decision.kind !== "recover") {
1788
+ debugLog("native.tool_resume.idle_retry_recovery_skipped", {
1789
+ requestId,
1790
+ bridgeKey,
1791
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
1792
+ convKey,
1793
+ skipReason: decision.reason,
1794
+ hadStoredCheckpoint: decision.hadStoredCheckpoint,
1795
+ ...(decision.expected !== undefined ? { expected: decision.expected } : {}),
1796
+ ...(decision.received !== undefined ? { received: decision.received } : {}),
1797
+ });
1798
+ writer.error(
1799
+ `${lostToolContinuationMessage()} ${formatLostToolContinuationDiagnostic({
1800
+ bridgeKey,
1801
+ hadStoredCheckpoint: decision.hadStoredCheckpoint,
1802
+ skipReason: decision.reason,
1803
+ })}`,
1804
+ "error",
1805
+ );
1806
+ return true;
1807
+ }
1808
+
1809
+ debugLog("native.tool_resume.idle_retry_recover", {
1810
+ requestId,
1811
+ bridgeKey,
1812
+ bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
1813
+ convKey,
1814
+ recoveryPath: "stored_checkpoint",
1815
+ attempt: nextAttempt,
1816
+ pendingToolCallIds: toolResults.map((r) => r.toolCallId),
1817
+ });
1818
+ const recoveredUserImages = collectToolResultImages(toolResults);
1819
+ const recoveredCurrentTurn: ParsedTurn = {
1820
+ userText: decision.wrappedText,
1821
+ steps: [],
1822
+ ...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
1823
+ };
1824
+ const payload = buildCursorRequest({
1825
+ modelId,
1826
+ systemPrompt,
1827
+ userText: decision.wrappedText,
1828
+ userImages: recoveredUserImages,
1829
+ turns: completedTurns,
1830
+ conversationId: decision.conversationId,
1831
+ checkpoint: decision.checkpoint,
1832
+ existingBlobStore: decision.blobStore,
1833
+ maxMode,
1834
+ cursorModelParameters,
1835
+ mcpTools,
1836
+ });
1837
+ payload.mcpTools = mcpTools;
1838
+ startNativeStreamWithIdleRetries({
1839
+ accessToken,
1840
+ requestBytes: payload.requestBytes,
1841
+ blobStore: payload.blobStore,
1842
+ mcpTools: payload.mcpTools,
1843
+ model,
1844
+ modelId,
1845
+ bridgeKey,
1846
+ convKey,
1847
+ completedTurns,
1848
+ contextCheckpoint: payload.contextCheckpoint,
1849
+ currentTurn: recoveredCurrentTurn,
1850
+ clientTranscript: recoveredClientTranscript,
1851
+ writer,
1852
+ options,
1853
+ requestId,
1854
+ maxIdleRetries: idleRetry.maxRetries,
1855
+ streamIdleTimeoutMs: resumeIdleTimeoutMs,
1856
+ getAccessToken,
1857
+ systemPrompt,
1858
+ conversationId: decision.conversationId,
1859
+ maxMode,
1860
+ cursorModelParameters,
1861
+ });
1862
+ return true;
1863
+ },
1864
+ };
1865
+
1866
+ writeNativeStream(
1867
+ bridge,
1868
+ heartbeatTimer,
1869
+ blobStore,
1870
+ mcpTools,
1871
+ model,
1872
+ modelId,
1873
+ bridgeKey,
1874
+ convKey,
1875
+ completedTurns,
1876
+ currentTurn,
1877
+ writer,
1878
+ options,
1879
+ requestId,
1880
+ idleRetry,
1881
+ resumeIdleTimeoutMs,
1882
+ // Same bridge, so the same checkpoint cell: frames that landed during the pause stay visible.
1883
+ checkpointRef,
1884
+ // A timeout after Cursor receives the tool result still needs the original pause
1885
+ // snapshot so recovery can safely recreate that continuation.
1886
+ pendingExecs,
1887
+ resumeTranscript,
1888
+ );
1889
+ }
1890
+
1891
+ // ── Request handling ──
1892
+
1893
+ export type ResolvedCursorModelRouting = ExtractedResolvedCursorModelRouting;
1894
+ export type CursorResolvableModel = ExtractedCursorResolvableModel;
1895
+
1896
+ export function resolveModelId(model: string, reasoningEffort?: string): string {
1897
+ return resolveModelIdImpl(model, reasoningEffort);
1898
+ }
1899
+
1900
+ export function resolveRequestedModelId(
1901
+ model: string,
1902
+ reasoningEffort?: string,
1903
+ cursorModelId?: string,
1904
+ ): string;
1905
+ export function resolveRequestedModelId(
1906
+ model: CursorResolvableModel,
1907
+ reasoningEffort?: string,
1908
+ routingByModelId?: Map<
1909
+ string,
1910
+ Record<string, CursorNativeModelRouting> | CursorNativeModelRouting
1911
+ >,
1912
+ ): ResolvedCursorModelRouting;
1913
+ export function resolveRequestedModelId(
1914
+ model: string | CursorResolvableModel,
1915
+ reasoningEffort?: string,
1916
+ cursorModelIdOrRoutingByModelId?:
1917
+ string | Map<string, Record<string, CursorNativeModelRouting> | CursorNativeModelRouting>,
1918
+ ): string | ResolvedCursorModelRouting {
1919
+ return resolveRequestedModelIdImpl(
1920
+ model as any,
1921
+ reasoningEffort,
1922
+ cursorModelIdOrRoutingByModelId as any,
1923
+ );
1924
+ }
1925
+
1926
+ // ── Streaming response ──
1927
+
1928
+ function formatStreamIdleTimeoutMessage(
1929
+ timeoutMs: number,
1930
+ attempt: number,
1931
+ maxRetries: number,
1932
+ emittedUserVisibleContent = false,
1933
+ ): string {
1934
+ const base = `Cursor stream idle timeout after ${timeoutMs}ms without upstream progress`;
1935
+ const attemptLabel = attempt === 1 ? "attempt" : "attempts";
1936
+ const retryLabel = maxRetries === 1 ? "retry" : "retries";
1937
+ const retryPart =
1938
+ maxRetries > 0 ? ` over ${attempt} ${attemptLabel} (${maxRetries} ${retryLabel})` : "";
1939
+ const partialPart = emittedUserVisibleContent
1940
+ ? " Partial assistant output was already streamed; automatic retry requires a checkpoint and was unavailable or exhausted."
1941
+ : "";
1942
+ const tunePart =
1943
+ " Tune PI_CURSOR_STREAM_IDLE_TIMEOUT_MS / PI_CURSOR_RESUME_IDLE_TIMEOUT_MS if long reasoning turns are expected.";
1944
+ return `${base}${retryPart}.${partialPart}${tunePart}`;
1945
+ }
1946
+
1947
+ function formatStreamParkMessage(execCase: string, timeoutMs: number): string {
1948
+ return (
1949
+ `Cursor parked the turn on exec case "${execCase}", which this build cannot answer ` +
1950
+ `(agent.proto is behind Cursor's wire protocol). The exec was failed with a throw and the ` +
1951
+ `run still did no work for ${timeoutMs}ms. Run /cursor.doctor for the recorded drift signal.`
1952
+ );
1953
+ }
1954
+
1955
+ function checkpointContextTokens(checkpoint?: Uint8Array | null): number | undefined {
1956
+ if (!checkpoint) return undefined;
1957
+ try {
1958
+ return positiveContextTokens(
1959
+ fromBinary(ConversationStateStructureSchema, checkpoint).tokenDetails?.usedTokens,
1960
+ );
1961
+ } catch {
1962
+ return undefined;
1963
+ }
1964
+ }
1965
+
1966
+ function startNativeStreamWithIdleRetries(input: NativeStreamAttemptInput): void {
1967
+ // Recovered/rebuilt streams enter this helper with ordinary retry semantics to avoid recursive recovery loops.
1968
+ let latestAccessToken = input.accessToken;
1969
+ // Mutable across generations so checkpoint continuation can replace the original request body.
1970
+ let requestBytes = input.requestBytes;
1971
+ let contextCheckpoint = input.contextCheckpoint;
1972
+ let blobStore = input.blobStore;
1973
+ let completedTurns = input.completedTurns;
1974
+ let currentTurn = input.currentTurn;
1975
+
1976
+ const controller: StreamIdleRetryController = {
1977
+ currentAttempt: 1,
1978
+ maxRetries:
1979
+ input.maxIdleRetries ??
1980
+ resolveStreamIdleMaxRetries(process.env.PI_CURSOR_STREAM_IDLE_MAX_RETRIES),
1981
+ // Default on: transport loss after partial output can still continue via checkpoint.
1982
+ recoverBeforeRetry: input.recoverBeforeRetry ?? true,
1983
+ restart(nextAttempt: number, context: IdleRestartContext) {
1984
+ controller.currentAttempt = nextAttempt;
1985
+ debugLog(
1986
+ nextAttempt === 1 ? "native.stream.attempt_start" : "native.stream.idle_retry_start",
1987
+ {
1988
+ requestId: input.requestId,
1989
+ bridgeKey: input.bridgeKey,
1990
+ convKey: input.convKey,
1991
+ modelId: input.modelId,
1992
+ attempt: nextAttempt,
1993
+ maxRetries: controller.maxRetries,
1994
+ hasCheckpoint: !!context.latestCheckpoint,
1995
+ emittedUserVisibleContent: context.emittedUserVisibleContent,
1996
+ },
1997
+ );
1998
+
1999
+ // A checkpoint from the interrupted stream determines continuation, not the retry-budget
2000
+ // counter: idle recovery can reuse attempt 1. Initial launch passes no latestCheckpoint.
2001
+ if (
2002
+ context.latestCheckpoint &&
2003
+ typeof input.systemPrompt === "string" &&
2004
+ input.conversationId
2005
+ ) {
2006
+ try {
2007
+ const continueText = CHECKPOINT_CONTINUATION_PROMPT;
2008
+ const payload = buildCursorRequest({
2009
+ modelId: input.modelId,
2010
+ systemPrompt: input.systemPrompt,
2011
+ userText: continueText,
2012
+ turns: context.completedTurns,
2013
+ conversationId: input.conversationId,
2014
+ checkpoint: context.latestCheckpoint,
2015
+ existingBlobStore: context.blobStore,
2016
+ maxMode: input.maxMode ?? false,
2017
+ cursorModelParameters: input.cursorModelParameters ?? [],
2018
+ mcpTools: input.mcpTools,
2019
+ });
2020
+ requestBytes = payload.requestBytes;
2021
+ contextCheckpoint = payload.contextCheckpoint;
2022
+ blobStore = payload.blobStore;
2023
+ completedTurns = context.completedTurns;
2024
+ currentTurn = { userText: continueText, steps: [] };
2025
+ const stored = conversationStates.get(input.convKey);
2026
+ if (stored) {
2027
+ commitStoredCheckpoint(
2028
+ stored,
2029
+ context.latestCheckpoint,
2030
+ context.blobStore,
2031
+ context.completedTurns,
2032
+ context.currentTurn,
2033
+ input.convKey,
2034
+ );
2035
+ }
2036
+ setLastStreamEvent("checkpoint_continuation");
2037
+ debugLog("native.stream.checkpoint_continuation", {
2038
+ requestId: input.requestId,
2039
+ bridgeKey: input.bridgeKey,
2040
+ convKey: input.convKey,
2041
+ attempt: nextAttempt,
2042
+ });
2043
+ } catch (error) {
2044
+ debugLog("native.stream.checkpoint_continuation_failed", {
2045
+ requestId: input.requestId,
2046
+ message: error instanceof Error ? error.message : String(error),
2047
+ });
2048
+ // Fall back to the previous request bytes only when no user-visible content was emitted.
2049
+ if (context.emittedUserVisibleContent) return false;
2050
+ }
2051
+ } else if (context.emittedUserVisibleContent) {
2052
+ // No checkpoint and partial output: cannot safely restart.
2053
+ return false;
2054
+ }
2055
+
2056
+ const launch = (accessToken: string) => {
2057
+ latestAccessToken = accessToken;
2058
+ input.writer.contextMode?.(
2059
+ contextCheckpoint ? "checkpoint" : "history",
2060
+ checkpointContextTokens(contextCheckpoint),
2061
+ contextCheckpoint ?? undefined,
2062
+ );
2063
+ const { bridge, heartbeatTimer } = startBridge(accessToken, requestBytes, {
2064
+ bridgeKey: input.bridgeKey,
2065
+ hasMcpTools: input.mcpTools.length > 0,
2066
+ });
2067
+ writeNativeStream(
2068
+ bridge,
2069
+ heartbeatTimer,
2070
+ blobStore,
2071
+ input.mcpTools,
2072
+ input.model,
2073
+ input.modelId,
2074
+ input.bridgeKey,
2075
+ input.convKey,
2076
+ completedTurns,
2077
+ currentTurn,
2078
+ input.writer,
2079
+ input.options,
2080
+ input.requestId,
2081
+ controller,
2082
+ input.streamIdleTimeoutMs,
2083
+ { current: null },
2084
+ [],
2085
+ input.clientTranscript ?? liveTranscript(completedTurns),
2086
+ );
2087
+ };
2088
+
2089
+ // First attempt is synchronous. Later attempts force-refresh credentials when possible.
2090
+ if (nextAttempt === 1 || !input.getAccessToken) {
2091
+ launch(latestAccessToken);
2092
+ return true;
2093
+ }
2094
+
2095
+ void input
2096
+ .getAccessToken({ forceRefresh: true })
2097
+ .then((token) => {
2098
+ if (input.writer.closed) return;
2099
+ setLastStreamEvent("idle_retry_token_refreshed");
2100
+ launch(token);
2101
+ })
2102
+ .catch((error) => {
2103
+ debugLog("native.stream.idle_retry_token_refresh_failed", {
2104
+ requestId: input.requestId,
2105
+ message: error instanceof Error ? error.message : String(error),
2106
+ });
2107
+ if (input.writer.closed) return;
2108
+ // Fall back to the previous token rather than hard-failing immediately.
2109
+ launch(latestAccessToken);
2110
+ });
2111
+ return true;
2112
+ },
2113
+ };
2114
+ controller.restart(1, {
2115
+ emittedUserVisibleContent: false,
2116
+ latestCheckpoint: null,
2117
+ blobStore: input.blobStore,
2118
+ completedTurns: input.completedTurns,
2119
+ currentTurn: input.currentTurn,
2120
+ });
2121
+ }