pi-cursor-sdk 0.1.20 → 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 (88) hide show
  1. package/CHANGELOG.md +32 -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 +57 -17
  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 +235 -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 +138 -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-setting-sources.ts +7 -10
  76. package/src/cursor-state.ts +232 -28
  77. package/src/cursor-tool-lifecycle.ts +9 -8
  78. package/src/cursor-tool-manifest.ts +41 -0
  79. package/src/cursor-tool-names.ts +18 -106
  80. package/src/cursor-tool-presentation-registry.ts +556 -0
  81. package/src/cursor-tool-transcript.ts +1 -1
  82. package/src/cursor-tool-visibility.ts +3 -27
  83. package/src/cursor-transcript-tool-formatters.ts +0 -59
  84. package/src/cursor-transcript-tool-specs.ts +158 -233
  85. package/src/cursor-transcript-utils.ts +0 -44
  86. package/src/cursor-web-tool-activity.ts +10 -60
  87. package/src/cursor-web-tool-args.ts +39 -0
  88. package/src/index.ts +4 -10
@@ -17,22 +17,147 @@ import {
17
17
  isCursorReplayToolName,
18
18
  } from "./cursor-tool-names.js";
19
19
  import {
20
- asCursorReplayToolDetails,
21
20
  createCursorReplayOnlyToolDefinition,
21
+ isCursorReplayNativeEditDetails,
22
+ isCursorReplayNativeWriteDetails,
23
+ parseCursorReplayToolDetails,
22
24
  renderCursorReplayResult,
23
25
  renderNativeLookingCursorFileMutationCall,
24
26
  renderNativeLookingCursorReadReplayResult,
25
27
  } from "./cursor-native-tool-display-replay.js";
26
28
  import {
27
29
  consumeCursorNativeToolDisplay,
28
- isCursorFileMutationToolName,
29
30
  isCursorReplayToolCallId,
30
31
  } from "./cursor-native-tool-display-state.js";
31
32
 
32
33
  const CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES = [CURSOR_REPLAY_ACTIVITY_TOOL_NAME] as const;
33
34
  const CURSOR_REPLAY_TOOL_NAMES = [CURSOR_REPLAY_ACTIVITY_TOOL_NAME, ...CURSOR_REPLAY_LEGACY_TOOL_NAMES] as const;
