pi-cursor-sdk 0.1.19 → 0.1.21

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 (89) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +72 -11
  3. package/docs/cursor-dogfood-checklist.md +57 -0
  4. package/docs/cursor-live-smoke-checklist.md +116 -10
  5. package/docs/cursor-model-ux-spec.md +60 -19
  6. package/docs/cursor-native-tool-replay.md +21 -11
  7. package/docs/cursor-native-tool-visual-audit.md +104 -59
  8. package/docs/cursor-testing-lessons.md +10 -5
  9. package/docs/cursor-tool-surfaces.md +69 -0
  10. package/package.json +37 -11
  11. package/scripts/debug-provider-events.d.mts +59 -0
  12. package/scripts/debug-provider-events.mjs +70 -175
  13. package/scripts/debug-sdk-events.d.mts +90 -0
  14. package/scripts/debug-sdk-events.mjs +36 -98
  15. package/scripts/fixtures/plan-strip-shim/index.ts +12 -0
  16. package/scripts/isolated-cursor-smoke.sh +264 -102
  17. package/scripts/lib/cursor-child-process.d.mts +10 -0
  18. package/scripts/lib/cursor-child-process.mjs +50 -0
  19. package/scripts/lib/cursor-cli-args.d.mts +63 -0
  20. package/scripts/lib/cursor-cli-args.mjs +129 -0
  21. package/scripts/lib/cursor-script-fail.d.mts +1 -0
  22. package/scripts/lib/cursor-script-fail.mjs +13 -0
  23. package/scripts/lib/cursor-sdk-output-filter.d.mts +5 -0
  24. package/scripts/lib/cursor-smoke-env.d.mts +38 -0
  25. package/scripts/lib/cursor-smoke-env.mjs +81 -0
  26. package/scripts/lib/cursor-smoke-shell.sh +174 -0
  27. package/scripts/lib/cursor-visual-render.d.mts +15 -0
  28. package/scripts/lib/cursor-visual-render.mjs +131 -0
  29. package/scripts/probe-mcp-coldstart.mjs +226 -0
  30. package/scripts/refresh-cursor-model-snapshots.mjs +29 -65
  31. package/scripts/steering-rpc-smoke.mjs +170 -65
  32. package/scripts/tmux-live-smoke.sh +152 -98
  33. package/scripts/visual-tui-smoke.mjs +659 -0
  34. package/shared/cursor-sdk-event-debug-env.d.mts +12 -0
  35. package/shared/cursor-sdk-event-debug-env.mjs +13 -0
  36. package/shared/cursor-sensitive-text.d.mts +1 -0
  37. package/{scripts/lib/cursor-probe-utils.mjs → shared/cursor-sensitive-text.mjs} +1 -13
  38. package/shared/cursor-setting-sources.d.mts +5 -0
  39. package/shared/cursor-setting-sources.mjs +22 -0
  40. package/src/context.ts +21 -12
  41. package/src/cursor-bridge-contract.ts +1 -3
  42. package/src/cursor-incomplete-tool-visibility.ts +72 -49
  43. package/src/cursor-mcp-timeout-override.ts +66 -11
  44. package/src/cursor-native-tool-display-registration.ts +63 -27
  45. package/src/cursor-native-tool-display-replay.ts +246 -143
  46. package/src/cursor-native-tool-display-state.ts +2 -0
  47. package/src/cursor-native-tool-display-tools.ts +149 -41
  48. package/src/cursor-provider-live-run-drain.ts +1 -52
  49. package/src/cursor-provider-run-finalizer.ts +235 -0
  50. package/src/cursor-provider-run-outcome.ts +149 -0
  51. package/src/cursor-provider-turn-api-key.ts +8 -0
  52. package/src/cursor-provider-turn-coordinator.ts +113 -440
  53. package/src/cursor-provider-turn-display-router.ts +216 -0
  54. package/src/cursor-provider-turn-emit.ts +59 -0
  55. package/src/cursor-provider-turn-finalize.ts +119 -0
  56. package/src/cursor-provider-turn-lifecycle-emitter.ts +97 -0
  57. package/src/cursor-provider-turn-message-offset.ts +15 -0
  58. package/src/cursor-provider-turn-prepare.ts +216 -0
  59. package/src/cursor-provider-turn-runner.ts +138 -0
  60. package/src/cursor-provider-turn-sdk-normalizer.ts +88 -0
  61. package/src/cursor-provider-turn-send.ts +103 -0
  62. package/src/cursor-provider-turn-shell-output.ts +107 -0
  63. package/src/cursor-provider-turn-tool-ledger.ts +126 -0
  64. package/src/cursor-provider-turn-types.ts +87 -0
  65. package/src/cursor-provider.ts +16 -482
  66. package/src/cursor-replay-activity-builders.ts +276 -0
  67. package/src/cursor-replay-source-names.ts +33 -0
  68. package/src/cursor-replay-summary-args.ts +191 -0
  69. package/src/cursor-replay-tool-details.ts +464 -0
  70. package/src/cursor-run-final-text.ts +56 -0
  71. package/src/cursor-sdk-abort-error-guard.ts +4 -0
  72. package/src/cursor-sdk-event-debug-constants.ts +14 -5
  73. package/src/cursor-sdk-event-debug.ts +8 -2
  74. package/src/cursor-sensitive-text.ts +3 -36
  75. package/src/cursor-session-agent.ts +265 -88
  76. package/src/cursor-setting-sources.ts +7 -10
  77. package/src/cursor-state.ts +232 -28
  78. package/src/cursor-tool-lifecycle.ts +17 -42
  79. package/src/cursor-tool-manifest.ts +41 -0
  80. package/src/cursor-tool-names.ts +18 -79
  81. package/src/cursor-tool-presentation-registry.ts +556 -0
  82. package/src/cursor-tool-transcript.ts +1 -1
  83. package/src/cursor-tool-visibility.ts +39 -0
  84. package/src/cursor-transcript-tool-formatters.ts +0 -59
  85. package/src/cursor-transcript-tool-specs.ts +169 -232
  86. package/src/cursor-transcript-utils.ts +0 -44
  87. package/src/cursor-web-tool-activity.ts +10 -60
  88. package/src/cursor-web-tool-args.ts +39 -0
  89. package/src/index.ts +4 -10
