pi-cursor-sdk 0.1.18 → 0.1.19

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 (46) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +37 -0
  3. package/docs/cursor-live-smoke-checklist.md +3 -0
  4. package/docs/cursor-model-ux-spec.md +4 -3
  5. package/docs/cursor-native-tool-replay.md +96 -2
  6. package/docs/cursor-testing-lessons.md +234 -5
  7. package/package.json +8 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  11. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  12. package/scripts/validate-smoke-jsonl.mjs +27 -3
  13. package/src/context.ts +45 -32
  14. package/src/cursor-agent-message-web-tools.ts +172 -0
  15. package/src/cursor-agents-context.ts +176 -0
  16. package/src/cursor-incomplete-tool-visibility.ts +118 -0
  17. package/src/cursor-live-run-coordinator.ts +18 -7
  18. package/src/cursor-model.ts +12 -0
  19. package/src/cursor-native-tool-display-registration.ts +1 -4
  20. package/src/cursor-native-tool-display-replay.ts +63 -5
  21. package/src/cursor-native-tool-display-tools.ts +20 -0
  22. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  23. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  24. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  25. package/src/cursor-provider-errors.ts +96 -0
  26. package/src/cursor-provider-live-run-drain.ts +181 -62
  27. package/src/cursor-provider-turn-coordinator.ts +198 -32
  28. package/src/cursor-provider.ts +270 -83
  29. package/src/cursor-question-tool.ts +1 -4
  30. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  31. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  32. package/src/cursor-sdk-event-debug-session.ts +163 -0
  33. package/src/cursor-sdk-event-debug.ts +597 -0
  34. package/src/cursor-sensitive-text.ts +27 -7
  35. package/src/cursor-session-agent.ts +25 -3
  36. package/src/cursor-session-send-policy.ts +43 -0
  37. package/src/cursor-setting-sources.ts +29 -0
  38. package/src/cursor-state.ts +1 -5
  39. package/src/cursor-tool-lifecycle.ts +111 -0
  40. package/src/cursor-tool-names.ts +12 -0
  41. package/src/cursor-tool-transcript.ts +4 -2
  42. package/src/cursor-transcript-tool-formatters.ts +228 -5
  43. package/src/cursor-transcript-tool-specs.ts +113 -14
  44. package/src/cursor-transcript-utils.ts +12 -0
  45. package/src/cursor-web-tool-activity.ts +84 -0
  46. package/src/index.ts +4 -1
@@ -8,14 +8,15 @@ import {
8
8
  type AssistantMessage,
9
9
  } from "@earendil-works/pi-ai";
10
10
  import { Agent, createAgentPlatform } from "@cursor/sdk";
11
- import type { SDKAgent, SettingSource } from "@cursor/sdk";
11
+ import type { SDKAgent } from "@cursor/sdk";
12
12
  import { installCursorMcpToolTimeoutOverride } from "./cursor-mcp-timeout-override.js";
13
13
  import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./cursor-sdk-output-filter.js";
14
- import { buildCursorSendPrompt } from "./context.js";
15
14
  import {
16
15
  acquireSessionCursorAgent,
16
+ buildCursorSessionSendPrompt,
17
17
  commitSessionAgentSend,
18
18
  disposeAllSessionCursorAgents,
19
+ planCursorSessionSend,
19
20
  resetSessionCursorAgent,
20
21
  } from "./cursor-session-agent.js";
