pi-cursor-sdk 0.1.27 → 0.1.29

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 (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +40 -37
  3. package/docs/crabbox-platform-testing-lessons.md +508 -0
  4. package/docs/cursor-dogfood-checklist.md +4 -3
  5. package/docs/cursor-live-smoke-checklist.md +24 -22
  6. package/docs/cursor-model-ux-spec.md +12 -12
  7. package/docs/cursor-native-tool-replay.md +10 -10
  8. package/docs/cursor-native-tool-visual-audit.md +9 -7
  9. package/docs/cursor-testing-lessons.md +22 -17
  10. package/docs/cursor-tool-surfaces.md +3 -3
  11. package/docs/platform-smoke.md +994 -0
  12. package/package.json +35 -6
  13. package/platform-smoke.config.mjs +21 -0
  14. package/scripts/debug-provider-events.mjs +10 -3
  15. package/scripts/debug-sdk-events.mjs +10 -2
  16. package/scripts/isolated-cursor-smoke.sh +4 -4
  17. package/scripts/lib/cursor-visual-render.mjs +1 -0
  18. package/scripts/platform-smoke/artifacts.mjs +124 -0
  19. package/scripts/platform-smoke/assertions.mjs +101 -0
  20. package/scripts/platform-smoke/card-detect.mjs +96 -0
  21. package/scripts/platform-smoke/crabbox-runner.mjs +215 -0
  22. package/scripts/platform-smoke/doctor.mjs +446 -0
  23. package/scripts/platform-smoke/jsonl-text.mjs +31 -0
  24. package/scripts/platform-smoke/live-suite-runner.mjs +677 -0
  25. package/scripts/platform-smoke/platform-build-windows.ps1 +187 -0
  26. package/scripts/platform-smoke/pty-capture.mjs +131 -0
  27. package/scripts/platform-smoke/render-ansi.mjs +65 -0
  28. package/scripts/platform-smoke/scenarios.mjs +186 -0
  29. package/scripts/platform-smoke/targets.mjs +900 -0
  30. package/scripts/platform-smoke/visual-evidence.mjs +139 -0
  31. package/scripts/platform-smoke.mjs +193 -0
  32. package/scripts/probe-mcp-coldstart.mjs +8 -1
  33. package/scripts/steering-rpc-smoke.mjs +1 -1
  34. package/scripts/tmux-live-smoke.sh +3 -3
  35. package/scripts/visual-tui-smoke.mjs +1 -1
  36. package/src/cursor-pi-tool-bridge-abort.ts +1 -0
  37. package/src/cursor-pi-tool-bridge-diagnostics.ts +12 -1
  38. package/src/cursor-pi-tool-bridge.ts +46 -1
  39. package/src/cursor-provider-errors.ts +18 -2
  40. package/src/cursor-provider-turn-lifecycle-emitter.ts +65 -8
  41. package/src/cursor-provider-turn-tool-ledger.ts +2 -3
  42. package/src/cursor-run-final-text.ts +11 -1
  43. package/src/cursor-sdk-process-error-guard.ts +1 -1
  44. package/src/cursor-state.ts +38 -19
  45. package/src/cursor-tool-lifecycle.ts +1 -1
  46. package/src/cursor-tool-manifest.ts +1 -1
  47. package/src/cursor-transcript-utils.ts +7 -3
@@ -9,6 +9,7 @@ import {
9
9
  isCursorToolLifecycleEligible,
10
10
  } from "./cursor-tool-lifecycle.js";
11
11
  import { classifyCursorToolVisibility } from "./cursor-tool-visibility.js";
12
+ import { getStartedToolCallFingerprint } from "./cursor-provider-turn-tool-ledger.js";
12
13
 
13
14
  function getNormalizedCursorToolName(toolCall: unknown): string {
14
15
  return classifyCursorToolVisibility(toolCall).normalizedName;
@@ -32,6 +33,10 @@ export class CursorToolLifecycleEmitter {
32
33
  private readonly isBridgeMcpToolCall: (toolCall: unknown) => boolean;
33
34
  private readonly emittedLifecycleCallIds = new Set<string>();
34
35
  private readonly lifecycleTimers = new Map<string, ReturnType<typeof setTimeout>>();
36
+ private readonly activeLifecycleFingerprintOwners = new Map<string, string>();
37
+ private readonly lifecycleFingerprintByCallId = new Map<string, string>();
38
+ private readonly activeLifecycleProgressTextOwners = new Map<string, string>();
39
+ private readonly lifecycleProgressTextByCallId = new Map<string, string>();
35
40
 
36
41
  constructor(options: CursorToolLifecycleEmitterOptions) {
37
42
  this.liveRun = options.liveRun;
@@ -47,12 +52,47 @@ export class CursorToolLifecycleEmitter {
47
52
  if (this.isBridgeMcpToolCall(toolCall)) return;
48
53
  if (!isCursorToolLifecycleEligible(toolCall)) return;
49
54
 
55
+ const progressText = formatCursorToolLifecycleProgressText(toolCall, this.resolvedApiKey);
56
+ if (!progressText) return;
57
+
58
+ const fingerprint = getStartedToolCallFingerprint(toolCall);
59
+ const existingOwner = this.activeLifecycleFingerprintOwners.get(fingerprint);
60
+ if (existingOwner && existingOwner !== callId) {
61
+ this.debugRecorder?.recordCoordinatorEvent("tool_lifecycle_skip", {
62
+ callId,
63
+ ownerCallId: existingOwner,
64
+ toolName: getNormalizedCursorToolName(toolCall),
65
+ reason: "duplicate-active-fingerprint",
66
+ });
67
+ return;
68
+ }
69
+
50
70
  this.cancel(callId);
71
+ this.activeLifecycleFingerprintOwners.set(fingerprint, callId);
72
+ this.lifecycleFingerprintByCallId.set(callId, fingerprint);
73
+ if (!this.activeLifecycleProgressTextOwners.has(progressText)) {
74
+ this.activeLifecycleProgressTextOwners.set(progressText, callId);
75
+ }
76
+ this.lifecycleProgressTextByCallId.set(callId, progressText);
51
77
  const timer = setTimeout(() => {
52
78
  this.lifecycleTimers.delete(callId);
53
- if (!this.hasStartedToolCall(callId)) return;
79
+ if (!this.hasStartedToolCall(callId)) {
80
+ this.clearLifecycleIdentity(callId);
81
+ return;
82
+ }
54
83
  if (this.emittedLifecycleCallIds.has(callId)) return;
55
- this.emit(callId, toolCall);
84
+ const progressOwner = this.activeLifecycleProgressTextOwners.get(progressText);
85
+ if (progressOwner && progressOwner !== callId && this.hasStartedToolCall(progressOwner)) {
86
+ this.debugRecorder?.recordCoordinatorEvent("tool_lifecycle_skip", {
87
+ callId,
88
+ ownerCallId: progressOwner,
89
+ toolName: getNormalizedCursorToolName(toolCall),
90
+ reason: "duplicate-active-progress-text",
91
+ });
92
+ return;
93
+ }
94
+ this.activeLifecycleProgressTextOwners.set(progressText, callId);
95
+ this.emit(callId, toolCall, progressText);
56
96
  }, CURSOR_TOOL_LIFECYCLE_DEFER_MS);
57
97
  timer.unref?.();
58
98
  this.lifecycleTimers.set(callId, timer);
@@ -60,20 +100,37 @@ export class CursorToolLifecycleEmitter {
60
100
 
61
101
  cancel(callId: string): void {
62
102
  const timer = this.lifecycleTimers.get(callId);
63
- if (!timer) return;
64
- clearTimeout(timer);
65
- this.lifecycleTimers.delete(callId);
103
+ if (timer) {
104
+ clearTimeout(timer);
105
+ this.lifecycleTimers.delete(callId);
106
+ }
107
+ this.clearLifecycleIdentity(callId);
66
108
  }
67
109
 
68
110
  clear(): void {
69
111
  this.emittedLifecycleCallIds.clear();
70
112
  for (const timer of this.lifecycleTimers.values()) clearTimeout(timer);
71
113
  this.lifecycleTimers.clear();
114
+ this.activeLifecycleFingerprintOwners.clear();
115
+ this.lifecycleFingerprintByCallId.clear();
116
+ this.activeLifecycleProgressTextOwners.clear();
117
+ this.lifecycleProgressTextByCallId.clear();
72
118
  }
73
119
 
74
- private emit(callId: string, toolCall: unknown): void {
75
- const progressText = formatCursorToolLifecycleProgressText(toolCall, this.resolvedApiKey);
76
- if (!progressText) return;
120
+ private clearLifecycleIdentity(callId: string): void {
121
+ const fingerprint = this.lifecycleFingerprintByCallId.get(callId);
122
+ if (fingerprint && this.activeLifecycleFingerprintOwners.get(fingerprint) === callId) {
123
+ this.activeLifecycleFingerprintOwners.delete(fingerprint);
124
+ }
125
+ this.lifecycleFingerprintByCallId.delete(callId);
126
+ const progressText = this.lifecycleProgressTextByCallId.get(callId);
127
+ if (progressText && this.activeLifecycleProgressTextOwners.get(progressText) === callId) {
128
+ this.activeLifecycleProgressTextOwners.delete(progressText);
129
+ }
130
+ this.lifecycleProgressTextByCallId.delete(callId);
131
+ }
132
+
133
+ private emit(callId: string, toolCall: unknown, progressText: string): void {
77
134
  this.emittedLifecycleCallIds.add(callId);
78
135
  this.debugRecorder?.recordCoordinatorEvent("tool_lifecycle", {
79
136
  callId,
@@ -1,5 +1,4 @@
1
- import { getField } from "./cursor-record-utils.js";
2
- import { getToolName } from "./cursor-transcript-utils.js";
1
+ import { getToolArgs, getToolName } from "./cursor-transcript-utils.js";
3
2
 
4
3
  export type CursorToolDisplaySource = "started" | "fallback" | "transcript";
5
4
 
@@ -122,5 +121,5 @@ export function getToolFingerprint(value: unknown): string {
122
121
  }
123
122
 
124
123
  export function getStartedToolCallFingerprint(toolCall: unknown): string {
125
- return getToolFingerprint({ toolName: getToolName(toolCall), args: getField(toolCall, "args") });
124
+ return getToolFingerprint({ toolName: getToolName(toolCall), args: getToolArgs(toolCall) });
126
125
  }
@@ -1,4 +1,5 @@
1
- import { hasUsableText } from "./cursor-record-utils.js";
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import { asRecord, hasUsableText } from "./cursor-record-utils.js";
2
3
 
3
4
  function isCursorTextBoundary(text: string, index: number): boolean {
4
5
  if (index <= 0 || index >= text.length) return true;
@@ -39,6 +40,15 @@ export function trimCurrentTurnAlreadyEmittedCursorText(
39
40
  return trimAlreadyEmittedCursorText(text, emittedText);
40
41
  }
41
42
 
43
+ export function getFinalAssistantText(message: Pick<AssistantMessage, "content">): string {
44
+ for (let index = message.content.length - 1; index >= 0; index--) {
45
+ const block = asRecord(message.content[index]);
46
+ if (block?.type !== "text" || typeof block.text !== "string") continue;
47
+ if (hasUsableText(block.text)) return block.text;
48
+ }
49
+ return "";
50
+ }
51
+
42
52
  export function selectCursorFinalText(
43
53
  resultText: unknown,
44
54
  textDeltas: readonly string[],
@@ -26,7 +26,7 @@ function hasActiveAbortSuppression(): boolean {
26
26
  }
27
27
 
28
28
  function isCursorProvenance(source: string): boolean {
29
- return source === "cursor-sdk-stack" || source === "cursor-backend-details";
29
+ return source === "cursor-sdk-stack" || source === "cursor-extension-connect-stack" || source === "cursor-backend-details";
30
30
  }
31
31
 
32
32
  function shouldSuppressProcessError(event: string | symbol, args: readonly unknown[]): boolean {
@@ -29,7 +29,8 @@ export type CursorAgentMode = AgentModeOption;
29
29
  const DEFAULT_CURSOR_AGENT_MODE: AgentModeOption = "agent";
30
30
 
31
31
  interface CursorFastEntryData {
32
- baseModelId: string;
32
+ modelId?: string;
33
+ baseModelId?: string;
33
34
  fast: boolean;
34
35
  }
35
36
 
@@ -71,7 +72,11 @@ export function parseCursorAgentMode(raw: unknown): AgentModeOption | undefined
71
72
  function isCursorFastEntryData(value: unknown): value is CursorFastEntryData {
72
73
  if (!value || typeof value !== "object") return false;
73
74
  const data = value as Record<string, unknown>;
74
- return typeof data.baseModelId === "string" && typeof data.fast === "boolean";
75
+ return (typeof data.modelId === "string" || typeof data.baseModelId === "string") && typeof data.fast === "boolean";
76
+ }
77
+
78
+ function getCursorFastEntryModelId(data: CursorFastEntryData): string {
79
+ return data.modelId ?? data.baseModelId ?? "";
75
80
  }
76
81
 
77
82
  function isCursorModeEntryData(value: unknown): value is CursorModeEntryData {
@@ -113,7 +118,8 @@ function restoreSessionFastPreferences(ctx: { sessionManager: Pick<ExtensionCont
113
118
  for (const entry of ctx.sessionManager.getBranch()) {
114
119
  if (entry.type !== "custom" || entry.customType !== FAST_ENTRY_TYPE) continue;
115
120
  if (isCursorFastEntryData(entry.data)) {
116
- sessionFastPreferences.set(entry.data.baseModelId, entry.data.fast);
121
+ const modelId = getCursorFastEntryModelId(entry.data);
122
+ if (modelId) sessionFastPreferences.set(modelId, entry.data.fast);
117
123
  }
118
124
  }
119
125
  }
@@ -128,12 +134,26 @@ function restoreSessionCursorMode(ctx: { sessionManager: Pick<ExtensionContext["
128
134
  }
129
135
  }
130
136
 
131
- function getEffectiveFast(baseModelId: string, modelId: string): boolean | undefined {
137
+ function getFastPreferenceModelId(metadata: NonNullable<ReturnType<typeof getCursorModelMetadata>>): string {
138
+ return metadata.selectionModelId || metadata.baseModelId;
139
+ }
140
+
141
+ function getStoredFastPreference(metadata: NonNullable<ReturnType<typeof getCursorModelMetadata>>): boolean | undefined {
142
+ const preferenceModelId = getFastPreferenceModelId(metadata);
143
+ return (
144
+ sessionFastPreferences.get(preferenceModelId) ??
145
+ (preferenceModelId !== metadata.baseModelId ? sessionFastPreferences.get(metadata.baseModelId) : undefined) ??
146
+ globalFastPreferences.get(preferenceModelId) ??
147
+ (preferenceModelId !== metadata.baseModelId ? globalFastPreferences.get(metadata.baseModelId) : undefined)
148
+ );
149
+ }
150
+
151
+ function getEffectiveFast(modelId: string): boolean | undefined {
132
152
  const metadata = getCursorModelMetadata(modelId);
133
153
  if (!metadata?.supportsFast) return undefined;
134
154
  if (cliForceNoFast) return false;
135
155
  if (cliForceFast) return true;
136
- return sessionFastPreferences.get(baseModelId) ?? globalFastPreferences.get(baseModelId) ?? metadata.defaultFast;
156
+ return getStoredFastPreference(metadata) ?? metadata.defaultFast;
137
157
  }
138
158
 
139
159
  function formatInvalidCursorMode(raw: string): string {
@@ -168,7 +188,7 @@ function updateCursorStatus(ctx: Pick<ExtensionContext, "model" | "ui">, model =
168
188
  return;
169
189
  }
170
190
  const metadata = getCursorModelMetadata(model.id);
171
- const fast = metadata?.supportsFast ? getEffectiveFast(metadata.baseModelId, model.id) : undefined;
191
+ const fast = metadata?.supportsFast ? getEffectiveFast(model.id) : undefined;
172
192
  ctx.ui.setStatus("cursor", formatCursorStatus(fast));
173
193
  }
174
194
 
@@ -186,19 +206,19 @@ function restoreMapValue(map: Map<string, boolean>, key: string, previous: boole
186
206
  }
187
207
  }
188
208
 
189
- function persistFastPreference(pi: Pick<ExtensionAPI, "appendEntry">, baseModelId: string, fast: boolean): void {
190
- const previousSession = sessionFastPreferences.get(baseModelId);
191
- const previousGlobal = globalFastPreferences.get(baseModelId);
209
+ function persistFastPreference(pi: Pick<ExtensionAPI, "appendEntry">, modelId: string, fast: boolean): void {
210
+ const previousSession = sessionFastPreferences.get(modelId);
211
+ const previousGlobal = globalFastPreferences.get(modelId);
192
212
  let savedGlobal = false;
193
- sessionFastPreferences.set(baseModelId, fast);
194
- globalFastPreferences.set(baseModelId, fast);
213
+ sessionFastPreferences.set(modelId, fast);
214
+ globalFastPreferences.set(modelId, fast);
195
215
  try {
196
216
  saveGlobalFastPreferences();
197
217
  savedGlobal = true;
198
- pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { baseModelId, fast });
218
+ pi.appendEntry<CursorFastEntryData>(FAST_ENTRY_TYPE, { modelId, fast });
199
219
  } catch (error) {
200
- restoreMapValue(sessionFastPreferences, baseModelId, previousSession);
201
- restoreMapValue(globalFastPreferences, baseModelId, previousGlobal);
220
+ restoreMapValue(sessionFastPreferences, modelId, previousSession);
221
+ restoreMapValue(globalFastPreferences, modelId, previousGlobal);
202
222
  if (savedGlobal) {
203
223
  try {
204
224
  saveGlobalFastPreferences();
@@ -285,9 +305,7 @@ function emitCursorToolsDebugReport(
285
305
  }
286
306
 
287
307
  export function getEffectiveFastForModelId(modelId: string): boolean | undefined {
288
- const metadata = getCursorModelMetadata(modelId);
289
- if (!metadata) return undefined;
290
- return getEffectiveFast(metadata.baseModelId, modelId);
308
+ return getEffectiveFast(modelId);
291
309
  }
292
310
 
293
311
  export function registerCursorRuntimeControls(pi: CursorRuntimeControlsExtensionApi): void {
@@ -327,10 +345,11 @@ export function registerCursorRuntimeControls(pi: CursorRuntimeControlsExtension
327
345
  return;
328
346
  }
329
347
 
330
- const current = getEffectiveFast(metadata.baseModelId, metadata.piModelId) ?? false;
348
+ const preferenceModelId = getFastPreferenceModelId(metadata);
349
+ const current = getEffectiveFast(metadata.piModelId) ?? false;
331
350
  const next = !current;
332
351
  try {
333
- persistFastPreference(pi, metadata.baseModelId, next);
352
+ persistFastPreference(pi, preferenceModelId, next);
334
353
  } catch (error) {
335
354
  updateCursorStatus(ctx);
336
355
  ctx.ui.notify(`Failed to save Cursor fast preference: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -45,7 +45,7 @@ export function buildCursorToolLifecycleLabel(toolCall: unknown, apiKey?: string
45
45
  return scrubLifecycleDetail(getString(args, "description"), apiKey) ?? "task";
46
46
  }
47
47
  case "shell": {
48
- return "shell";
48
+ return scrubLifecycleDetail(getString(args, "command") ?? getString(args, "cmd"), apiKey);
49
49
  }
50
50
  case "mcp": {
51
51
  return scrubLifecycleDetail(getString(args, "toolName"), apiKey) ?? "mcp";
@@ -4,7 +4,7 @@ import type { CursorPiToolBridgeSnapshot } from "./cursor-pi-tool-bridge-types.j
4
4
  export const CURSOR_TOOL_MANIFEST_ENV = "PI_CURSOR_TOOL_MANIFEST";
5
5
 
6
6
  /**
7
- * Representative @cursor/sdk@1.0.16 local-agent ToolType values; actual exposure can vary by run.
7
+ * Representative @cursor/sdk@1.0.17 local-agent ToolType values; actual exposure can vary by run.
8
8
  * See docs/cursor-native-tool-replay.md#sdk-tooltype-replay-matrix.
9
9
  */
10
10
  export const CURSOR_HOST_TOOL_MANIFEST_SUMMARY =
@@ -145,14 +145,18 @@ export function formatError(error: unknown): string {
145
145
  return text ? `Error: ${text}` : "Error";
146
146
  }
147
147
 
148
+ function normalizeDisplaySeparators(path: string): string {
149
+ return path.replace(/\\/g, "/");
150
+ }
151
+
148
152
  export function formatDisplayPath(path: string, cwd = process.cwd()): string {
149
153
  const trimmed = path.trim();
150
154
  if (!trimmed) return trimmed;
151
- if (!isAbsolute(trimmed)) return trimmed;
155
+ if (!isAbsolute(trimmed)) return normalizeDisplaySeparators(trimmed);
152
156
  const relativePath = relative(cwd, trimmed);
153
157
  if (!relativePath || relativePath === "") return ".";
154
- if (relativePath.startsWith("..") || isAbsolute(relativePath)) return trimmed;
155
- return relativePath;
158
+ if (relativePath.startsWith("..") || isAbsolute(relativePath)) return normalizeDisplaySeparators(trimmed);
159
+ return normalizeDisplaySeparators(relativePath);
156
160
  }
157
161
 
158
162
  export function formatDiffPath(path: string, cwd = process.cwd()): string {