@@ -0,0 +1,8 @@
1
+ const CURSOR_API_KEY_ENV_VAR = "CURSOR_API_KEY";
2
+
3
+ export function resolveCursorApiKey(apiKey?: string): string | undefined {
4
+ const trimmed = apiKey?.trim();
5
+ if (!trimmed) return undefined;
6
+ if (trimmed === CURSOR_API_KEY_ENV_VAR) return process.env.CURSOR_API_KEY?.trim();
7
+ return trimmed;
8
+ }
@@ -2,103 +2,40 @@ import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-wo
2
2
  import type { InteractionUpdate } from "@cursor/sdk";
3
3
  import type { CursorLiveRun } from "./cursor-live-run-coordinator.js";
4
4
  import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
5
- import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
6
- import { resolveNativeReplayDisposition } from "./cursor-native-replay-routing.js";
7
5
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
8
- import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
9
- import { asRecord, getField, hasUsableText } from "./cursor-record-utils.js";
10
- import { scrubPiToolDisplay, scrubSensitiveText } from "./cursor-sensitive-text.js";
11
- import { buildCursorPiToolDisplay, formatCursorToolTranscript, getCursorCreatePlanText, mergeCursorToolCalls } from "./cursor-tool-transcript.js";
12
6
  import {
13
7
  recordDiscardedIncompleteStartedToolCall,
14
8
  type CursorSdkEventDebugRecorder,
15
- DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
16
9
  } from "./cursor-sdk-event-debug.js";
17
10
  import {
18
- buildIncompleteCursorToolDisplay,
19
- formatIncompleteCursorToolTrace,
20
- type IncompleteCursorToolDiscardReason,
11
+ buildIncompleteCursorToolRunOutcome,
12
+ resolveIncompleteCursorToolVisibility,
13
+ type IncompleteCursorToolRunOutcome,
21
14
  } from "./cursor-incomplete-tool-visibility.js";
