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 @@
1
+ export declare function scrubSensitiveText(text: string, apiKey?: string): string;
@@ -1,21 +1,9 @@
1
- export const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
1
+ /** Canonical secret/bridge scrubbing (parity-tested by provider runtime and maintainer scripts). */
2
2
 
3
3
  function escapeRegExp(value) {
4
4
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
5
  }
6
6
 
7
- export function resolveCursorSettingSources(raw) {
8
- const trimmed = raw?.trim();
9
- if (!trimmed) return ["all"];
10
- const normalized = trimmed.toLowerCase();
11
- if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
12
- if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
13
- return trimmed
14
- .split(",")
15
- .map((entry) => entry.trim())
16
- .filter(Boolean);
17
- }
18
-
19
7
  const BRIDGE_ENDPOINT_ROOT = "/cursor-pi-tool-bridge";
20
8
  const BRIDGE_ENDPOINT_TOKEN_PATTERN = "[^/\\s\"'<>]+";
21
9
  const BRIDGE_LOOPBACK_HOST_PATTERN = "127\\.0\\.0\\.1(?::\\d+)?";
@@ -0,0 +1,5 @@
1
+ export declare const CURSOR_SETTING_SOURCES_ENV: "PI_CURSOR_SETTING_SOURCES";
2
+
3
+ export declare function resolveCursorSettingSources(raw?: string): string[] | undefined;
4
+
5
+ export declare function serializeCursorSettingSources(settingSources: string[] | undefined): string;
@@ -0,0 +1,22 @@
1
+ /** Canonical Cursor settingSources parsing (parity-tested by provider runtime and maintainer scripts). */
2
+ export const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
3
+
4
+ export function resolveCursorSettingSources(raw) {
5
+ const trimmed = raw?.trim();
6
+ if (!trimmed) return ["all"];
7
+ const normalized = trimmed.toLowerCase();
8
+ if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
9
+ if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
10
+ const sources = trimmed
11
+ .split(",")
12
+ .map((entry) => entry.trim())
13
+ .filter(Boolean);
14
+ if (sources.length === 0) return undefined;
15
+ return sources;
16
+ }
17
+
18
+ /** Serialize parsed settingSources for PI_CURSOR_SETTING_SOURCES (undefined => explicit none). */
19
+ export function serializeCursorSettingSources(settingSources) {
20
+ if (settingSources === undefined || settingSources.length === 0) return "none";
21
+ return settingSources.join(",");
22
+ }
package/src/context.ts CHANGED
@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
2
2
  import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
3
3
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
4
4
  import type { SDKImage } from "@cursor/sdk";
5
- import { getCursorPiBridgeContractText } from "./cursor-bridge-contract.js";
6
5
  import { getCursorReplayPromptLabel } from "./cursor-tool-names.js";
7
6
 