34
- export const NATIVE_CURSOR_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls", ...CURSOR_REPLAY_TOOL_NAMES] as const;
35
- export type NativeCursorToolName = (typeof NATIVE_CURSOR_TOOL_NAMES)[number];
35
+
36
+ type AnyToolDefinition = ToolDefinition<TSchema, unknown, unknown>;
37
+ type RenderCall = NonNullable<AnyToolDefinition["renderCall"]>;
38
+ type RenderResult = NonNullable<AnyToolDefinition["renderResult"]>;
39
+
40
+ type NativeReplayStrategy = {
41
+ createDefinition: (cwd: string) => AnyToolDefinition;
42
+ missingReplayPolicy?: "block-file-mutation";
43
+ renderReplayCall?: (
44
+ args: Parameters<RenderCall>[0],
45
+ theme: Parameters<RenderCall>[1],
46
+ context: Parameters<RenderCall>[2],
47
+ renderBase: () => ReturnType<RenderCall>,
48
+ ) => ReturnType<RenderCall>;
49
+ renderReplayResult?: (
50
+ result: Parameters<RenderResult>[0],
51
+ options: Parameters<RenderResult>[1],
52
+ theme: Parameters<RenderResult>[2],
53
+ context: Parameters<RenderResult>[3],
54
+ renderBase: () => ReturnType<RenderResult>,
55
+ ) => ReturnType<RenderResult>;
56
+ };
57
+
58
+ function emptyText(): Text {
59
+ return new Text("", 0, 0);
60
+ }
61
+
62
+ function renderReadReplayCall(
63
+ args: Parameters<RenderCall>[0],
64
+ theme: Parameters<RenderCall>[1],
65
+ context: Parameters<RenderCall>[2],
66
+ renderBase: () => ReturnType<RenderCall>,
67
+ ): ReturnType<RenderCall> {
68
+ const rendered = renderBase();
69
+ if ((args as Record<string, unknown>).localReadPreview !== true || context.expanded) return rendered;
70
+ const baseText = rendered.render(120).join("\n").trimEnd();
71
+ const labeled = `${baseText}${theme.fg("muted", " · local file preview")}`;
72
+ if (rendered instanceof Text) {
73
+ rendered.setText(labeled);
74
+ return rendered;
75
+ }
76
+ return new Text(labeled, 0, 0);
77
+ }
78
+
79
+ function renderReadReplayResult(
80
+ result: Parameters<RenderResult>[0],
81
+ options: Parameters<RenderResult>[1],
82
+ theme: Parameters<RenderResult>[2],
83
+ context: Parameters<RenderResult>[3],
84
+ renderBase: () => ReturnType<RenderResult>,
85
+ ): ReturnType<RenderResult> {
86
+ return renderNativeLookingCursorReadReplayResult(
87
+ result,
88
+ options,
89
+ theme,
90
+ context as Parameters<typeof renderNativeLookingCursorReadReplayResult>[3],
91
+ renderBase,
92
+ );
93
+ }
94
+
95
+ function renderEditReplayResult(
96
+ result: Parameters<RenderResult>[0],
97
+ options: Parameters<RenderResult>[1],
98
+ theme: Parameters<RenderResult>[2],
99
+ context: Parameters<RenderResult>[3],
100
+ renderBase: () => ReturnType<RenderResult>,
101
+ ): ReturnType<RenderResult> {
102
+ const details = parseCursorReplayToolDetails(result.details);
103
+ return details && isCursorReplayNativeEditDetails(details)
104
+ ? renderCursorReplayResult(result, options, theme, context as Parameters<typeof renderCursorReplayResult>[3], context.isError)
105
+ : renderBase();
106
+ }
107
+
108
+ function renderWriteReplayResult(
109
+ result: Parameters<RenderResult>[0],
110
+ options: Parameters<RenderResult>[1],
111
+ theme: Parameters<RenderResult>[2],
112
+ context: Parameters<RenderResult>[3],
113
+ renderBase: () => ReturnType<RenderResult>,
114
+ ): ReturnType<RenderResult> {
115
+ const details = parseCursorReplayToolDetails(result.details);
116
+ return details && isCursorReplayNativeWriteDetails(details)
117
+ ? renderCursorReplayResult(result, options, theme, context as Parameters<typeof renderCursorReplayResult>[3], context.isError)
118
+ : renderBase();
119
+ }
120
+
121
+ type BuiltinNativeCursorToolName = "read" | "bash" | "edit" | "write" | "grep" | "find" | "ls";
122
+
123
+ const NATIVE_CURSOR_TOOL_STRATEGIES: Record<BuiltinNativeCursorToolName, NativeReplayStrategy> = {
124
+ read: {
125
+ createDefinition: (cwd) => createReadToolDefinition(cwd) as AnyToolDefinition,
126
+ renderReplayCall: renderReadReplayCall,
127
+ renderReplayResult: renderReadReplayResult,
128
+ },
129
+ bash: { createDefinition: (cwd) => createBashToolDefinition(cwd) as AnyToolDefinition },
130
+ edit: {
131
+ createDefinition: (cwd) => createEditToolDefinition(cwd) as AnyToolDefinition,
132
+ missingReplayPolicy: "block-file-mutation",
133
+ renderReplayCall: (args, theme, context) =>
134
+ renderNativeLookingCursorFileMutationCall("edit", args as Record<string, unknown>, theme, context.isPartial),
135
+ renderReplayResult: renderEditReplayResult,
136
+ },
137
+ write: {
138
+ createDefinition: (cwd) => createWriteToolDefinition(cwd) as AnyToolDefinition,
139
+ missingReplayPolicy: "block-file-mutation",
140
+ renderReplayCall: (args, theme, context) =>
141
+ renderNativeLookingCursorFileMutationCall("write", args as Record<string, unknown>, theme, context.isPartial),
142
+ renderReplayResult: renderWriteReplayResult,
143
+ },
144
+ grep: { createDefinition: (cwd) => createGrepToolDefinition(cwd) as AnyToolDefinition },
145
+ find: { createDefinition: (cwd) => createFindToolDefinition(cwd) as AnyToolDefinition },
146
+ ls: { createDefinition: (cwd) => createLsToolDefinition(cwd) as AnyToolDefinition },
147
+ };
148
+
149
+ const BUILTIN_NATIVE_CURSOR_TOOL_NAMES = Object.keys(NATIVE_CURSOR_TOOL_STRATEGIES) as BuiltinNativeCursorToolName[];
150
+ export const NATIVE_CURSOR_TOOL_NAMES = [
151
+ ...BUILTIN_NATIVE_CURSOR_TOOL_NAMES,
152
+ ...CURSOR_REPLAY_TOOL_NAMES,
153
+ ] as readonly NativeCursorToolName[];
154
+ export type NativeCursorToolName = BuiltinNativeCursorToolName | typeof CURSOR_REPLAY_TOOL_NAMES[number];
155
+
156
+ function getNativeReplayStrategy(toolName: string): NativeReplayStrategy | undefined {
157
+ return Object.hasOwn(NATIVE_CURSOR_TOOL_STRATEGIES, toolName)
158
+ ? NATIVE_CURSOR_TOOL_STRATEGIES[toolName as BuiltinNativeCursorToolName]
159
+ : undefined;
160
+ }
36
161
 