22
- import { getToolName, normalizeToolName } from "./cursor-transcript-utils.js";
15
+ import { getToolName } from "./cursor-transcript-utils.js";
16
+ import { classifyCursorToolVisibility } from "./cursor-tool-visibility.js";
17
+ import { buildCursorPiToolDisplay } from "./cursor-tool-transcript.js";
18
+ import { getField } from "./cursor-record-utils.js";
19
+ import { CursorTurnDisplayRouter } from "./cursor-provider-turn-display-router.js";
23
20
  import {
24
- CURSOR_TOOL_LIFECYCLE_DEFER_MS,
25
- formatCursorToolLifecycleProgressText,
26
- isCursorToolLifecycleEligible,
27
- } from "./cursor-tool-lifecycle.js";
28
-
29
- function formatCursorToolName(toolCall: unknown): string {
30
- return truncateCursorDisplayLine(getToolName(toolCall), 80) || "unknown";
31
- }
32
-
33
- interface CursorShellOutputDelta {
34
- stream: "stdout" | "stderr";
35
- data: string;
36
- }
37
-
38
- interface CursorShellOutputDeltas {
39
- stdout: string[];
40
- stderr: string[];
41
- }
42
-
43
- function isCursorShellToolCall(toolCall: unknown): boolean {
44
- const normalizedName = getToolName(toolCall).replace(/\s+/g, " ").trim().toLowerCase();
45
- return normalizedName === "shell" || normalizedName === "run_terminal_cmd" || normalizedName === "terminal" || normalizedName === "bash";
46
- }
47
-
48
- function getCursorShellOutputDelta(update: InteractionUpdate): CursorShellOutputDelta | undefined {
49
- if (update.type !== "shell-output-delta") return undefined;
50
- const event = getField(update, "event");
51
- const eventCase = getField(event, "case");
52
- if (eventCase !== "stdout" && eventCase !== "stderr") return undefined;
53
- const value = getField(event, "value");
54
- const data = getField(value, "data");
55
- if (typeof data !== "string" || data.length === 0) return undefined;
56
- return { stream: eventCase, data };
57
- }
58
-
59
- function mergeShellOutputDeltasIntoCursorToolCall(toolCall: unknown, deltas: CursorShellOutputDeltas | undefined): unknown {
60
- if (!deltas) return toolCall;
61
- const stdout = deltas.stdout.join("");
62
- const stderr = deltas.stderr.join("");
63
- if (!hasUsableText(stdout) && !hasUsableText(stderr)) return toolCall;
64
-
65
- const toolRecord = asRecord(toolCall);
66
- const result = getField(toolRecord, "result");
67
- const resultRecord = asRecord(result);
68
- if (!toolRecord || !resultRecord || resultRecord.status !== "success") return toolCall;
69
-
70
- const value = getField(resultRecord, "value");
71
- const valueRecord = asRecord(value);
72
- const completedStdout = getField(valueRecord, "stdout");
73
- const completedStderr = getField(valueRecord, "stderr");
74
- if (hasUsableText(typeof completedStdout === "string" ? completedStdout : undefined)) return toolCall;
75
- if (hasUsableText(typeof completedStderr === "string" ? completedStderr : undefined)) return toolCall;
21
+ createTurnCoordinatorContentEmitter,
22
+ CursorToolLifecycleEmitter,
23
+ } from "./cursor-provider-turn-lifecycle-emitter.js";
24
+ import { resolveCursorToolCompletion } from "./cursor-provider-turn-sdk-normalizer.js";
25
+ import {
26
+ CursorShellOutputTracker,
27
+ getCursorShellOutputDelta,
28
+ isCursorShellToolCall,
29
+ } from "./cursor-provider-turn-shell-output.js";
30
+ import {
31
+ CursorToolCompletionLedger,
32
+ getToolFingerprint,
33
+ } from "./cursor-provider-turn-tool-ledger.js";
76
34
 
77
- return {
78
- ...toolRecord,
79
- result: {
80
- ...resultRecord,
81
- value: {
82
- ...(valueRecord ?? {}),
83
- stdout,
84
- stderr,
85
- },
86
- },
87
- };
35
+ function getNormalizedCursorToolName(toolCall: unknown): string {
36
+ return classifyCursorToolVisibility(toolCall).normalizedName;
88
37
  }
89
38
 