8
7
  export interface CursorPrompt {
@@ -14,6 +13,8 @@ export interface CursorPromptOptions {
14
13
  maxInputTokens?: number;
15
14
  charsPerToken?: number;
16
15
  imageTokenEstimate?: number;
16
+ /** Compact callable-surface summary; included on bootstrap prompts when set. */
17
+ toolManifest?: string;
17
18
  }
18
19
 
19
20
  export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
@@ -21,20 +22,25 @@ export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
21
22
  const SECTION_SEPARATOR = "\n\n";
22
23
 
23
24
  export function getCursorToolTailGuardText(): string {
24
- return "Tool boundary reminder: If a tool is needed, call an available Cursor SDK/MCP tool. Never print a tool card (for example Tool call/Shell/command) as assistant text.";
25
+ return [
26
+ "Shell: use an explicit `cd` to the repo path when running project commands; session cwd may not match paths in tool args.",
27
+ "Tool boundary reminder: If a tool is needed, call an available Cursor SDK/MCP tool. Never print a tool card (for example Tool call/Shell/command) as assistant text.",
28
+ ].join("\n");
25
29
  }
26
30
 
27
- function getCursorToolBoundaryText(): string {
28
- return [
31
+ function getCursorToolBoundaryText(options: { hasToolManifest?: boolean } = {}): string {
32
+ const lines = [
29
33
  "Cursor SDK tool boundary:",
30
- "You can call only tools actually exposed by Cursor SDK in this run. Pi tool names, replay tool names, and transcript tool names are context only, not callable capabilities.",
31
- getCursorPiBridgeContractText(),
32
- "If asked to list or exercise available tools, list and exercise Cursor SDK tools only; do not claim access to pi-side tools from the system prompt unless Cursor exposes an equivalent tool that runs.",
34
+ "Call only tools exposed by Cursor SDK in this run. Pi tool names, replay labels, and transcript names are context onlynot callable.",
35
+ "Bridged pi tools: call pi__* MCP names when exposed, not the pi card name in history. Replay activity is display-only.",
36
+ "Do not claim pi-side or WebSearch/WebFetch tools unless Cursor executes an equivalent tool.",
33
37
  "Use pi__cursor_ask_question for material choices if exposed.",
34
- "Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
35
- "Replay: pi may display recorded Cursor tool activity as pi-style cards, but replay is display-only and not a capability to invoke.",
36
- "Images: only latest user images are sent; ask to reattach or describe prior images.",
37
- ].join("\n");
38
+ "Images: only the latest user message's images are sent as bytes; ask to reattach or describe prior images.",
39
+ ];
40
+ if (options.hasToolManifest) {
41
+ lines.push("See callable tool surfaces block below.");
42
+ }
43
+ return lines.join("\n");
38
44
  }
39
45
 
40
46
  function getCursorBootstrapTailSections(): string[] {
@@ -370,7 +376,10 @@ export function buildCursorIncrementalPrompt(context: Context, options: CursorPr
370
376
  }
371
377
 
372
378
  export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
373
- const sectionsBeforeMessages: string[] = [getCursorToolBoundaryText()];
379
+ const sectionsBeforeMessages: string[] = [getCursorToolBoundaryText({ hasToolManifest: Boolean(options.toolManifest) })];
380
+ if (options.toolManifest) {
381
+ sectionsBeforeMessages.push(options.toolManifest);
382
+ }
374
383
 
375
384
  if (context.systemPrompt) {
376
385
  sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
@@ -20,8 +20,6 @@ export function buildCursorPiBridgeMcpToolDescription(options: {
20
20
  }): string {
21
21
  return [
22
22
  options.piToolDescription,
23
- "",
24
- getCursorPiBridgeContractText(),
25
- `This run exposes real pi tool ${options.piToolName} as Cursor MCP tool ${options.mcpToolName}.`,
23
+ `Call MCP name ${options.mcpToolName} (pi tool: ${options.piToolName}). Full tool-surface rules are in the session bootstrap prompt.`,
26
24
  ].join("\n");
27
25
  }
@@ -1,34 +1,63 @@
1
- import {
2
- CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
3
- getCursorReplayDisplayLabel,
4
- type CursorReplayLegacyToolName,
5
- } from "./cursor-tool-names.js";
1
+ import { CURSOR_REPLAY_ACTIVITY_TOOL_NAME } from "./cursor-tool-names.js";
6
2
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
7
3
  import { scrubSensitiveText } from "./cursor-sensitive-text.js";
8
4
  import {
9
5
  DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
10
6
  type DiscardedIncompleteStartedToolCallReason,
11
7
  } from "./cursor-sdk-event-debug.js";
12
- import { getToolArgs, getToolName, normalizeToolName, truncateArg, type CursorPiToolDisplay } from "./cursor-transcript-utils.js";
13
- import { resolveTranscriptToolName } from "./cursor-web-tool-activity.js";
8
+ import {
9
+ assembleCursorReplayActivityDetails,
10
+ parseCursorReplayToolDetails,
11
+ resolveIncompleteReplayActivitySourceToolName,
12
+ } from "./cursor-replay-tool-details.js";
13
+ import { truncateArg, type CursorPiToolDisplay } from "./cursor-transcript-utils.js";
14
+ import { classifyCursorToolVisibility } from "./cursor-tool-visibility.js";
14
15
 
15
16
  export type IncompleteCursorToolDiscardReason = DiscardedIncompleteStartedToolCallReason;
16
17
 
17
- const INCOMPLETE_TITLE_KEYS: Partial<Record<string, CursorReplayLegacyToolName>> = {
18
- task: "cursor_task",
19
- mcp: "cursor_mcp",
20
- generateimage: "cursor_generate_image",
21
- recordscreen: "cursor_record_screen",
22
- semsearch: "cursor_sem_search",
23
- websearch: "cursor_web_search",
24
- webfetch: "cursor_web_fetch",
25
- createplan: "cursor_create_plan",
26
- updatetodos: "cursor_update_todos",
27
- readlints: "cursor_read_lints",
28
- delete: "cursor_delete",
29
- edit: "cursor_edit",
30
- write: "cursor_write",
31
- };
18
+ export interface IncompleteCursorToolRunOutcome {
19
+ reason: IncompleteCursorToolDiscardReason;
20
+ assistantTextProduced: boolean;
21
+ }
22
+
23
+ export interface IncompleteCursorToolRunOutcomeInput {
24
+ reason?: IncompleteCursorToolDiscardReason;
25
+ status?: string;
26
+ signalAborted?: boolean;
27
+ assistantTextProduced?: boolean;
28
+ }
29
+
30
+ export type IncompleteCursorToolVisibilityDecision = "emit" | "suppress" | "debugOnly";
31
+
32
+ export function buildIncompleteCursorToolRunOutcome(
33
+ outcome: IncompleteCursorToolRunOutcomeInput = {},
34
+ ): IncompleteCursorToolRunOutcome {
35
+ return {
36
+ reason:
37
+ outcome.reason ??
38
+ (outcome.status === "cancelled" || outcome.signalAborted
39
+ ? "abort"
40
+ : outcome.status === "error"
41
+ ? "sdk-failure"
42
+ : DISCARDED_INCOMPLETE_TOOL_CALL_REASON),
43
+ assistantTextProduced: outcome.assistantTextProduced ?? false,
44
+ };
45
+ }
46
+
47
+ export function resolveIncompleteCursorToolVisibility(
48
+ toolCall: unknown,
49
+ outcome: IncompleteCursorToolRunOutcome,
50
+ ): IncompleteCursorToolVisibilityDecision {
51
+ const visibility = classifyCursorToolVisibility(toolCall);
52
+ if (
53
+ outcome.reason === DISCARDED_INCOMPLETE_TOOL_CALL_REASON &&
54
+ outcome.assistantTextProduced &&
55
+ visibility.fastLocalDiscovery
56
+ ) {
57
+ return "debugOnly";
58
+ }
59
+ return "emit";
60
+ }
32
61
 
33
62
  function buildGenericIncompleteActivityTitle(displayName: string): string {
34
63
  if (!displayName || displayName === "unknown") return "Cursor tool";
@@ -49,25 +78,8 @@ export function formatIncompleteCursorToolReasonText(reason: IncompleteCursorToo
49
78
  }
50
79
 
51
80
  export function getIncompleteCursorToolActivityTitle(toolCall: unknown): string {
52
- const args = getToolArgs(toolCall);
53
- const name = resolveTranscriptToolName(getToolName(toolCall), args);
54
- const normalized = normalizeToolName(name).toLowerCase();
55
- const labelKey = INCOMPLETE_TITLE_KEYS[normalized];
56
- if (labelKey) return getCursorReplayDisplayLabel(labelKey);
57
- switch (normalized) {
58
- case "read":
59
- return "Cursor read";
60
- case "shell":
61
- return "Cursor shell";
62
- case "grep":
63
- return "Cursor grep";
64
- case "glob":
65
- return "Cursor find";
66
- case "ls":
67
- return "Cursor ls";
68
- default:
69
- return buildGenericIncompleteActivityTitle(name);
70
- }
81
+ const visibility = classifyCursorToolVisibility(toolCall);
82
+ return visibility.incompleteTitle ?? buildGenericIncompleteActivityTitle(visibility.displayName);
71
83
  }
72
84
 
73
85
  export function buildIncompleteCursorToolDisplay(
@@ -75,33 +87,44 @@ export function buildIncompleteCursorToolDisplay(
75
87
  reason: IncompleteCursorToolDiscardReason,
76
88
  options: { apiKey?: string } = {},
77
89
  ): CursorPiToolDisplay {
78
- const args = getToolArgs(toolCall);
79
- const transcriptName = resolveTranscriptToolName(getToolName(toolCall), args);
90
+ const visibility = classifyCursorToolVisibility(toolCall);
80
91
  const activityTitle = getIncompleteCursorToolActivityTitle(toolCall);
81
92
  const headline = `${activityTitle} did not complete`;
82
93
  const reasonText = scrubSensitiveText(formatIncompleteCursorToolReasonText(reason), options.apiKey);
83
94
  const contentText = `${headline}\n${reasonText}`;
95
+ const details = assembleCursorReplayActivityDetails(
96
+ resolveIncompleteReplayActivitySourceToolName(visibility.normalizedName),
97
+ headline,
98
+ { summary: reasonText, expandedText: contentText },
99
+ contentText,
100
+ true,
101
+ reasonText,
102
+ );
84
103
  return {
85
104
  toolName: CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
86
105
  args: {
87
- cursorToolName: normalizeToolName(transcriptName),
106
+ cursorToolName: visibility.normalizedName,
88
107
  activityTitle,
89
108
  activitySummary: reasonText,
90
109
  incomplete: true,
91
110
  },
92
111
  result: {
93
112
  content: [{ type: "text", text: contentText }],
94
- details: {
95
- cursorToolName: normalizeToolName(transcriptName),
96
- title: headline,
97
- summary: reasonText,
98
- },
113
+ details,
99
114
  },
100
115
  isError: true,
101
116
  };
102
117
  }
103
118
 
104
119
  export function formatIncompleteCursorToolTrace(display: CursorPiToolDisplay): string {
120
+ const parsed = parseCursorReplayToolDetails(display.result.details);
121
+ if (parsed?.variant === "activity") {
122
+ const summary =
123
+ parsed.summary?.trim() ||
124
+ (typeof display.args.activitySummary === "string" && display.args.activitySummary.trim()) ||
125
+ formatIncompleteCursorToolReasonText(DISCARDED_INCOMPLETE_TOOL_CALL_REASON);
126
+ return `${truncateCursorDisplayLine(parsed.title)}: ${truncateCursorDisplayLine(summary)}\n`;
127
+ }
105
128
  const details = display.result.details;
106
129
  const detailRecord = details && typeof details === "object" ? (details as Record<string, unknown>) : undefined;
107
130
  const argsRecord = display.args;
@@ -1,17 +1,23 @@
1
1
  const CURSOR_SDK_MCP_DEFAULT_TIMEOUT_MS = 60_000;
2
2
  const DEFAULT_CURSOR_MCP_TOOL_TIMEOUT_MS = 3_600_000;
3
+ const DEFAULT_CURSOR_MCP_CONNECT_TIMEOUT_MS = 10_000;
4
+ const MIN_CURSOR_MCP_CONNECT_TIMEOUT_MS = 1_000;
3
5
  const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647;
4
6
  const CURSOR_MCP_TOOL_TIMEOUT_MS_ENV = "PI_CURSOR_MCP_TOOL_TIMEOUT_MS";
5
7
  const CURSOR_MCP_TOOL_TIMEOUT_SECONDS_ENV = "PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS";
8
+ const CURSOR_MCP_CONNECT_TIMEOUT_MS_ENV = "PI_CURSOR_MCP_CONNECT_TIMEOUT_MS";
9
+ const CURSOR_MCP_CONNECT_TIMEOUT_SECONDS_ENV = "PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS";
6
10
 
7
11
  interface CursorMcpToolTimeoutOverrideOptions {
8
12
  timeoutMs?: number;
13
+ connectTimeoutMs?: number;
9
14
  env?: Record<string, string | undefined>;
10
15
  }
11
16
 
12
17
  interface CursorMcpToolTimeoutOverrideState {
13
18
  installed: boolean;
14
19
  timeoutMs: number;
20
+ connectTimeoutMs: number;
15
21
  sdkDefaultTimeoutMs: number;
16
22
  }
17
23
 
@@ -20,7 +26,8 @@ type SetTimeoutHandler = Parameters<GlobalSetTimeout>[0];
20
26
  type SetTimeoutDelay = Parameters<GlobalSetTimeout>[1];
21
27
 
22
28
  let originalSetTimeout: GlobalSetTimeout | undefined;
23
- let installedTimeoutMs = DEFAULT_CURSOR_MCP_TOOL_TIMEOUT_MS;
29
+ let installedToolTimeoutMs = DEFAULT_CURSOR_MCP_TOOL_TIMEOUT_MS;
30
+ let installedConnectTimeoutMs = DEFAULT_CURSOR_MCP_CONNECT_TIMEOUT_MS;
24
31
 
25
32
  function parsePositiveNumber(value: string | undefined): number | undefined {
26
33
  const trimmed = value?.trim();
@@ -46,15 +53,47 @@ export function resolveCursorMcpToolTimeoutMs(
46
53
  return DEFAULT_CURSOR_MCP_TOOL_TIMEOUT_MS;
47
54
  }
48
55
 
49
- export function isCursorSdkMcpToolTimeoutStack(stack: string | undefined): boolean {
56
+ function normalizeConnectTimeoutMs(timeoutMs: number): number {
57
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return DEFAULT_CURSOR_MCP_CONNECT_TIMEOUT_MS;
58
+ return Math.min(
59
+ Math.max(Math.trunc(timeoutMs), MIN_CURSOR_MCP_CONNECT_TIMEOUT_MS),
60
+ CURSOR_SDK_MCP_DEFAULT_TIMEOUT_MS,
61
+ );
62
+ }
63
+
64
+ export function resolveCursorMcpConnectTimeoutMs(
65
+ env: Record<string, string | undefined> = process.env,
66
+ ): number {
67
+ const explicitMs = parsePositiveNumber(env[CURSOR_MCP_CONNECT_TIMEOUT_MS_ENV]);
68
+ if (explicitMs !== undefined) return normalizeConnectTimeoutMs(explicitMs);
69
+
70
+ const explicitSeconds = parsePositiveNumber(env[CURSOR_MCP_CONNECT_TIMEOUT_SECONDS_ENV]);
71
+ if (explicitSeconds !== undefined) return normalizeConnectTimeoutMs(explicitSeconds * 1000);
72
+
73
+ return DEFAULT_CURSOR_MCP_CONNECT_TIMEOUT_MS;
74
+ }
75
+
76
+ function isCursorSdkMcpProtocolTimeoutStack(stack: string | undefined): boolean {
50
77
  if (!stack) return false;
51
78
  return (
52
79
  /(?:node_modules[/\\]@cursor[/\\]sdk|node_modules\/\@cursor\/sdk|@cursor\/sdk\/dist)/.test(stack) &&
53
- /\b_setupTimeout\b|\bProtocol\._setupTimeout\b/.test(stack) &&
80
+ /\b_setupTimeout\b|\bProtocol\._setupTimeout\b/.test(stack)
81
+ );
82
+ }
83
+
84
+ export function isCursorSdkMcpToolTimeoutStack(stack: string | undefined): boolean {
85
+ if (!stack) return false;
86
+ return (
87
+ isCursorSdkMcpProtocolTimeoutStack(stack) &&
54
88
  /\bcallTool\b|\bClient\.callTool\b|\bMcpSdkClient\.callTool\b/.test(stack)
55
89
  );
56
90
  }
57
91
 
92
+ export function isCursorSdkMcpConnectTimeoutStack(stack: string | undefined): boolean {
93
+ if (!stack || !isCursorSdkMcpProtocolTimeoutStack(stack)) return false;
94
+ return /\bClient\.(?:connect|listTools)\b|\bMcpSdkClient\.getTools\b/.test(stack);
95
+ }
96
+
58
97
  function isCursorSdkDefaultMcpTimeout(delay: SetTimeoutDelay): boolean {
59
98
  return typeof delay === "number" && delay === CURSOR_SDK_MCP_DEFAULT_TIMEOUT_MS;
60
99
  }
@@ -67,10 +106,15 @@ function patchedSetTimeout(
67
106
  const delegate = originalSetTimeout;
68
107
  if (!delegate) throw new Error("Cursor MCP timeout override installed without original setTimeout");
69
108
 
70
- const nextDelay =
71
- isCursorSdkDefaultMcpTimeout(delay) && isCursorSdkMcpToolTimeoutStack(new Error().stack)
72
- ? installedTimeoutMs
73
- : delay;
109
+ let nextDelay = delay;
110
+ if (isCursorSdkDefaultMcpTimeout(delay)) {
111
+ const stack = new Error().stack;
112
+ if (isCursorSdkMcpToolTimeoutStack(stack)) {
113
+ nextDelay = installedToolTimeoutMs;
114
+ } else if (isCursorSdkMcpConnectTimeoutStack(stack)) {
115
+ nextDelay = installedConnectTimeoutMs;
116
+ }
117
+ }
74
118
 
75
119
  return Reflect.apply(delegate, globalThis, [handler, nextDelay, ...args]) as ReturnType<GlobalSetTimeout>;
76
120
  }
@@ -78,9 +122,12 @@ function patchedSetTimeout(
78
122
  export function installCursorMcpToolTimeoutOverride(
79
123
  options: CursorMcpToolTimeoutOverrideOptions = {},
80
124
  ): CursorMcpToolTimeoutOverrideState {
81
- installedTimeoutMs = normalizeOverrideTimeoutMs(
125
+ installedToolTimeoutMs = normalizeOverrideTimeoutMs(
82
126
  options.timeoutMs ?? resolveCursorMcpToolTimeoutMs(options.env),
83
127
  );
128
+ installedConnectTimeoutMs = normalizeConnectTimeoutMs(
129
+ options.connectTimeoutMs ?? resolveCursorMcpConnectTimeoutMs(options.env),
130
+ );
84
131
 
85
132
  if (!originalSetTimeout) {
86
133
  originalSetTimeout = globalThis.setTimeout;
@@ -89,23 +136,31 @@ export function installCursorMcpToolTimeoutOverride(
89
136
 
90
137
  return {
91
138
  installed: true,
92
- timeoutMs: installedTimeoutMs,
139
+ timeoutMs: installedToolTimeoutMs,
140
+ connectTimeoutMs: installedConnectTimeoutMs,
93
141
  sdkDefaultTimeoutMs: CURSOR_SDK_MCP_DEFAULT_TIMEOUT_MS,
94
142
  };
95
143
  }
96
144
 
97
- export function restoreCursorMcpToolTimeoutOverrideForTests(): void {
145
+ export function restoreCursorMcpToolTimeoutOverride(): void {
98
146
  if (originalSetTimeout) {
99
147
  globalThis.setTimeout = originalSetTimeout;
100
148
  originalSetTimeout = undefined;
101
149
  }
102
- installedTimeoutMs = DEFAULT_CURSOR_MCP_TOOL_TIMEOUT_MS;
150
+ installedToolTimeoutMs = DEFAULT_CURSOR_MCP_TOOL_TIMEOUT_MS;
151
+ installedConnectTimeoutMs = DEFAULT_CURSOR_MCP_CONNECT_TIMEOUT_MS;
103
152
  }
104
153
 
154
+ export const restoreCursorMcpToolTimeoutOverrideForTests = restoreCursorMcpToolTimeoutOverride;
155
+
105
156
  export const cursorMcpToolTimeoutOverrideDefaults = {
106
157
  cursorSdkDefaultTimeoutMs: CURSOR_SDK_MCP_DEFAULT_TIMEOUT_MS,
107
158
  defaultOverrideTimeoutMs: DEFAULT_CURSOR_MCP_TOOL_TIMEOUT_MS,
159
+ defaultConnectTimeoutMs: DEFAULT_CURSOR_MCP_CONNECT_TIMEOUT_MS,
160
+ minConnectTimeoutMs: MIN_CURSOR_MCP_CONNECT_TIMEOUT_MS,
108
161
  maxNodeTimerDelayMs: MAX_NODE_TIMER_DELAY_MS,
109
162
  timeoutMsEnv: CURSOR_MCP_TOOL_TIMEOUT_MS_ENV,
110
163
  timeoutSecondsEnv: CURSOR_MCP_TOOL_TIMEOUT_SECONDS_ENV,
164
+ connectTimeoutMsEnv: CURSOR_MCP_CONNECT_TIMEOUT_MS_ENV,
165
+ connectTimeoutSecondsEnv: CURSOR_MCP_CONNECT_TIMEOUT_SECONDS_ENV,
111
166
  } as const;
@@ -13,12 +13,19 @@ import {
13
13
  NATIVE_CURSOR_TOOL_DISPLAY_ENV,
14
14
  readBooleanEnv,
15
15
  registeredNativeToolNames,
16
+ skippedNativeToolNames,
16
17
  } from "./cursor-native-tool-display-state.js";
17
18
  import { isCursorReplayToolName } from "./cursor-tool-names.js";
18
19
 
19
- const CORE_PI_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
20
+ export const CURSOR_CORE_PI_REPLAY_TOOL_NAMES = ["read", "bash", "edit", "write"] as const;
21
+ const CORE_PI_TOOL_NAMES = new Set<string>(CURSOR_CORE_PI_REPLAY_TOOL_NAMES);
20
22
 
21
- type CursorNativeToolRegistryApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools" | "registerTool" | "setActiveTools">;
23
+ function isCursorCorePiReplayToolName(toolName: string): toolName is (typeof CURSOR_CORE_PI_REPLAY_TOOL_NAMES)[number] {
24
+ return CORE_PI_TOOL_NAMES.has(toolName);
25
+ }
26
+
27
+ type CursorNativeToolActivationApi = Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">;
28
+ type CursorNativeToolRegistryApi = CursorNativeToolActivationApi & Pick<ExtensionAPI, "getAllTools" | "registerTool">;
22
29
 
23
30
  export interface CursorNativeToolDisplayExtensionApi extends CursorNativeToolRegistryApi {
24
31
  on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
@@ -34,7 +41,40 @@ function hasNonBuiltinTool(pi: Pick<ExtensionAPI, "getAllTools">, toolName: Nati
34
41
 
35
42
  type NativeRegistrationContext = { hasUI: boolean; ui: Pick<ExtensionContext["ui"], "notify">; model?: ExtensionContext["model"] };
36
43
 
37
- export function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
44
+ function registerNativeCursorToolsFromSet(
45
+ pi: CursorNativeToolRegistryApi,
46
+ toolNames: readonly NativeCursorToolName[],
47
+ ): NativeCursorToolName[] {
48
+ const newlySkippedToolNames: NativeCursorToolName[] = [];
49
+ for (const toolName of toolNames) {
50
+ if (registeredNativeToolNames.has(toolName) || skippedNativeToolNames.has(toolName)) continue;
51
+ if (hasNonBuiltinTool(pi, toolName)) {
52
+ skippedNativeToolNames.add(toolName);
53
+ newlySkippedToolNames.push(toolName);
54
+ continue;
55
+ }
56
+ registerNativeCursorTool(pi, toolName);
57
+ registeredNativeToolNames.add(toolName);
58
+ }
59
+ return newlySkippedToolNames;
60
+ }
61
+
62
+ function notifySkippedNativeCursorToolsIfNeeded(ctx: NativeRegistrationContext, skippedToolNames: readonly NativeCursorToolName[]): void {
63
+ if (skippedToolNames.length === 0 || readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV) !== true || !ctx.hasUI) return;
64
+ ctx.ui.notify(
65
+ `Cursor native tool replay skipped for ${skippedToolNames.join(", ")} because another extension already provides ${skippedToolNames.length === 1 ? "that tool" : "those tools"}. Cursor will use scrubbed activity transcripts for skipped tools.`,
66
+ "warning",
67
+ );
68
+ }
69
+
70
+ function hasAttemptedNativeCursorToolRegistration(): boolean {
71
+ return registeredNativeToolNames.size > 0 || skippedNativeToolNames.size > 0;
72
+ }
73
+
74
+ export function syncRegisteredNativeCursorToolsForModel(
75
+ pi: CursorNativeToolActivationApi,
76
+ model: ExtensionContext["model"],
77
+ ): void {
38
78
  if (registeredNativeToolNames.size === 0) return;
39
79
  const activeToolNames = new Set(pi.getActiveTools());
40
80
  let changed = false;
@@ -47,7 +87,7 @@ export function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "
47
87
  }
48
88
  } else {
49
89
  for (const toolName of registeredNativeToolNames) {
50
- if (CORE_PI_TOOL_NAMES.has(toolName)) continue;
90
+ if (isCursorCorePiReplayToolName(toolName)) continue;
51
91
  if (!activeToolNames.delete(toolName)) continue;
52
92
  changed = true;
53
93
  }
@@ -55,45 +95,41 @@ export function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "
55
95
  if (changed) pi.setActiveTools([...activeToolNames]);
56
96
  }
57
97
 
58
- function registerAvailableNativeCursorTools(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
98
+ function ensureNativeCursorToolsRegisteredForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
59
99
  if (!isCursorNativeToolRegistrationRequested()) {
60
100
  registeredNativeToolNames.clear();
101
+ skippedNativeToolNames.clear();
61
102
  return;
62
103
  }
104
+ if (!isCursorModel(ctx.model) || hasAttemptedNativeCursorToolRegistration()) return;
63
105
 
64
- const skippedToolNames: string[] = [];
65
- for (const toolName of NATIVE_CURSOR_TOOL_NAMES) {
66
- if (registeredNativeToolNames.has(toolName)) continue;
67
- if (hasNonBuiltinTool(pi, toolName)) {
68
- skippedToolNames.push(toolName);
69
- continue;
70
- }
71
- registerNativeCursorTool(pi, toolName);
72
- registeredNativeToolNames.add(toolName);
73
- }
74
-
75
- syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
106
+ const nonCoreToolNames = NATIVE_CURSOR_TOOL_NAMES.filter((toolName) => !isCursorCorePiReplayToolName(toolName));
107
+ const skippedToolNames = [
108
+ ...registerNativeCursorToolsFromSet(pi, nonCoreToolNames),
109
+ ...registerNativeCursorToolsFromSet(pi, CURSOR_CORE_PI_REPLAY_TOOL_NAMES),
110
+ ];
111
+ notifySkippedNativeCursorToolsIfNeeded(ctx, skippedToolNames);
112
+ }
76
113
 
77
- if (skippedToolNames.length > 0 && readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV) === true && ctx.hasUI) {
78
- ctx.ui.notify(
79
- `Cursor native tool replay skipped for ${skippedToolNames.join(", ")} because another extension already provides ${skippedToolNames.length === 1 ? "that tool" : "those tools"}. Cursor will use scrubbed activity transcripts for skipped tools.`,
80
- "warning",
81
- );
114
+ function ensureThenSyncNativeCursorToolsForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
115
+ if (isCursorModel(ctx.model) && !hasAttemptedNativeCursorToolRegistration()) {
116
+ ensureNativeCursorToolsRegisteredForModel(pi, ctx);
82
117
  }
118
+ syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
83
119
  }
84
120
 
85
121
  export function registerCursorNativeToolDisplay(pi: CursorNativeToolDisplayExtensionApi): void {
86
122
  pi.on("session_start", (_event, ctx) => {
87
- registerAvailableNativeCursorTools(pi, ctx);
123
+ ensureThenSyncNativeCursorToolsForModel(pi, ctx);
88
124
  });
89
125
  pi.on("before_agent_start", (_event, ctx) => {
90
- syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
126
+ ensureThenSyncNativeCursorToolsForModel(pi, ctx);
91
127
  });
92
128
  pi.on("turn_start", (_event, ctx) => {
93
- syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
129
+ ensureThenSyncNativeCursorToolsForModel(pi, ctx);
94
130
  });
95
- pi.on("model_select", (event) => {
96
- syncRegisteredNativeCursorToolsForModel(pi, event.model);
131
+ pi.on("model_select", (event, ctx) => {
132
+ ensureThenSyncNativeCursorToolsForModel(pi, { ...ctx, model: event.model });
97
133
  });
98
134
  }
99
135