21
22
  import {
@@ -36,6 +37,7 @@ import {
36
37
  cursorLiveRuns,
37
38
  drainCursorLiveRunTurn,
38
39
  drainExistingCursorLiveRunBeforeSend,
40
+ flushPendingCursorLiveRunTraceEventsToStream,
39
41
  DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS,
40
42
  getPendingCursorLiveRun,
41
43
  hasTrailingUserMessagesAfterToolResults,
@@ -48,8 +50,27 @@ import {
48
50
  import { getEffectiveFastForModelId } from "./cursor-state.js";
49
51
  import { buildCursorModelSelection } from "./model-discovery.js";
50
52
  import { getCheckpointContextWindow, saveCachedContextWindow } from "./context-window-cache.js";
53
+ import {
54
+ attachCursorSdkEventDebugPiStreamTap,
55
+ CursorSdkEventDebugSink,
56
+ DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
57
+ } from "./cursor-sdk-event-debug.js";
51
58
  import { CursorSdkTurnCoordinator } from "./cursor-provider-turn-coordinator.js";
52
59
  import { isCursorNativeToolDisplayRuntimeEnabled } from "./cursor-native-tool-display.js";
60
+ import {
61
+ formatCursorSdkAbortMessage,
62
+ formatCursorSdkRunFailureDetail,
63
+ MISSING_CURSOR_API_KEY_MESSAGE,
64
+ resolveCursorSdkAbortCause,
65
+ sanitizeCursorProviderError,
66
+ } from "./cursor-provider-errors.js";
67
+ import { getEffectiveCursorSettingSources } from "./cursor-setting-sources.js";
68
+ import { hasUsableText } from "./cursor-record-utils.js";
69
+ import {
70
+ countCursorAgentMessages,
71
+ loadCursorTranscriptWebToolCallsAfterOffset,
72
+ } from "./cursor-agent-message-web-tools.js";
73
+ import { installCursorSdkAbortErrorSuppression } from "./cursor-sdk-abort-error-guard.js";
53
74
 
54
75
  function makeInitialMessage(model: Model<Api>): AssistantMessage {
55
76
  return {
@@ -72,25 +93,6 @@ function makeInitialMessage(model: Model<Api>): AssistantMessage {
72
93
  }
73
94
 
74
95
  const CURSOR_API_KEY_ENV_VAR = "CURSOR_API_KEY";
75
- const MISSING_API_KEY_MESSAGE =
76
- "Cursor SDK runs require a Cursor API key. Run /login -> Use an API key -> Cursor, set CURSOR_API_KEY before starting pi, or restart pi with --api-key.";
77
- const GENERIC_CURSOR_SDK_ERROR_MESSAGE =
78
- "Cursor SDK request failed. The API key may be missing, invalid, or unauthorized. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
79
- const AUTH_CURSOR_SDK_ERROR_MESSAGE =
80
- "Cursor SDK request failed because the API key may be invalid or unauthorized. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
81
- const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
82
-
83
- import { scrubSensitiveText } from "./cursor-sensitive-text.js";
84
- import { hasUsableText } from "./cursor-record-utils.js";
85
-
86
- function isGenericErrorMessage(message: string): boolean {
87
- const normalized = message.trim().toLowerCase();
88
- return normalized === "" || normalized === "error" || normalized === "unknown error";
89
- }
90
-
91
- function isLikelyAuthError(message: string): boolean {
92
- return /\b(unauthorized|unauthorised|forbidden|invalid api key|invalid key|authentication|auth|401|403)\b/i.test(message);
93
- }
94
96
 
95
97
  function resolveCursorApiKey(apiKey?: string): string | undefined {
96
98
  const trimmed = apiKey?.trim();
@@ -99,27 +101,6 @@ function resolveCursorApiKey(apiKey?: string): string | undefined {
99
101
  return trimmed;
100
102
  }
101
103
 
102
- function resolveCursorSettingSources(): SettingSource[] | undefined {
103
- const raw = process.env[CURSOR_SETTING_SOURCES_ENV]?.trim();
104
- if (!raw) return ["all"];
105
- const normalized = raw.toLowerCase();
106
- if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
107
- if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
108
- return raw
109
- .split(",")
110
- .map((entry) => entry.trim())
111
- .filter((entry): entry is SettingSource => Boolean(entry));
112
- }
113
-
114
- function sanitizeError(error: unknown, apiKey?: string): string {
115
- const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
116
- if (message === MISSING_API_KEY_MESSAGE) return MISSING_API_KEY_MESSAGE;
117
- const scrubbed = scrubSensitiveText(message, apiKey).trim();
118
- if (isGenericErrorMessage(scrubbed)) return GENERIC_CURSOR_SDK_ERROR_MESSAGE;
119
- if (isLikelyAuthError(scrubbed)) return AUTH_CURSOR_SDK_ERROR_MESSAGE;
120
- return scrubbed || GENERIC_CURSOR_SDK_ERROR_MESSAGE;
121
- }
122
-
123
104
  async function cacheSdkContextWindow(agentId: string, modelId: string): Promise<void> {
124
105
  try {
125
106
  const platform = await createAgentPlatform();
@@ -137,6 +118,8 @@ export function streamCursor(
137
118
  options?: SimpleStreamOptions,
138
119
  ): AssistantMessageEventStream {
139
120
  const stream = createAssistantMessageEventStream();
121
+ const sdkEventDebugRef: { current?: CursorSdkEventDebugSink } = {};
122
+ attachCursorSdkEventDebugPiStreamTap(stream, sdkEventDebugRef);
140
123
 
141
124
  (async () => {
142
125
  const partial = makeInitialMessage(model);
@@ -150,8 +133,51 @@ export function streamCursor(
150
133
  let abortSignal: AbortSignal | undefined;
151
134
  let abortListener: (() => void) | undefined;
152
135
  let restoreCursorSdkOutputFilter: (() => void) | undefined;
136
+ let sdkEventDebug: CursorSdkEventDebugSink | undefined;
137
+ let deferSdkEventDebugFinalize = false;
138
+ const sdkAbortErrorSuppression = installCursorSdkAbortErrorSuppression();
139
+
140
+ const pushSanitizedStreamError = (error: unknown, reason: "error" | "aborted" = "error"): void => {
141
+ partial.stopReason = reason;
142
+ partial.errorMessage =
143
+ reason === "aborted"
144
+ ? formatCursorSdkAbortMessage(resolveCursorSdkAbortCause({ signalAborted: options?.signal?.aborted }))
145
+ : sanitizeCursorProviderError(error, resolvedApiKey ?? options?.apiKey);
146
+ stream.push({ type: "error", reason, error: partial });
147
+ };
148
+
149
+ const getCursorAgentMessageOffset = async (agentId: string, cwd: string): Promise<number | undefined> => {
150
+ try {
151
+ return await countCursorAgentMessages(agentId, cwd);
152
+ } catch (error) {
153
+ sdkEventDebug?.recordError("cursor_agent_message_count", error);
154
+ return undefined;
155
+ }
156
+ };
157
+
158
+ const replayCursorTranscriptWebToolCalls = async (
159
+ agentId: string,
160
+ cwd: string,
161
+ messageOffset: number | undefined,
162
+ turnCoordinator: CursorSdkTurnCoordinator,
163
+ ): Promise<void> => {
164
+ try {
165
+ const transcriptToolCalls = await loadCursorTranscriptWebToolCallsAfterOffset({ agentId, cwd, offset: messageOffset });
166
+ if (transcriptToolCalls.length === 0) return;
167
+ sdkEventDebug?.recordCoordinatorEvent("cursor-transcript-web-tools", {
168
+ agentId,
169
+ messageOffset,
170
+ count: transcriptToolCalls.length,
171
+ });
172
+ turnCoordinator.handleTranscriptCompletedToolCalls(transcriptToolCalls);
173
+ } catch (error) {
174
+ sdkEventDebug?.recordError("cursor_transcript_web_tools", error);
175
+ }
176
+ };
153
177
 
154
178
  try {
179
+ let turnCoordinatorForCleanup: CursorSdkTurnCoordinator | undefined;
180
+ try {
155
181
  const throwIfAborted = (): void => {
156
182
  if (options?.signal?.aborted) throw new CursorLiveRunAbortError();
157
183
  };
@@ -159,21 +185,29 @@ export function streamCursor(
159
185
  stream.push({ type: "start", partial });
160
186
  throwIfAborted();
161
187
 
162
- if ((await drainExistingCursorLiveRunBeforeSend(stream, partial, model, context, options?.signal)) === "stream_ended") {
163
- stream.end();
188
+ const cwd = getCursorSessionCwd();
189
+ sdkEventDebug = CursorSdkEventDebugSink.maybeCreate({
190
+ cwd,
191
+ modelId: model.id,
192
+ provider: model.provider,
193
+ });
194
+ sdkEventDebugRef.current = sdkEventDebug;
195
+ sdkEventDebug?.recordContextSnapshot(context);
196
+
197
+ if ((await drainExistingCursorLiveRunBeforeSend(stream, partial, model, context, options?.signal, sdkEventDebug)) === "stream_ended") {
198
+ sdkEventDebug?.recordFinalPartial(partial);
199
+ await sdkEventDebug?.finalize();
200
+ sdkEventDebugRef.current = undefined;
164
201
  return;
165
202
  }
166
203
 
167
204
  const apiKey = resolveCursorApiKey(options?.apiKey);
168
- if (!apiKey) throw new Error(MISSING_API_KEY_MESSAGE);
205
+ if (!apiKey) throw new Error(MISSING_CURSOR_API_KEY_MESSAGE);
169
206
  resolvedApiKey = apiKey;
170
207
 
171
- // pi-ai Context/SimpleStreamOptions do not expose ExtensionContext.cwd; bridge via session_start
172
- // until pi threads session cwd into streamSimple (cwd can change without a new session event).
173
- const cwd = getCursorSessionCwd();
174
208
  const fastEnabled = getEffectiveFastForModelId(model.id);
175
209
  const selection = buildCursorModelSelection(model.id, options?.reasoning ?? "off", fastEnabled);
176
- const settingSources = resolveCursorSettingSources();
210
+ const settingSources = getEffectiveCursorSettingSources();
177
211
 
178
212
  installCursorMcpToolTimeoutOverride();
179
213
  restoreCursorSdkOutputFilter = installCursorSdkOutputFilter();
@@ -182,6 +216,7 @@ export function streamCursor(
182
216
  cwd,
183
217
  modelSelection: selection,
184
218
  settingSources,
219
+ debugRecorder: sdkEventDebug,
185
220
  onBridgeToolRequest: (request: CursorPiBridgeToolRequest) => {
186
221
  if (liveRunForBridgeQueue && !liveRunForBridgeQueue.disposed) {
187
222
  cursorLiveRuns.queueEvent(liveRunForBridgeQueue, { type: "bridge-tool", request });
@@ -199,19 +234,39 @@ export function streamCursor(
199
234
  throwIfAborted();
200
235
 
201
236
  const promptOptions = getCursorPromptOptions(model);
202
- let { prompt, bootstrap } = buildCursorSendPrompt(context, promptOptions, sessionAgentLease.sendState);
203
- if (sessionAgentLease.sendState.bootstrapped && bootstrap) {
237
+ let sendPlan = planCursorSessionSend(sessionAgentLease.sendState, context);
238
+ let prompt = buildCursorSessionSendPrompt(context, promptOptions, sendPlan);
239
+ if (sendPlan.resetAgent) {
204
240
  await resetSessionCursorAgent(sessionAgentLease.scopeKey);
205
241
  sessionAgentLease = await acquireSessionCursorAgent(sessionAgentAcquireParams);
206
242
  sessionAgentScopeKey = sessionAgentLease.scopeKey;
207
243
  agent = sessionAgentLease.agent;
208
244
  bridgeRun = sessionAgentLease.bridgeRun;
209
- ({ prompt, bootstrap } = buildCursorSendPrompt(context, promptOptions, sessionAgentLease.sendState));
245
+ sendPlan = planCursorSessionSend(sessionAgentLease.sendState, context);
246
+ prompt = buildCursorSessionSendPrompt(context, promptOptions, sendPlan);
210
247
  }
248
+ const bootstrap = sendPlan.mode === "bootstrap";
211
249
  const sessionBridgeRun = sessionAgentLease.bridgeRun;
212
250
  const promptInputTokens = estimateCursorPromptInputTokens(prompt, promptOptions);
213
251
  const useNativeToolReplay = isCursorNativeToolDisplayRuntimeEnabled();
214
252
  const activeToolNames = getActiveContextToolNames(context);
253
+ sdkEventDebug?.recordProviderMeta({
254
+ model: {
255
+ id: model.id,
256
+ provider: model.provider,
257
+ api: model.api,
258
+ reasoning: options?.reasoning ?? "off",
259
+ fastEnabled,
260
+ selection,
261
+ },
262
+ settingSources: settingSources ?? null,
263
+ sendState: sessionAgentLease.sendState,
264
+ sendPlan,
265
+ promptOptions,
266
+ activeToolNames: activeToolNames ? [...activeToolNames] : [],
267
+ sessionAgentScopeKey,
268
+ bridgeRunId: bridgeRun?.id,
269
+ });
215
270
  const nativeReplayId = createCursorNativeReplayId();
216
271
  const textDeltas: string[] = [];
217
272
  const useLiveRun = useNativeToolReplay || bridgeRun !== undefined;
@@ -224,6 +279,7 @@ export function streamCursor(
224
279
  sessionAgentScopeKey,
225
280
  promptInputTokens,
226
281
  textDeltas,
282
+ debugRecorder: sdkEventDebug,
227
283
  })
228
284
  : undefined;
229
285
  if (liveRun) {
@@ -243,11 +299,14 @@ export function streamCursor(
243
299
  activeToolNames,
244
300
  nativeReplayId,
245
301
  textDeltas,
302
+ debugRecorder: sdkEventDebug,
246
303
  });
304
+ turnCoordinatorForCleanup = turnCoordinator;
247
305
 
248
306
  // Handle abort signal
249
307
  let run: Awaited<ReturnType<SDKAgent["send"]>> | null = null;
250
308
  abortListener = () => {
309
+ sdkAbortErrorSuppression.suppressAbortErrors();
251
310
  activeLiveRun?.bridgeRun?.cancel("Cursor SDK run aborted");
252
311
  if (run) {
253
312
  run.cancel().catch(() => {});
@@ -257,25 +316,73 @@ export function streamCursor(
257
316
  abortSignal?.addEventListener("abort", abortListener, { once: true });
258
317
 
259
318
  throwIfAborted();
260
- run = await agent.send(
261
- { text: prompt.text, images: prompt.images.length > 0 ? prompt.images : undefined },
262
- {
263
- onDelta: (args) => turnCoordinator.handleDelta(args.update),
264
- onStep: (args) => turnCoordinator.handleStep(args.step),
319
+ sdkEventDebug?.recordSendMeta({
320
+ mode: sendPlan.mode,
321
+ reason: sendPlan.reason,
322
+ resetAgent: sendPlan.resetAgent,
323
+ bootstrap,
324
+ promptText: prompt.text,
325
+ imageCount: prompt.images.length,
326
+ useNativeToolReplay,
327
+ bridgeEnabled: bridgeRun !== undefined,
328
+ nativeReplayId,
329
+ promptInputTokens,
330
+ });
331
+ const sendPayload = {
332
+ text: prompt.text,
333
+ images: prompt.images.length > 0 ? prompt.images : undefined,
334
+ };
335
+ sdkEventDebug?.recordSendPayload(sendPayload);
336
+ const cursorAgentMessageOffset = await getCursorAgentMessageOffset(agent.agentId, cwd);
337
+ sdkEventDebug?.recordProviderEvent("agent_send_start", sendPayload);
338
+ run = await agent.send(sendPayload, {
339
+ onDelta: (args) => {
340
+ sdkEventDebug?.recordOnDelta(args.update);
341
+ turnCoordinator.handleDelta(args.update);
265
342
  },
266
- );
343
+ onStep: (args) => {
344
+ sdkEventDebug?.recordOnStep(args.step);
345
+ turnCoordinator.handleStep(args.step);
346
+ },
347
+ });
348
+ sdkEventDebug?.recordRunMeta({
349
+ runId: run.id,
350
+ agentId: run.agentId,
351
+ status: run.status,
352
+ });
353
+ sdkEventDebug?.attachRunStream(run);
354
+ sdkEventDebug?.recordProviderEvent("agent_send_returned", {
355
+ runId: run.id,
356
+ agentId: run.agentId,
357
+ status: run.status,
358
+ });
267
359
  if (liveRun) cursorLiveRuns.attachSdkRun(liveRun, run);
268
360
  if (options?.signal?.aborted) {
361
+ sdkAbortErrorSuppression.suppressAbortErrors();
362
+ liveRun?.bridgeRun?.cancel("Cursor SDK run aborted");
269
363
  await run.cancel().catch(() => {});
270
364
  throw new CursorLiveRunAbortError();
271
365
  }
366
+ const activeRun = run;
272
367
 
273
368
  if (liveRun) {
274
- void run
369
+ deferSdkEventDebugFinalize = true;
370
+ const waitCompletion = run
275
371
  .wait()
276
372
  .then(async (result) => {
373
+ sdkEventDebug?.recordWaitResult(result);
374
+ if (result.status === "finished" && !options?.signal?.aborted) {
375
+ await replayCursorTranscriptWebToolCalls(activeRun.agentId, cwd, cursorAgentMessageOffset, turnCoordinator);
376
+ }
377
+ turnCoordinator.discardIncompleteStartedToolCalls(
378
+ result.status === "cancelled" || options?.signal?.aborted
379
+ ? "abort"
380
+ : result.status === "error"
381
+ ? "sdk-failure"
382
+ : DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
383
+ );
384
+ await sdkEventDebug?.captureRunArtifacts(run);
277
385
  if (liveRun.disposed) return;
278
- turnCoordinator.discardIncompleteStartedToolCalls();
279
386
  await cacheSdkContextWindow(liveRun.agent.agentId, model.id);
280
387
  if (liveRun.disposed) return;
281
388
  if (result.status === "finished" && !options?.signal?.aborted) {
@@ -285,14 +392,30 @@ export function streamCursor(
285
392
  selectCursorFinalText(result.result, liveRun.textDeltas, liveRun.emittedText, turnCoordinator.planTextCandidate),
286
393
  );
287
394
  } else if (result.status === "cancelled" || options?.signal?.aborted) {
288
- cursorLiveRuns.markCancelled(liveRun);
395
+ cursorLiveRuns.markCancelled(
396
+ liveRun,
397
+ formatCursorSdkAbortMessage(
398
+ resolveCursorSdkAbortCause({
399
+ signalAborted: options?.signal?.aborted,
400
+ sdkStatusCancelled: result.status === "cancelled",
401
+ }),
402
+ ),
403
+ );
289
404
  } else {
290
- cursorLiveRuns.markError(liveRun, sanitizeError(result.result ?? "Cursor SDK run failed", resolvedApiKey ?? options?.apiKey));
405
+ const failureDetail = formatCursorSdkRunFailureDetail(result, run?.result);
406
+ cursorLiveRuns.markError(
407
+ liveRun,
408
+ sanitizeCursorProviderError(failureDetail, resolvedApiKey ?? options?.apiKey),
409
+ );
291
410
  }
292
411
  })
293
412
  .catch(async (error: unknown) => {
413
+ sdkEventDebug?.recordWaitResult({ status: "error", error: String(error) });
414
+ sdkEventDebug?.recordError("run_wait", error);
415
+ turnCoordinatorForCleanup?.discardIncompleteStartedToolCalls("sdk-failure");
416
+ await sdkEventDebug?.captureRunArtifacts(run);
294
417
  if (liveRun.disposed) return;
295
- cursorLiveRuns.markError(liveRun, sanitizeError(error, resolvedApiKey ?? options?.apiKey));
418
+ cursorLiveRuns.markError(liveRun, sanitizeCursorProviderError(error, resolvedApiKey ?? options?.apiKey));
296
419
  });
297
420
 
298
421
  try {
@@ -300,18 +423,53 @@ export function streamCursor(
300
423
  await cursorLiveRuns.waitForProgress(liveRun, options?.signal);
301
424
  await settleCursorLiveToolBatch(liveRun);
302
425
  turnCoordinator.closeTraceBlock();
303
- await drainCursorLiveRunTurn(stream, partial, model, context, liveRun, 0, { mode: "emit", signal: options?.signal });
426
+ await drainCursorLiveRunTurn(stream, partial, model, context, liveRun, 0, {
427
+ mode: "emit",
428
+ signal: options?.signal,
429
+ debugRecorder: sdkEventDebug,
430
+ });
304
431
  });
305
432
  } catch (error) {
306
- if (error instanceof CursorLiveRunAbortError) await cursorLiveRuns.release(liveRun);
433
+ if (error instanceof CursorLiveRunAbortError) {
434
+ sdkAbortErrorSuppression.suppressAbortErrors();
435
+ turnCoordinator.discardIncompleteStartedToolCalls("abort");
436
+ turnCoordinator.closeTraceBlock();
437
+ flushPendingCursorLiveRunTraceEventsToStream(stream, partial, liveRun, {
438
+ includeTracesBehindQueuedTools: true,
439
+ });
440
+ await cursorLiveRuns.release(liveRun);
441
+ }
307
442
  throw error;
443
+ } finally {
444
+ sdkEventDebugRef.current = undefined;
445
+ void waitCompletion
446
+ .finally(async () => {
447
+ try {
448
+ sdkEventDebug?.recordFinalPartial(partial);
449
+ await sdkEventDebug?.finalize();
450
+ } finally {
451
+ sdkAbortErrorSuppression.dispose();
452
+ }
453
+ })
454
+ .catch(() => {});
308
455
  }
309
456
  agent = null;
310
457
  return;
311
458
  }
312
459
 
313
460
  const result = await run.wait();
314
- turnCoordinator.discardIncompleteStartedToolCalls();
461
+ sdkEventDebug?.recordWaitResult(result);
462
+ if (result.status === "finished" && !options?.signal?.aborted) {
463
+ await replayCursorTranscriptWebToolCalls(run.agentId, cwd, cursorAgentMessageOffset, turnCoordinator);
464
+ }
465
+ turnCoordinator.discardIncompleteStartedToolCalls(
466
+ result.status === "cancelled" || options?.signal?.aborted
467
+ ? "abort"
468
+ : result.status === "error"
469
+ ? "sdk-failure"
470
+ : DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
471
+ );
472
+ await sdkEventDebug?.captureRunArtifacts(run);
315
473
  await cacheSdkContextWindow(agent.agentId, model.id);
316
474
 
317
475
  // Close any open thinking/activity trace, then use the final run result only when
@@ -321,11 +479,18 @@ export function streamCursor(
321
479
  if (result.status === "cancelled") {
322
480
  await abandonSessionCursorAgent(sessionAgentScopeKey);
323
481
  partial.stopReason = "aborted";
482
+ partial.errorMessage = formatCursorSdkAbortMessage(
483
+ resolveCursorSdkAbortCause({
484
+ signalAborted: options?.signal?.aborted,
485
+ sdkStatusCancelled: true,
486
+ }),
487
+ );
324
488
  stream.push({ type: "error", reason: "aborted", error: partial });
325
489
  } else if (result.status === "error") {
326
490
  await abandonSessionCursorAgent(sessionAgentScopeKey);
327
491
  partial.stopReason = "error";
328
- partial.errorMessage = sanitizeError(result.result ?? "Cursor SDK run failed", resolvedApiKey ?? options?.apiKey);
492
+ const failureDetail = formatCursorSdkRunFailureDetail(result, run.result);
493
+ partial.errorMessage = sanitizeCursorProviderError(failureDetail, resolvedApiKey ?? options?.apiKey);
329
494
  stream.push({ type: "error", reason: "error", error: partial });
330
495
  } else {
331
496
  commitSessionAgentSend(sessionAgentScopeKey, context, bootstrap);
@@ -336,27 +501,49 @@ export function streamCursor(
336
501
  applyCursorApproximateUsage(partial, model, context, promptInputTokens);
337
502
  stream.push({ type: "done", reason: "stop", message: partial });
338
503
  }
339
- } catch (error) {
340
- if (activeLiveRun && !activeLiveRun.disposed) await cursorLiveRuns.release(activeLiveRun);
341
- else await abandonSessionCursorAgent(sessionAgentScopeKey);
342
- if (error instanceof CursorLiveRunAbortError) {
343
- partial.stopReason = "aborted";
344
- stream.push({ type: "error", reason: "aborted", error: partial });
345
- } else {
346
- partial.stopReason = "error";
347
- partial.errorMessage = sanitizeError(error, resolvedApiKey ?? options?.apiKey);
348
- stream.push({ type: "error", reason: "error", error: partial });
349
- }
350
- } finally {
351
- restoreCursorSdkOutputFilter?.();
504
+ } catch (error) {
505
+ sdkEventDebug?.recordError("provider_stream", error);
506
+ turnCoordinatorForCleanup?.discardIncompleteStartedToolCalls(
507
+ error instanceof CursorLiveRunAbortError ? "abort" : "sdk-failure",
508
+ );
509
+ if (activeLiveRun && !activeLiveRun.disposed) await cursorLiveRuns.release(activeLiveRun);
510
+ else await abandonSessionCursorAgent(sessionAgentScopeKey);
511
+ if (error instanceof CursorLiveRunAbortError) {
512
+ sdkAbortErrorSuppression.suppressAbortErrors();
513
+ pushSanitizedStreamError(error, "aborted");
514
+ } else {
515
+ pushSanitizedStreamError(error, "error");
516
+ }
517
+ } finally {
518
+ if (!deferSdkEventDebugFinalize) {
519
+ try {
520
+ sdkEventDebug?.recordFinalPartial(partial);
521
+ await sdkEventDebug?.finalize();
522
+ } finally {
523
+ sdkAbortErrorSuppression.dispose();
524
+ }
525
+ }
526
+ sdkEventDebugRef.current = undefined;
527
+ restoreCursorSdkOutputFilter?.();
352
528
 
353
- if (abortSignal && abortListener) {
354
- abortSignal.removeEventListener("abort", abortListener);
529
+ if (abortSignal && abortListener) {
530
+ abortSignal.removeEventListener("abort", abortListener);
531
+ }
355
532
  }
533
+ } catch (error) {
534
+ if (activeLiveRun && !activeLiveRun.disposed) await cursorLiveRuns.release(activeLiveRun).catch(() => {});
535
+ else await abandonSessionCursorAgent(sessionAgentScopeKey).catch(() => {});
536
+ pushSanitizedStreamError(error, error instanceof CursorLiveRunAbortError ? "aborted" : "error");
356
537
  }
357
538
 
358
539
  stream.end();
359
- })();
540
+ })().catch((error: unknown) => {
541
+ const partial = makeInitialMessage(model);
542
+ partial.stopReason = "error";
543
+ partial.errorMessage = sanitizeCursorProviderError(error, resolveCursorApiKey(options?.apiKey));
544
+ stream.push({ type: "error", reason: "error", error: partial });
545
+ stream.end();
546
+ });
360
547
 
361
548
  return stream;
362
549
  }
@@ -1,6 +1,7 @@
1
1
  import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, ExtensionHandler, SessionStartEvent, TurnStartEvent } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
+ import { isCursorModel } from "./cursor-model.js";
4
5
  import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge.js";
5
6
 
6
7
  export const CURSOR_ASK_QUESTION_TOOL_NAME = "cursor_ask_question";
@@ -83,10 +84,6 @@ const CursorAskQuestionParamsSchema = Type.Object({
83
84
  questions: Type.Optional(Type.Array(QuestionSchema, { description: "Ask multiple questions sequentially" })),
84
85
  });
85
86
 
86
- function isCursorModel(model: ExtensionContext["model"]): boolean {
87
- return model?.provider === "cursor" || model?.api === "cursor-sdk";
88
- }
89
-
90
87
  function normalizeOption(option: RawQuestionOption, index: number): CursorQuestionOption | undefined {
91
88
  if (typeof option === "string") {
92
89
  const trimmed = option.trim();
@@ -0,0 +1,109 @@
1
+ import { asRecord } from "./cursor-record-utils.js";
2
+
3
+ interface CursorSdkAbortErrorSuppressionToken {
4
+ suppress: boolean;
5
+ }
6
+
7
+ export interface CursorSdkAbortErrorSuppression {
8
+ suppressAbortErrors(): void;
9
+ dispose(): void;
10
+ }
11
+
12
+ function getString(record: Record<string, unknown> | undefined, key: string): string | undefined {
13
+ const value = record?.[key];
14
+ return typeof value === "string" ? value : undefined;
15
+ }
16
+
17
+ type GenericProcessEmit = (event: string | symbol, ...args: unknown[]) => boolean;
18
+
19
+ // The local Cursor SDK can surface abort-time ConnectRPC cancellation as a process-level
20
+ // uncaught exception/unhandled rejection even when run.cancel() is awaited/caught.
21
+ const activeSuppressions = new Set<CursorSdkAbortErrorSuppressionToken>();
22
+ let originalProcessEmit: GenericProcessEmit | undefined;
23
+ let captureCallbackInstalled = false;
24
+
25
+ export function isCursorSdkAbortConnectError(error: unknown): boolean {
26
+ const record = asRecord(error);
27
+ const name = error instanceof Error ? error.name : getString(record, "name");
28
+ const message = error instanceof Error ? error.message : getString(record, "message");
29
+ const rawMessage = getString(record, "rawMessage") ?? message;
30
+ const code = record?.code;
31
+ const cause = asRecord(record?.cause);
32
+ const causeName = getString(cause, "name");
33
+ const stack = error instanceof Error ? error.stack ?? "" : getString(record, "stack") ?? "";
34
+
35
+ return (
36
+ name === "ConnectError" &&
37
+ (code === 1 || code === "canceled") &&
38
+ Boolean(rawMessage && /(?:operation was aborted|canceled)/i.test(rawMessage)) &&
39
+ (causeName === "AbortError" || /AbortError/.test(stack)) &&
40
+ stack.includes("@cursor/sdk") &&
41
+ stack.includes("@connectrpc/connect-node")
42
+ );
43
+ }
44
+
45
+ function hasActiveSuppression(): boolean {
46
+ for (const suppression of activeSuppressions) {
47
+ if (suppression.suppress) return true;
48
+ }
49
+ return false;
50
+ }
51
+
52
+ function shouldSuppressProcessError(event: string | symbol, args: readonly unknown[]): boolean {
53
+ if (event !== "uncaughtException" && event !== "unhandledRejection") return false;
54
+ return hasActiveSuppression() && isCursorSdkAbortConnectError(args[0]);
55
+ }
56
+
57
+ function installProcessEmitPatch(): void {
58
+ if (originalProcessEmit) return;
59
+ originalProcessEmit = process.emit.bind(process) as GenericProcessEmit;
60
+ process.emit = function patchedCursorSdkAbortEmit(this: NodeJS.Process, event: string | symbol, ...args: unknown[]): boolean {
61
+ if (shouldSuppressProcessError(event, args)) return false;
62
+ return originalProcessEmit!(event, ...args);
63
+ } as typeof process.emit;
64
+ }
65
+
66
+ function installCaptureCallbackIfAvailable(): void {
67
+ if (captureCallbackInstalled || process.hasUncaughtExceptionCaptureCallback()) return;
68
+ process.setUncaughtExceptionCaptureCallback((error: Error) => {
69
+ if (shouldSuppressProcessError("uncaughtException", [error])) return;
70
+ uninstallCaptureCallbackIfIdle(true);
71
+ if (originalProcessEmit?.("uncaughtException", error)) return;
72
+ throw error;
73
+ });
74
+ captureCallbackInstalled = true;
75
+ }
76
+
77
+ function uninstallCaptureCallbackIfIdle(force = false): void {
78
+ if (!captureCallbackInstalled) return;
79
+ if (!force && activeSuppressions.size > 0) return;
80
+ process.setUncaughtExceptionCaptureCallback(null);
81
+ captureCallbackInstalled = false;
82
+ }
83
+
84
+ function uninstallProcessEmitPatchIfIdle(): void {
85
+ if (activeSuppressions.size > 0 || !originalProcessEmit) return;
86
+ uninstallCaptureCallbackIfIdle();
87
+ process.emit = originalProcessEmit as typeof process.emit;
88
+ originalProcessEmit = undefined;
89
+ }
90
+
91
+ export function installCursorSdkAbortErrorSuppression(): CursorSdkAbortErrorSuppression {
92
+ installProcessEmitPatch();
93
+ const token: CursorSdkAbortErrorSuppressionToken = { suppress: false };
94
+ activeSuppressions.add(token);
95
+ let disposed = false;
96
+ return {
97
+ suppressAbortErrors(): void {
98
+ if (disposed) return;
99
+ token.suppress = true;
100
+ installCaptureCallbackIfAvailable();
101
+ },
102
+ dispose(): void {
103
+ if (disposed) return;
104
+ disposed = true;
105
+ activeSuppressions.delete(token);
106
+ uninstallProcessEmitPatchIfIdle();
107
+ },
108
+ };
109
+ }