pi-cursor-sdk 0.1.20 → 0.1.22

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