90
- type CursorToolDisplaySource = "started" | "fallback" | "transcript";
91
-
92
- type ToolCompletionResolution =
93
- | { action: "ignore-bridge"; identity?: string }
94
- | {
95
- action: "handle";
96
- toolCall: unknown;
97
- identity?: string;
98
- source?: CursorToolDisplaySource;
99
- matchedStartedCallId?: string;
100
- };
101
-
102
39
  export interface CursorSdkTurnCoordinatorOptions {
103
40
  stream: AssistantMessageEventStream;
104
41
  partial: AssistantMessage;
@@ -123,21 +60,12 @@ export class CursorSdkTurnCoordinator {
123
60
  readonly nativeReplayId: string;
124
61
  readonly textDeltas: string[];
125
62
 
126
- private readonly contentEmitter: CursorPartialContentEmitter;
127
63
  private readonly debugRecorder?: CursorSdkEventDebugRecorder;
128
- private nativeToolDisplayCounter = 0;
129
- private nativeToolReplayStarted = false;
130
- private cursorPlanTextCandidate: string | undefined;
131
- private readonly startedToolCalls = new Map<string, unknown>();
132
- private readonly bridgeStartedToolCallIds = new Set<string>();
133
- private readonly activeShellCallIds = new Set<string>();
134
- private readonly ambiguousShellOutputCallIds = new Set<string>();
135
- private readonly shellOutputDeltasByCallId = new Map<string, CursorShellOutputDeltas>();
136
- private readonly completedToolIdentities = new Set<string>();
137
- private readonly completedStartedToolFingerprints = new Set<string>();
138
- private readonly completedFallbackToolFingerprints = new Set<string>();
139
- private readonly emittedLifecycleCallIds = new Set<string>();
140
- private readonly lifecycleTimers = new Map<string, ReturnType<typeof setTimeout>>();
64
+ private readonly ledger = new CursorToolCompletionLedger();
65
+ private readonly shellOutput = new CursorShellOutputTracker();
66
+ private readonly displayRouter: CursorTurnDisplayRouter;
67
+ private readonly lifecycleEmitter: CursorToolLifecycleEmitter;
68
+ private readonly contentEmitter;
141
69
 
142
70
  constructor(options: CursorSdkTurnCoordinatorOptions) {
143
71
  this.stream = options.stream;
@@ -150,37 +78,61 @@ export class CursorSdkTurnCoordinator {
150
78
  this.nativeReplayId = options.nativeReplayId;
151
79
  this.textDeltas = options.textDeltas;
152
80
  this.debugRecorder = options.debugRecorder;
153
- this.contentEmitter = new CursorPartialContentEmitter(options.stream, options.partial, undefined, false);
81
+ this.contentEmitter = createTurnCoordinatorContentEmitter(options.stream, options.partial);
82
+ this.displayRouter = new CursorTurnDisplayRouter({
83
+ cwd: options.cwd,
84
+ resolvedApiKey: options.resolvedApiKey,
85
+ liveRun: options.liveRun,
86
+ useNativeToolReplay: options.useNativeToolReplay,
87
+ activeToolNames: options.activeToolNames,
88
+ nativeReplayId: options.nativeReplayId,
89
+ contentEmitter: this.contentEmitter,
90
+ debugRecorder: options.debugRecorder,
91
+ });
92
+ this.lifecycleEmitter = new CursorToolLifecycleEmitter({
93
+ liveRun: options.liveRun,
94
+ resolvedApiKey: options.resolvedApiKey,
95
+ contentEmitter: this.contentEmitter,
96
+ debugRecorder: options.debugRecorder,
97
+ hasStartedToolCall: (callId) => this.ledger.hasStartedToolCall(callId),
98
+ isBridgeMcpToolCall: (toolCall) => options.liveRun?.bridgeRun?.isBridgeMcpToolCall(toolCall) ?? false,
99
+ });
154
100
  }
155
101
 
156
102
  get planTextCandidate(): string | undefined {
157
- return this.cursorPlanTextCandidate;
103
+ return this.displayRouter.planTextCandidate;
158
104
  }
159
105
 
160
106
  get replayStarted(): boolean {
161
- return this.nativeToolReplayStarted;
107
+ return this.displayRouter.nativeToolReplayStarted;
162
108
  }
163
109
 
164
110
  discardIncompleteStartedToolCalls(
165
- reason: IncompleteCursorToolDiscardReason = DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
111
+ outcome: IncompleteCursorToolRunOutcome = buildIncompleteCursorToolRunOutcome(),
166
112
  ): void {
167
- for (const [callId, toolCall] of this.startedToolCalls) {
168
- if (typeof callId !== "string") continue;
113
+ for (const [callId, toolCall] of this.ledger.startedToolCallEntries()) {
114
+ const toolName = getNormalizedCursorToolName(toolCall);
169
115
  recordDiscardedIncompleteStartedToolCall(this.debugRecorder, process.env, {
170
- toolName: normalizeToolName(getToolName(toolCall)),
116
+ toolName,
171
117
  callId,
172
- reason,
118
+ reason: outcome.reason,
173
119
  });
174
- this.emitIncompleteStartedToolCall(toolCall, reason);
120
+ const visibilityDecision = resolveIncompleteCursorToolVisibility(toolCall, outcome);
121
+ if (visibilityDecision !== "emit") {
122
+ this.displayRouter.recordIncompleteSkip(
123
+ toolName,
124
+ visibilityDecision === "debugOnly" && outcome.assistantTextProduced
125
+ ? "successful-run-text-produced"
126
+ : visibilityDecision,
127
+ );
128
+ continue;
129
+ }
130
+ const action = this.displayRouter.routeIncompleteStartedToolCall(toolCall, outcome.reason);
131
+ if (action) this.displayRouter.emitDisplayAction(action);
175
132
  }
176
- this.startedToolCalls.clear();
177
- this.bridgeStartedToolCallIds.clear();
178
- this.activeShellCallIds.clear();
179
- this.ambiguousShellOutputCallIds.clear();
180
- this.shellOutputDeltasByCallId.clear();
181
- this.emittedLifecycleCallIds.clear();
182
- for (const timer of this.lifecycleTimers.values()) clearTimeout(timer);
183
- this.lifecycleTimers.clear();
133
+ this.ledger.clearStartedToolCalls();
134
+ this.shellOutput.clear();
135
+ this.lifecycleEmitter.clear();
184
136
  }
185
137
 
186
138
  closeTraceBlock(): void {
@@ -218,28 +170,37 @@ export class CursorSdkTurnCoordinator {
218
170
  return;
219
171
  }
220
172
  if (update.type === "partial-tool-call") {
221
- this.maybeScheduleCursorToolLifecycle(update.callId, update.toolCall);
173
+ this.lifecycleEmitter.maybeSchedule(update.callId, update.toolCall);
222
174
  return;
223
175
  }
224
176
  if (update.type === "tool-call-started") {
225
177
  if (this.liveRun?.bridgeRun?.isBridgeMcpToolCall(update.toolCall)) {
226
- if (typeof update.callId === "string") this.bridgeStartedToolCallIds.add(update.callId);
178
+ if (typeof update.callId === "string") this.ledger.markBridgeStarted(update.callId);
227
179
  } else {
228
- this.maybeScheduleCursorToolLifecycle(update.callId, update.toolCall);
229
- this.startedToolCalls.set(update.callId, update.toolCall);
230
- if (isCursorShellToolCall(update.toolCall)) this.activeShellCallIds.add(update.callId);
180
+ this.lifecycleEmitter.maybeSchedule(update.callId, update.toolCall);
181
+ this.ledger.registerStartedToolCall(update.callId, update.toolCall);
182
+ if (isCursorShellToolCall(update.toolCall) && typeof update.callId === "string") {
183
+ this.shellOutput.onShellToolStarted(update.callId);
184
+ }
231
185
  }
232
186
  return;
233
187
  }
234
188
  if (update.type === "tool-call-completed") {
235
- const resolution = this.resolveToolCompletion({
189
+ const resolution = resolveCursorToolCompletion({
236
190
  source: "delta",
237
191
  callId: update.callId,
238
192
  toolCall: update.toolCall,
239
- startedToolCall: this.startedToolCalls.get(update.callId),
193
+ startedToolCall: this.ledger.getStartedToolCall(update.callId),
194
+ liveRun: this.liveRun,
195
+ ledger: this.ledger,
196
+ shellOutput: this.shellOutput,
197
+ onClearStartedCallId: (callId) => {
198
+ this.lifecycleEmitter.cancel(callId);
199
+ this.shellOutput.onShellToolCleared(callId);
200
+ },
240
201
  });
241
202
  if (resolution.action === "ignore-bridge") {
242
- this.recordIgnoreBridgeDecision(resolution.identity, getToolName(update.toolCall), "delta");
203
+ this.displayRouter.recordIgnoreBridgeDecision(resolution.identity, getToolName(update.toolCall), "delta");
243
204
  return;
244
205
  }
245
206
  this.handleCompletedToolCall(resolution.toolCall, {
@@ -250,7 +211,7 @@ export class CursorSdkTurnCoordinator {
250
211
  }
251
212
  if (update.type === "shell-output-delta") {
252
213
  const delta = getCursorShellOutputDelta(update);
253
- if (delta) this.appendShellOutputDelta(delta);
214
+ if (delta) this.shellOutput.appendShellOutputDelta(delta);
254
215
  return;
255
216
  }
256
217
  if (update.type === "summary") {
@@ -272,13 +233,20 @@ export class CursorSdkTurnCoordinator {
272
233
  const stepId = getField(stepEnvelope, "id") ?? getField(toolCall, "id") ?? getField(toolCall, "callId");
273
234
  if (!toolCall) return;
274
235
 
275
- const resolution = this.resolveToolCompletion({
236
+ const resolution = resolveCursorToolCompletion({
276
237
  source: "step",
277
238
  callId: stepId,
278
239
  toolCall,
240
+ liveRun: this.liveRun,
241
+ ledger: this.ledger,
242
+ shellOutput: this.shellOutput,
243
+ onClearStartedCallId: (callId) => {
244
+ this.lifecycleEmitter.cancel(callId);
245
+ this.shellOutput.onShellToolCleared(callId);
246
+ },
279
247
  });
280
248
  if (resolution.action === "ignore-bridge") {
281
- this.recordIgnoreBridgeDecision(resolution.identity, getToolName(toolCall), "step");
249
+ this.displayRouter.recordIgnoreBridgeDecision(resolution.identity, getToolName(toolCall), "step");
282
250
  return;
283
251
  }
284
252
  this.handleCompletedToolCall(resolution.toolCall, {
@@ -286,66 +254,8 @@ export class CursorSdkTurnCoordinator {
286
254
  source: resolution.source,
287
255
  });
288
256
  if (resolution.matchedStartedCallId && resolution.matchedStartedCallId !== stepId) {
289
- this.completedToolIdentities.add(`cursor-tool:${resolution.matchedStartedCallId}`);
290
- }
291
- }
292
-
293
- private resolveToolCompletion(options: {
294
- source: "delta" | "step";
295
- callId: unknown;
296
- toolCall: unknown;
297
- startedToolCall?: unknown;
298
- }): ToolCompletionResolution {
299
- const bridgeStartedCallId = this.takeBridgeStartedToolCallId(options.callId);
300
- if (bridgeStartedCallId) {
301
- this.completedToolIdentities.add(`cursor-tool:${bridgeStartedCallId}`);
302
- return { action: "ignore-bridge", identity: `cursor-tool:${bridgeStartedCallId}` };
303
- }
304
-
305
- let matchedStartedCallId: string | undefined;
306
- let resolvedToolCall: unknown;
307
- let identity: string | undefined;
308
- let source: "started" | "fallback" | undefined;
309
-
310
- if (options.source === "delta") {
311
- const callId = options.callId;
312
- identity = typeof callId === "string" ? `cursor-tool:${callId}` : undefined;
313
- resolvedToolCall = mergeCursorToolCalls(options.startedToolCall, options.toolCall);
314
- if (typeof callId === "string") {
315
- this.clearStartedToolCall(callId);
316
- }
317
- resolvedToolCall = mergeShellOutputDeltasIntoCursorToolCall(
318
- resolvedToolCall,
319
- typeof callId === "string" ? this.takeShellOutputDeltas(callId) : undefined,
320
- );
321
- source = identity ? "started" : "fallback";
322
- } else {
323
- matchedStartedCallId = this.removeStartedToolCallForStep(options.toolCall, options.callId);
324
- resolvedToolCall = mergeShellOutputDeltasIntoCursorToolCall(
325
- options.toolCall,
326
- matchedStartedCallId ? this.takeShellOutputDeltas(matchedStartedCallId) : undefined,
327
- );
328
- const identityId = typeof options.callId === "string" ? options.callId : matchedStartedCallId;
329
- identity = identityId ? `cursor-tool:${identityId}` : undefined;
330
- }
331
-
332
- if (this.liveRun?.bridgeRun?.isBridgeMcpToolCall(resolvedToolCall)) {
333
- const bridgeIdentity = options.source === "step" && matchedStartedCallId
334
- ? `cursor-tool:${matchedStartedCallId}`
335
- : identity;
336
- if (bridgeIdentity) this.completedToolIdentities.add(bridgeIdentity);
337
- return { action: "ignore-bridge", identity: bridgeIdentity };
338
- }
339
-
340
- if (options.source === "delta") {
341
- return { action: "handle", toolCall: resolvedToolCall, identity, source };
257
+ this.ledger.recordCompletedIdentity(`cursor-tool:${resolution.matchedStartedCallId}`);
342
258
  }
343
- return {
344
- action: "handle",
345
- toolCall: resolvedToolCall,
346
- identity,
347
- matchedStartedCallId,
348
- };
349
259
  }
350
260
 
351
261
  handleTranscriptCompletedToolCalls(toolCalls: readonly { identity: string; toolCall: unknown }[]): void {
@@ -356,271 +266,34 @@ export class CursorSdkTurnCoordinator {
356
266
 
357
267
  private handleCompletedToolCall(
358
268
  toolCall: unknown,
359
- options: { identity?: string; source?: CursorToolDisplaySource } = {},
269
+ options: { identity?: string; source?: "started" | "fallback" | "transcript" } = {},
360
270
  ): void {
361
- const planText = getCursorCreatePlanText(toolCall);
362
- if (planText) this.cursorPlanTextCandidate = scrubSensitiveText(planText, this.resolvedApiKey);
363
-
364
- const transcript = scrubSensitiveText(formatCursorToolTranscript(toolCall, { cwd: this.cwd }), this.resolvedApiKey);
365
271
  const display = buildCursorPiToolDisplay(toolCall, { cwd: this.cwd });
366
- const toolName = display.toolName;
367
- const fingerprint = this.getToolFingerprint({ toolName: display.toolName, args: display.args, result: display.result });
368
- if (options.identity && this.completedToolIdentities.has(options.identity)) {
369
- this.recordDisplayDecision({
370
- action: "skip-duplicate",
371
- toolName,
372
- identity: options.identity,
373
- source: options.source,
374
- reason: "identity-already-completed",
375
- });
376
- return;
377
- }
378
- if (options.source === "started") {
379
- if (this.completedFallbackToolFingerprints.has(fingerprint)) {
380
- this.recordDisplayDecision({
381
- action: "skip-duplicate",
382
- toolName,
383
- identity: options.identity,
384
- source: options.source,
385
- reason: "fallback-fingerprint-already-completed",
386
- });
387
- return;
388
- }
389
- } else if (this.completedStartedToolFingerprints.has(fingerprint) || this.completedFallbackToolFingerprints.has(fingerprint)) {
390
- this.recordDisplayDecision({
391
- action: "skip-duplicate",
392
- toolName,
393
- identity: options.identity,
394
- source: options.source,
395
- reason: "fingerprint-already-completed",
396
- });
397
- return;
398
- }
399
- if (options.identity) this.completedToolIdentities.add(options.identity);
400
- if (options.source === "started") {
401
- this.completedStartedToolFingerprints.add(fingerprint);
402
- } else {
403
- this.completedFallbackToolFingerprints.add(fingerprint);
404
- }
405
-
406
- const disposition = resolveNativeReplayDisposition({
272
+ const fingerprint = getToolFingerprint({
407
273
  toolName: display.toolName,
408
- useNativeToolReplay: this.useNativeToolReplay,
409
- activeToolNames: this.activeToolNames,
410
- hasLiveRun: this.liveRun !== undefined,
274
+ args: display.args,
275
+ result: display.result,
411
276
  });
412
-
413
- if (disposition === "queue_replay" && this.liveRun) {
414
- this.nativeToolReplayStarted = true;
415
- const id = `${this.nativeReplayId}-tool-${++this.nativeToolDisplayCounter}`;
416
- const scrubbedDisplay = scrubPiToolDisplay(display, this.resolvedApiKey);
417
- this.recordDisplayDecision({
418
- action: "queue_replay",
419
- disposition,
420
- toolName,
277
+ const duplicateReason = this.ledger.shouldSkipDuplicateCompletion({
278
+ identity: options.identity,
279
+ source: options.source,
280
+ fingerprint,
281
+ });
282
+ if (duplicateReason) {
283
+ this.displayRouter.recordDuplicateSkip(display.toolName, {
421
284
  identity: options.identity,
422
285
  source: options.source,
423
- transcript,
424
- replayToolId: id,
425
- });
426
- cursorLiveRuns.queueEvent(this.liveRun, {
427
- type: "tool",
428
- tool: { ...scrubbedDisplay, id },
286
+ reason: duplicateReason,
429
287
  });
430
288
  return;
431
289
  }
432
-
433
- const traceText =
434
- disposition === "inactive_trace"
435
- ? formatInactiveCursorReplayTrace(scrubPiToolDisplay(display, this.resolvedApiKey))
436
- : transcript || `Cursor tool: ${formatCursorToolName(toolCall)} completed`;
437
- this.recordDisplayDecision({
438
- action: "emit_trace",
439
- disposition,
440
- toolName,
290
+ this.ledger.recordCompletedTool({
441
291
  identity: options.identity,
442
292
  source: options.source,
443
- transcript,
444
- traceText,
445
- });
446
- this.emitCursorToolTrace(traceText);
447
- }
448
-
449
- private emitIncompleteStartedToolCall(toolCall: unknown, reason: IncompleteCursorToolDiscardReason): void {
450
- const display = scrubPiToolDisplay(
451
- buildIncompleteCursorToolDisplay(toolCall, reason, { apiKey: this.resolvedApiKey }),
452
- this.resolvedApiKey,
453
- );
454
- const toolName = display.toolName;
455
- const disposition = resolveNativeReplayDisposition({
456
- toolName,
457
- useNativeToolReplay: this.useNativeToolReplay,
458
- activeToolNames: this.activeToolNames,
459
- hasLiveRun: this.liveRun !== undefined,
460
- });
461
-
462
- // Aborted live runs emit trace visibility only; do not synthesize a toolUse replay turn.
463
- if (disposition === "queue_replay" && this.liveRun && reason !== "abort") {
464
- this.nativeToolReplayStarted = true;
465
- const id = `${this.nativeReplayId}-tool-${++this.nativeToolDisplayCounter}`;
466
- this.recordDisplayDecision({
467
- action: "queue_replay",
468
- disposition,
469
- toolName,
470
- source: "started",
471
- reason: "incomplete-started-tool-call",
472
- replayToolId: id,
473
- });
474
- cursorLiveRuns.queueEvent(this.liveRun, {
475
- type: "tool",
476
- tool: { ...display, id },
477
- });
478
- return;
479
- }
480
-
481
- const traceText =
482
- disposition === "inactive_trace"
483
- ? formatInactiveCursorReplayTrace(display)
484
- : formatIncompleteCursorToolTrace(display);
485
- this.recordDisplayDecision({
486
- action: "emit_trace",
487
- disposition,
488
- toolName,
489
- source: "started",
490
- reason: "incomplete-started-tool-call",
491
- traceText,
293
+ fingerprint,
492
294
  });
493
- this.emitCursorToolTrace(traceText);
494
- }
495
-
496
- private recordIgnoreBridgeDecision(
497
- identity: string | undefined,
498
- toolName: string,
499
- source: "delta" | "step",
500
- ): void {
501
- this.debugRecorder?.recordDisplayDecision({
502
- action: "ignore-bridge",
503
- toolName,
504
- identity,
505
- source,
506
- });
507
- }
508
-
509
- private recordDisplayDecision(decision: Parameters<CursorSdkEventDebugRecorder["recordDisplayDecision"]>[0]): void {
510
- this.debugRecorder?.recordDisplayDecision(decision);
511
- }
512
295
 
513
- private emitCursorToolTrace(text: string): void {
514
- const traceText = text.endsWith("\n") ? text : `${text}\n`;
515
- if (this.liveRun) {
516
- cursorLiveRuns.queueEvent(this.liveRun, { type: "thinking-delta", text: traceText });
517
- cursorLiveRuns.queueEvent(this.liveRun, { type: "thinking-completed" });
518
- return;
519
- }
520
- this.contentEmitter.appendThinkingBlock(traceText);
521
- }
522
-
523
- private maybeScheduleCursorToolLifecycle(callId: unknown, toolCall: unknown): void {
524
- if (typeof callId !== "string" || this.emittedLifecycleCallIds.has(callId)) return;
525
- if (this.liveRun?.bridgeRun?.isBridgeMcpToolCall(toolCall)) return;
526
- if (!isCursorToolLifecycleEligible(toolCall)) return;
527
-
528
- this.cancelCursorToolLifecycleTimer(callId);
529
- const timer = setTimeout(() => {
530
- this.lifecycleTimers.delete(callId);
531
- if (!this.startedToolCalls.has(callId)) return;
532
- if (this.emittedLifecycleCallIds.has(callId)) return;
533
- this.emitCursorToolLifecycle(callId, toolCall);
534
- }, CURSOR_TOOL_LIFECYCLE_DEFER_MS);
535
- timer.unref?.();
536
- this.lifecycleTimers.set(callId, timer);
537
- }
538
-
539
- private cancelCursorToolLifecycleTimer(callId: string): void {
540
- const timer = this.lifecycleTimers.get(callId);
541
- if (!timer) return;
542
- clearTimeout(timer);
543
- this.lifecycleTimers.delete(callId);
544
- }
545
-
546
- private emitCursorToolLifecycle(callId: string, toolCall: unknown): void {
547
- const progressText = formatCursorToolLifecycleProgressText(toolCall, this.resolvedApiKey);
548
- if (!progressText) return;
549
- this.emittedLifecycleCallIds.add(callId);
550
- this.debugRecorder?.recordCoordinatorEvent("tool_lifecycle", {
551
- callId,
552
- toolName: normalizeToolName(getToolName(toolCall)),
553
- progressText,
554
- liveRun: this.liveRun !== undefined,
555
- });
556
- if (this.liveRun) {
557
- cursorLiveRuns.queueEvent(this.liveRun, { type: "thinking-delta", text: progressText });
558
- return;
559
- }
560
- this.contentEmitter.appendThinkingDelta(progressText);
561
- }
562
-
563
- private getToolFingerprint(value: unknown): string {
564
- try {
565
- return JSON.stringify(value);
566
- } catch {
567
- return String(value);
568
- }
569
- }
570
-
571
- private getStartedToolCallFingerprint(toolCall: unknown): string {
572
- return this.getToolFingerprint({ toolName: getToolName(toolCall), args: getField(toolCall, "args") });
573
- }
574
-
575
- private clearStartedToolCall(callId: string): void {
576
- this.cancelCursorToolLifecycleTimer(callId);
577
- this.startedToolCalls.delete(callId);
578
- this.bridgeStartedToolCallIds.delete(callId);
579
- this.activeShellCallIds.delete(callId);
580
- this.ambiguousShellOutputCallIds.delete(callId);
581
- }
582
-
583
- private takeBridgeStartedToolCallId(callId: unknown): string | undefined {
584
- if (typeof callId !== "string" || !this.bridgeStartedToolCallIds.has(callId)) return undefined;
585
- this.bridgeStartedToolCallIds.delete(callId);
586
- return callId;
587
- }
588
-
589
- private takeShellOutputDeltas(callId: string): CursorShellOutputDeltas | undefined {
590
- const deltas = this.shellOutputDeltasByCallId.get(callId);
591
- this.shellOutputDeltasByCallId.delete(callId);
592
- return deltas;
593
- }
594
-
595
- private appendShellOutputDelta(delta: CursorShellOutputDelta): void {
596
- if (this.activeShellCallIds.size !== 1) {
597
- for (const activeCallId of this.activeShellCallIds) {
598
- this.ambiguousShellOutputCallIds.add(activeCallId);
599
- this.shellOutputDeltasByCallId.delete(activeCallId);
600
- }
601
- return;
602
- }
603
- const [callId] = this.activeShellCallIds;
604
- if (!callId || this.ambiguousShellOutputCallIds.has(callId)) return;
605
- let deltas = this.shellOutputDeltasByCallId.get(callId);
606
- if (!deltas) {
607
- deltas = { stdout: [], stderr: [] };
608
- this.shellOutputDeltasByCallId.set(callId, deltas);
609
- }
610
- deltas[delta.stream].push(delta.data);
611
- }
612
-
613
- private removeStartedToolCallForStep(toolCall: unknown, stepId: unknown): string | undefined {
614
- if (typeof stepId === "string" && this.startedToolCalls.has(stepId)) {
615
- this.clearStartedToolCall(stepId);
616
- return stepId;
617
- }
618
- const fingerprint = this.getStartedToolCallFingerprint(toolCall);
619
- for (const [callId, startedToolCall] of this.startedToolCalls) {
620
- if (this.getStartedToolCallFingerprint(startedToolCall) !== fingerprint) continue;
621
- this.clearStartedToolCall(callId);
622
- return callId;
623
- }
624
- return undefined;
296
+ const action = this.displayRouter.routeCompletedToolCall(toolCall, options);
297
+ if (action) this.displayRouter.emitDisplayAction(action);
625
298
  }
626
299
  }