37
162
  export function isNativeCursorToolName(toolName: string): toolName is NativeCursorToolName {
38
163
  return NATIVE_CURSOR_TOOL_NAMES.some((nativeToolName) => nativeToolName === toolName);
@@ -42,6 +167,7 @@ export function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
42
167
  definition: ToolDefinition<TParams, TDetails, TState>,
43
168
  getCurrentDefinition: () => ToolDefinition<TParams, TDetails, TState>,
44
169
  ): ToolDefinition<TParams, TDetails, TState> {
170
+ const strategy = getNativeReplayStrategy(definition.name);
45
171
  return {
46
172
  ...definition,
47
173
  async execute(toolCallId, params, signal, onUpdate, ctx) {
@@ -60,61 +186,43 @@ export function wrapNativeCursorTool<TParams extends TSchema, TDetails, TState>(
60
186
  terminate: cursorDisplay.terminate ?? true,
61
187
  };
62
188
  }
63
- if (isCursorFileMutationToolName(definition.name) && isCursorReplayToolCallId(toolCallId)) {
189
+ if (strategy?.missingReplayPolicy === "block-file-mutation" && isCursorReplayToolCallId(toolCallId)) {
64
190
  throw new Error(`No recorded Cursor ${definition.name} result was available. This replay-only call does not execute file mutations.`);
65
191
  }
66
192
  return getCurrentDefinition().execute(toolCallId, params, signal, onUpdate, ctx);
67
193
  },
68
194
  renderCall(args, theme, context) {
69
- if (definition.name === "read" && isCursorReplayToolCallId(context.toolCallId)) {
70
- const currentRenderCall = getCurrentDefinition().renderCall;
71
- const rendered = currentRenderCall ? currentRenderCall(args, theme, context) : new Text("", 0, 0);
72
- if ((args as Record<string, unknown>).localReadPreview === true && !context.expanded) {
73
- const baseText = rendered.render(120).join("\n").trimEnd();
74
- const labeled = `${baseText}${theme.fg("muted", " · local file preview")}`;
75
- if (rendered instanceof Text) {
76
- rendered.setText(labeled);
77
- return rendered;
78
- }
79
- return new Text(labeled, 0, 0);
80
- }
81
- return rendered;
82
- }
83
- if (isCursorFileMutationToolName(definition.name) && isCursorReplayToolCallId(context.toolCallId)) {
84
- return renderNativeLookingCursorFileMutationCall(definition.name, args as Record<string, unknown>, theme, context.isPartial);
85
- }
86
195
  const currentRenderCall = getCurrentDefinition().renderCall;
87
- return currentRenderCall ? currentRenderCall(args, theme, context) : new Text("", 0, 0);
196
+ const renderBase = () => currentRenderCall?.(args, theme, context) ?? emptyText();
197
+ const isReplayCall = typeof context.toolCallId === "string" && isCursorReplayToolCallId(context.toolCallId);
198
+ if (isReplayCall && strategy?.renderReplayCall) {
199
+ return strategy.renderReplayCall(args, theme, context, renderBase) as ReturnType<NonNullable<ToolDefinition<TParams, TDetails, TState>["renderCall"]>>;
200
+ }
201
+ return renderBase();
88
202
  },
89
203
  renderResult(result, options, theme, context) {
90
- const details = asCursorReplayToolDetails(result.details);
91
- if (isCursorFileMutationToolName(definition.name) && details?.cursorToolName === definition.name) {
92
- return renderCursorReplayResult(result, options, theme, context, context.isError);
93
- }
94
- if (definition.name === "read" && isCursorReplayToolCallId(context.toolCallId)) {
95
- return renderNativeLookingCursorReadReplayResult(result, options, theme, context, () =>
96
- getCurrentDefinition().renderResult?.(result, options, theme, context),
97
- );
98
- }
99
204
  const currentRenderResult = getCurrentDefinition().renderResult;
100
- return currentRenderResult ? currentRenderResult(result, options, theme, context) : new Text("", 0, 0);
205
+ const renderBase = () => currentRenderResult?.(result, options, theme, context) ?? emptyText();
206
+ const isReplayCall = typeof context.toolCallId === "string" && isCursorReplayToolCallId(context.toolCallId);
207
+ if (isReplayCall && strategy?.renderReplayResult) {
208
+ return strategy.renderReplayResult(result, options, theme, context, renderBase) as ReturnType<NonNullable<ToolDefinition<TParams, TDetails, TState>["renderResult"]>>;
209
+ }
210
+ return renderBase();
101
211
  },
102
212
  };
103
213
  }
104
214
 
105
215
  export function createNativeCursorToolDefinition(toolName: NativeCursorToolName, cwd: string): ToolDefinition<TSchema, unknown, unknown> {
106
- if (toolName === "read") return createReadToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
107
- if (toolName === "bash") return createBashToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
108
- if (toolName === "edit") return createEditToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
109
- if (toolName === "write") return createWriteToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
110
- if (toolName === "grep") return createGrepToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
111
- if (toolName === "find") return createFindToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
112
- if (toolName === "ls") return createLsToolDefinition(cwd) as ToolDefinition<TSchema, unknown, unknown>;
216
+ const strategy = getNativeReplayStrategy(toolName);
217
+ if (strategy) return strategy.createDefinition(cwd);
113
218
  if (isCursorReplayToolName(toolName)) return createCursorReplayOnlyToolDefinition(toolName) as ToolDefinition<TSchema, unknown, unknown>;
114
219
  throw new Error(`Unsupported Cursor native replay tool: ${toolName}`);
115
220
  }
116
221
 
117
- export function registerNativeCursorTool(pi: Pick<import("@earendil-works/pi-coding-agent").ExtensionAPI, "registerTool">, toolName: NativeCursorToolName): void {
222
+ export function registerNativeCursorTool(
223
+ pi: Pick<import("@earendil-works/pi-coding-agent").ExtensionAPI, "registerTool">,
224
+ toolName: NativeCursorToolName,
225
+ ): void {
118
226
  const definition = createNativeCursorToolDefinition(toolName, getCursorSessionCwd());
119
227
  pi.registerTool(wrapNativeCursorTool(definition, () => createNativeCursorToolDefinition(toolName, getCursorSessionCwd())));
120
228
  }
@@ -22,7 +22,7 @@ import { type CursorPiBridgeToolRequest } from "./cursor-pi-tool-bridge.js";
22
22
  import { resetSessionCursorAgent } from "./cursor-session-agent.js";
23
23
  import { applyCursorApproximateUsage } from "./cursor-usage-accounting.js";
24
24
  import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
25
- import { hasUsableText } from "./cursor-record-utils.js";
25
+ import { trimCurrentTurnAlreadyEmittedCursorText } from "./cursor-run-final-text.js";
26
26
  import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./cursor-provider-errors.js";
27
27
  import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
28
28
  import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
@@ -152,57 +152,6 @@ function emitCursorLiveQueuedEvent(
152
152
  }
153
153
  }
154
154
 
155
- function isCursorTextBoundary(text: string, index: number): boolean {
156
- if (index <= 0 || index >= text.length) return true;
157
- const before = text[index - 1];
158
- const after = text[index];
159
- return !/[\p{L}\p{N}_]/u.test(before) || !/[\p{L}\p{N}_]/u.test(after);
160
- }
161
-
162
- function trimAlreadyEmittedCursorText(text: string, emittedText: string, options?: { allowPartialPrefix?: boolean }): string {
163
- if (!text || !emittedText) return text;
164
- if (text === emittedText) return "";
165
- if (text.startsWith(emittedText) && (options?.allowPartialPrefix || isCursorTextBoundary(text, emittedText.length))) {
166
- return text.slice(emittedText.length);
167
- }
168
- if (emittedText.endsWith(text) && isCursorTextBoundary(emittedText, emittedText.length - text.length)) return "";
169
- const trimmedText = text.trim();
170
- const trimmedEmittedText = emittedText.trim();
171
- if (trimmedText === trimmedEmittedText) return "";
172
- if (trimmedText && trimmedEmittedText.endsWith(trimmedText)) {
173
- const suffixStart = trimmedEmittedText.length - trimmedText.length;
174
- if (isCursorTextBoundary(trimmedEmittedText, suffixStart)) return "";
175
- }
176
- return text;
177
- }
178
-
179
- function trimCurrentTurnAlreadyEmittedCursorText(text: string, currentTurnEmittedText: string, emittedText = currentTurnEmittedText): string {
180
- if (!currentTurnEmittedText) return trimAlreadyEmittedCursorText(text, emittedText);
181
- const currentTurnTrimmedText = trimAlreadyEmittedCursorText(text, currentTurnEmittedText, { allowPartialPrefix: true });
182
- if (currentTurnTrimmedText !== text) return currentTurnTrimmedText;
183
- if (emittedText.endsWith(currentTurnEmittedText)) {
184
- const emittedTextTrimmedText = trimAlreadyEmittedCursorText(text, emittedText, { allowPartialPrefix: true });
185
- if (emittedTextTrimmedText !== text) return emittedTextTrimmedText;
186
- }
187
- return trimAlreadyEmittedCursorText(text, emittedText);
188
- }
189
-
190
- export function selectCursorFinalText(
191
- resultText: unknown,
192
- textDeltas: readonly string[],
193
- emittedText: string,
194
- fallbackText?: string,
195
- options?: { allowPartialPrefix?: boolean },
196
- ): string {
197
- const candidates = [typeof resultText === "string" ? resultText : undefined, fallbackText, textDeltas.join("")];
198
- for (const candidate of candidates) {
199
- if (!hasUsableText(candidate)) continue;
200
- const trimmedCandidate = trimAlreadyEmittedCursorText(candidate, emittedText, options);
201
- if (hasUsableText(trimmedCandidate)) return trimmedCandidate;
202
- }
203
- return "";
204
- }
205
-
206
155
  function emitCursorNativeToolUseTurn(
207
156
  stream: AssistantMessageEventStream,
208
157
  partial: AssistantMessage,
@@ -0,0 +1,235 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
3
+ import { abandonSessionCursorAgent } from "./cursor-provider-live-run-drain.js";
4
+ import {
5
+ classifyCursorRunEmission,
6
+ getCursorRunAbortMessage,
7
+ type CursorRunOutcome,
8
+ } from "./cursor-provider-run-outcome.js";
9
+ import {
10
+ formatCursorSdkAbortMessage,
11
+ resolveCursorSdkAbortCause,
12
+ sanitizeCursorProviderError,
13
+ } from "./cursor-provider-errors.js";
14
+ import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
15
+ import type { IncompleteCursorToolRunOutcomeInput } from "./cursor-incomplete-tool-visibility.js";
16
+ import type { installCursorSdkAbortErrorSuppression } from "./cursor-sdk-abort-error-guard.js";
17
+ import type { CursorSdkEventDebugSink } from "./cursor-sdk-event-debug.js";
18
+ import { awaitFinalizeCursorRunOutcome } from "./cursor-provider-turn-finalize.js";
19
+ import type {
20
+ CursorProviderTurnPrepareResult,
21
+ CursorProviderTurnRunnerParams,
22
+ CursorProviderTurnSend,
23
+ CursorProviderTurnSendResult,
24
+ } from "./cursor-provider-turn-types.js";
25
+ import { applyCursorApproximateUsage } from "./cursor-usage-accounting.js";
26
+ import { hasUsableText } from "./cursor-record-utils.js";
27
+ import { buildIncompleteCursorToolRunOutcome } from "./cursor-incomplete-tool-visibility.js";
28
+
29
+ export type CursorTurnTerminalEvent =
30
+ | {
31
+ kind: "direct";
32
+ prepared: CursorProviderTurnPrepareResult;
33
+ outcome: CursorRunOutcome;
34
+ }
35
+ | { kind: "error"; prepared: CursorProviderTurnPrepareResult | undefined; error: unknown };
36
+
37
+ function applyLiveRunOutcome(
38
+ outcome: CursorRunOutcome,
39
+ prepared: CursorProviderTurnPrepareResult,
40
+ context: CursorProviderTurnRunnerParams["context"],
41
+ ): void {
42
+ if (prepared.runtime.kind !== "live" || prepared.runtime.liveRun.disposed) return;
43
+ const { liveRun } = prepared.runtime;
44
+ switch (classifyCursorRunEmission(outcome)) {
45
+ case "finished":
46
+ prepared.sessionAgentLease.commitSend(context, prepared.meta.bootstrap);
47
+ cursorLiveRuns.markFinished(liveRun, outcome.kind === "finished" ? outcome.finalText : "");
48
+ break;
49
+ case "cancelled":
50
+ cursorLiveRuns.markCancelled(liveRun, getCursorRunAbortMessage(outcome));
51
+ break;
52
+ case "failed":
53
+ cursorLiveRuns.markError(liveRun, outcome.kind === "error" ? outcome.errorMessage : "Cursor SDK run failed.");
54
+ break;
55
+ }
56
+ }
57
+
58
+ export interface CursorLiveRunCompletion {
59
+ waitCompletion: Promise<void>;
60
+ prepared: CursorProviderTurnPrepareResult;
61
+ }
62
+
63
+ export interface CursorRunFinalizerParams {
64
+ runnerParams: CursorProviderTurnRunnerParams;
65
+ sdkEventDebug: () => CursorSdkEventDebugSink | undefined;
66
+ sdkAbortErrorSuppression: ReturnType<typeof installCursorSdkAbortErrorSuppression>;
67
+ resolvedApiKey: () => string | undefined;
68
+ }
69
+
70
+ export interface StartCursorLiveRunCompletionParams {
71
+ send: CursorProviderTurnSend;
72
+ prepared: CursorProviderTurnPrepareResult;
73
+ modelId: string;
74
+ discardIncompleteTools: (outcome: IncompleteCursorToolRunOutcomeInput) => void;
75
+ }
76
+
77
+ export class CursorRunFinalizer {
78
+ private terminalApplied = false;
79
+
80
+ constructor(private readonly params: CursorRunFinalizerParams) {}
81
+
82
+ startLiveRunCompletion(startParams: StartCursorLiveRunCompletionParams): CursorLiveRunCompletion {
83
+ const { runnerParams } = this.params;
84
+ const sdkEventDebug = this.params.sdkEventDebug();
85
+ const { send, prepared, modelId, discardIncompleteTools } = startParams;
86
+ const { run, cursorAgentMessageOffset } = send;
87
+ if (prepared.runtime.kind !== "live") throw new Error("startLiveRunCompletion requires a live run");
88
+ const { liveRun } = prepared.runtime;
89
+ const waitCompletion = awaitFinalizeCursorRunOutcome({
90
+ run,
91
+ prepared,
92
+ cursorAgentMessageOffset,
93
+ modelId,
94
+ signal: runnerParams.options?.signal,
95
+ runResultFallback: run.result,
96
+ resolvedApiKey: this.params.resolvedApiKey(),
97
+ optionsApiKey: runnerParams.options?.apiKey,
98
+ sdkEventDebug,
99
+ cacheContextWindow: true,
100
+ contextWindowAgentId: liveRun.agent.agentId,
101
+ })
102
+ .then(async (outcome) => {
103
+ applyLiveRunOutcome(outcome, prepared, runnerParams.context);
104
+ })
105
+ .catch(async (error: unknown) => {
106
+ sdkEventDebug?.recordWaitResult({ status: "error", error: String(error) });
107
+ sdkEventDebug?.recordError("run_wait", error);
108
+ discardIncompleteTools({ status: "error" });
109
+ await sdkEventDebug?.captureRunArtifacts(run);
110
+ if (liveRun.disposed) return;
111
+ cursorLiveRuns.markError(
112
+ liveRun,
113
+ sanitizeCursorProviderError(error, this.params.resolvedApiKey() ?? runnerParams.options?.apiKey),
114
+ );
115
+ });
116
+ return { waitCompletion, prepared };
117
+ }
118
+
119
+ async applyTerminalEvent(event: CursorTurnTerminalEvent): Promise<void> {
120
+ if (this.terminalApplied) return;
121
+ if (event.kind === "direct") {
122
+ await this.applyDirectOutcome(event.prepared, event.outcome);
123
+ this.terminalApplied = true;
124
+ return;
125
+ }
126
+ await this.applyErrorOutcome(event.prepared, event.error);
127
+ this.terminalApplied = true;
128
+ }
129
+
130
+ async cleanup(
131
+ prepared: CursorProviderTurnPrepareResult | undefined,
132
+ sendResult: CursorProviderTurnSendResult | undefined,
133
+ liveCompletion: CursorLiveRunCompletion | undefined,
134
+ ): Promise<void> {
135
+ this.safeCleanup(() => prepared?.restoreCursorSdkOutputFilter());
136
+ const abortRegistration = sendResult?.abortRegistration;
137
+ if (abortRegistration) {
138
+ this.safeCleanup(() => abortRegistration.signal.removeEventListener("abort", abortRegistration.listener));
139
+ }
140
+ this.params.runnerParams.sdkEventDebugRef.current = undefined;
141
+ if (liveCompletion) {
142
+ this.safeCleanup(() => liveCompletion.prepared.sessionAgentLease.trackRunCompletion(liveCompletion.waitCompletion));
143
+ void liveCompletion.waitCompletion
144
+ .finally(async () => {
145
+ await this.finalizeSdkEventDebugBestEffort();
146
+ this.safeCleanup(() => this.params.sdkAbortErrorSuppression.dispose());
147
+ })
148
+ .catch(() => {});
149
+ return;
150
+ }
151
+ await this.finalizeSdkEventDebugBestEffort();
152
+ this.safeCleanup(() => this.params.sdkAbortErrorSuppression.dispose());
153
+ }
154
+
155
+ private async applyDirectOutcome(
156
+ prepared: CursorProviderTurnPrepareResult,
157
+ outcome: CursorRunOutcome,
158
+ ): Promise<void> {
159
+ const { stream, partial, model, context } = this.params.runnerParams;
160
+ prepared.runtime.turnCoordinator.closeTraceBlock();
161
+ switch (classifyCursorRunEmission(outcome)) {
162
+ case "cancelled":
163
+ await abandonSessionCursorAgent(prepared.sessionAgentScopeKey);
164
+ this.pushTerminalError(partial, "aborted", getCursorRunAbortMessage(outcome));
165
+ break;
166
+ case "failed":
167
+ await abandonSessionCursorAgent(prepared.sessionAgentScopeKey);
168
+ this.pushTerminalError(partial, "error", outcome.kind === "error" ? outcome.errorMessage : "Cursor SDK run failed.");
169
+ break;
170
+ case "finished":
171
+ prepared.sessionAgentLease.commitSend(context, prepared.meta.bootstrap);
172
+ prepared.runtime.turnCoordinator.flushText(
173
+ outcome.kind === "finished" && hasUsableText(outcome.finalText) ? [outcome.finalText] : [],
174
+ );
175
+ applyCursorApproximateUsage(partial, model, context, prepared.meta.promptInputTokens);
176
+ stream.push({ type: "done", reason: "stop", message: partial });
177
+ break;
178
+ }
179
+ }
180
+
181
+ private async applyErrorOutcome(prepared: CursorProviderTurnPrepareResult | undefined, error: unknown): Promise<void> {
182
+ this.params.sdkEventDebug()?.recordError("provider_stream", error);
183
+ prepared?.runtime.turnCoordinator.discardIncompleteStartedToolCalls(
184
+ buildIncompleteCursorToolRunOutcome({
185
+ status: error instanceof CursorLiveRunAbortError ? "cancelled" : "error",
186
+ signalAborted: error instanceof CursorLiveRunAbortError,
187
+ }),
188
+ );
189
+ const activeLiveRun = prepared?.runtime.liveRun;
190
+ if (activeLiveRun && !activeLiveRun.disposed) {
191
+ await cursorLiveRuns.release(activeLiveRun);
192
+ } else {
193
+ await abandonSessionCursorAgent(prepared?.sessionAgentScopeKey);
194
+ }
195
+ if (error instanceof CursorLiveRunAbortError) {
196
+ this.params.sdkAbortErrorSuppression.suppressAbortErrors();
197
+ this.pushTerminalError(this.params.runnerParams.partial, "aborted", this.abortMessage());
198
+ } else {
199
+ this.pushTerminalError(
200
+ this.params.runnerParams.partial,
201
+ "error",
202
+ sanitizeCursorProviderError(error, this.params.resolvedApiKey() ?? this.params.runnerParams.options?.apiKey),
203
+ );
204
+ }
205
+ }
206
+
207
+ private pushTerminalError(partial: AssistantMessage, reason: "error" | "aborted", message: string): void {
208
+ partial.stopReason = reason;
209
+ partial.errorMessage = message;
210
+ this.params.runnerParams.stream.push({ type: "error", reason, error: partial });
211
+ }
212
+
213
+ private abortMessage(): string {
214
+ return formatCursorSdkAbortMessage(
215
+ resolveCursorSdkAbortCause({ signalAborted: this.params.runnerParams.options?.signal?.aborted }),
216
+ );
217
+ }
218
+
219
+ private safeCleanup(cleanup: () => void): void {
220
+ try {
221
+ cleanup();
222
+ } catch {
223
+ // Cleanup must not reclassify an already-emitted provider turn.
224
+ }
225
+ }
226
+
227
+ private async finalizeSdkEventDebugBestEffort(): Promise<void> {
228
+ try {
229
+ this.params.sdkEventDebug()?.recordFinalPartial(this.params.runnerParams.partial);
230
+ await this.params.sdkEventDebug()?.finalize();
231
+ } catch {
232
+ // Debug artifact IO is best-effort and must not emit a second terminal event.
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,149 @@
1
+ import type { RunResult } from "@cursor/sdk";
2
+ import { selectCursorFinalText } from "./cursor-run-final-text.js";
3
+ import {
4
+ formatCursorSdkAbortMessage,
5
+ formatCursorSdkRunFailureDetail,
6
+ resolveCursorSdkAbortCause,
7
+ sanitizeCursorProviderError,
8
+ } from "./cursor-provider-errors.js";
9
+ import { hasUsableText } from "./cursor-record-utils.js";
10
+ import {
11
+ buildIncompleteCursorToolRunOutcome,
12
+ type IncompleteCursorToolRunOutcome,
13
+ type IncompleteCursorToolRunOutcomeInput,
14
+ } from "./cursor-incomplete-tool-visibility.js";
15
+
16
+ /** Unified SDK wait() facts consumed by live and direct emission strategies. */
17
+ export type CursorRunOutcome =
18
+ | {
19
+ kind: "finished";
20
+ waitResult: RunResult;
21
+ finalText: string;
22
+ incompleteTools: IncompleteCursorToolRunOutcome;
23
+ assistantTextProduced: boolean;
24
+ }
25
+ | {
26
+ kind: "cancelled";
27
+ waitResult: RunResult;
28
+ incompleteTools: IncompleteCursorToolRunOutcome;
29
+ abortMessage: string;
30
+ }
31
+ | {
32
+ kind: "error";
33
+ waitResult: RunResult;
34
+ incompleteTools: IncompleteCursorToolRunOutcome;
35
+ errorMessage: string;
36
+ };
37
+
38
+ export interface ResolveCursorRunOutcomeParams {
39
+ waitResult: RunResult;
40
+ signalAborted?: boolean;
41
+ textDeltas: readonly string[];
42
+ emittedText: string;
43
+ planTextCandidate?: string;
44
+ selectFinalTextOptions?: { allowPartialPrefix?: boolean };
45
+ runResultFallback?: string;
46
+ resolvedApiKey?: string;
47
+ optionsApiKey?: string;
48
+ }
49
+
50
+ function hasCursorAssistantText(
51
+ resultText: unknown,
52
+ textDeltas: readonly string[],
53
+ fallbackText?: string,
54
+ ): boolean {
55
+ return (
56
+ hasUsableText(typeof resultText === "string" ? resultText : undefined) ||
57
+ hasUsableText(textDeltas.join("")) ||
58
+ hasUsableText(fallbackText)
59
+ );
60
+ }
61
+
62
+ export function isCursorRunFinishedSuccessfully(outcome: CursorRunOutcome): boolean {
63
+ return outcome.kind === "finished";
64
+ }
65
+
66
+ function buildCursorRunAbortMessage(signalAborted: boolean | undefined, sdkStatusCancelled: boolean): string {
67
+ return formatCursorSdkAbortMessage(
68
+ resolveCursorSdkAbortCause({
69
+ signalAborted,
70
+ sdkStatusCancelled,
71
+ }),
72
+ );
73
+ }
74
+
75
+ export function resolveCursorRunOutcome(params: ResolveCursorRunOutcomeParams): CursorRunOutcome {
76
+ const { waitResult, signalAborted } = params;
77
+ const sdkCancelled = waitResult.status === "cancelled";
78
+ const callerAborted = signalAborted === true;
79
+
80
+ if (callerAborted || sdkCancelled) {
81
+ const incompleteTools = buildIncompleteCursorToolRunOutcome({
82
+ status: "cancelled",
83
+ signalAborted: callerAborted,
84
+ assistantTextProduced: false,
85
+ });
86
+ return {
87
+ kind: "cancelled",
88
+ waitResult,
89
+ incompleteTools,
90
+ abortMessage: buildCursorRunAbortMessage(callerAborted, sdkCancelled),
91
+ };
92
+ }
93
+
94
+ if (waitResult.status === "error") {
95
+ const failureDetail = formatCursorSdkRunFailureDetail(waitResult, params.runResultFallback);
96
+ return {
97
+ kind: "error",
98
+ waitResult,
99
+ incompleteTools: buildIncompleteCursorToolRunOutcome({
100
+ status: "error",
101
+ assistantTextProduced: false,
102
+ }),
103
+ errorMessage: sanitizeCursorProviderError(failureDetail, params.resolvedApiKey ?? params.optionsApiKey),
104
+ };
105
+ }
106
+
107
+ const assistantTextProduced = hasCursorAssistantText(
108
+ waitResult.result,
109
+ params.textDeltas,
110
+ params.planTextCandidate,
111
+ );
112
+ const incompleteTools = buildIncompleteCursorToolRunOutcome({
113
+ status: waitResult.status,
114
+ assistantTextProduced,
115
+ });
116
+ const finalText = selectCursorFinalText(
117
+ waitResult.result,
118
+ params.textDeltas,
119
+ params.emittedText,
120
+ params.planTextCandidate,
121
+ params.selectFinalTextOptions,
122
+ );
123
+
124
+ return {
125
+ kind: "finished",
126
+ waitResult,
127
+ finalText,
128
+ incompleteTools,
129
+ assistantTextProduced,
130
+ };
131
+ }
132
+
133
+ export type CursorRunEmission = "finished" | "cancelled" | "failed";
134
+
135
+ export function classifyCursorRunEmission(outcome: CursorRunOutcome): CursorRunEmission {
136
+ switch (outcome.kind) {
137
+ case "finished":
138
+ return "finished";
139
+ case "cancelled":
140
+ return "cancelled";
141
+ case "error":
142
+ return "failed";
143
+ }
144
+ }
145
+
146
+ export function getCursorRunAbortMessage(outcome: CursorRunOutcome): string {
147
+ if (outcome.kind === "cancelled") return outcome.abortMessage;
148
+ return buildCursorRunAbortMessage(false, outcome.waitResult.status === "cancelled");
149
+ }