pi-cursor-sdk 0.1.18 → 0.1.20

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 (49) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +59 -1
  3. package/docs/cursor-live-smoke-checklist.md +4 -1
  4. package/docs/cursor-model-ux-spec.md +7 -5
  5. package/docs/cursor-native-tool-replay.md +99 -3
  6. package/docs/cursor-testing-lessons.md +234 -5
  7. package/package.json +10 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  11. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  12. package/scripts/probe-mcp-coldstart.mjs +244 -0
  13. package/scripts/validate-smoke-jsonl.mjs +27 -3
  14. package/src/context.ts +45 -32
  15. package/src/cursor-agent-message-web-tools.ts +172 -0
  16. package/src/cursor-agents-context.ts +176 -0
  17. package/src/cursor-incomplete-tool-visibility.ts +124 -0
  18. package/src/cursor-live-run-coordinator.ts +18 -7
  19. package/src/cursor-mcp-timeout-override.ts +66 -11
  20. package/src/cursor-model.ts +12 -0
  21. package/src/cursor-native-tool-display-registration.ts +1 -4
  22. package/src/cursor-native-tool-display-replay.ts +65 -6
  23. package/src/cursor-native-tool-display-tools.ts +20 -0
  24. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  25. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  26. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  27. package/src/cursor-provider-errors.ts +96 -0
  28. package/src/cursor-provider-live-run-drain.ts +181 -62
  29. package/src/cursor-provider-turn-coordinator.ts +220 -33
  30. package/src/cursor-provider.ts +302 -93
  31. package/src/cursor-question-tool.ts +1 -4
  32. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  33. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  34. package/src/cursor-sdk-event-debug-session.ts +163 -0
  35. package/src/cursor-sdk-event-debug.ts +602 -0
  36. package/src/cursor-sensitive-text.ts +27 -7
  37. package/src/cursor-session-agent.ts +279 -82
  38. package/src/cursor-session-send-policy.ts +43 -0
  39. package/src/cursor-setting-sources.ts +29 -0
  40. package/src/cursor-state.ts +1 -5
  41. package/src/cursor-tool-lifecycle.ts +85 -0
  42. package/src/cursor-tool-names.ts +39 -0
  43. package/src/cursor-tool-transcript.ts +4 -2
  44. package/src/cursor-tool-visibility.ts +63 -0
  45. package/src/cursor-transcript-tool-formatters.ts +228 -5
  46. package/src/cursor-transcript-tool-specs.ts +135 -24
  47. package/src/cursor-transcript-utils.ts +12 -0
  48. package/src/cursor-web-tool-activity.ts +84 -0
  49. package/src/index.ts +4 -1
@@ -0,0 +1,176 @@
1
+ import type {
2
+ BeforeAgentStartEvent,
3
+ BeforeAgentStartEventResult,
4
+ BuildSystemPromptOptions,
5
+ ExtensionAPI,
6
+ ExtensionContext,
7
+ ExtensionHandler,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import { parseEnvBoolean } from "./cursor-env-boolean.js";
11
+ import { isCursorModel } from "./cursor-model.js";
12
+ import {
13
+ cursorSettingSourcesLoadProjectAgentsRules,
14
+ cursorSettingSourcesLoadUserAgentsRules,
15
+ getEffectiveCursorSettingSources,
16
+ } from "./cursor-setting-sources.js";
17
+ import type { SettingSource } from "@cursor/sdk";
18
+
19
+ export const CURSOR_PRESERVE_PI_AGENTS_MD_ENV = "PI_CURSOR_PRESERVE_PI_AGENTS_MD";
20
+
21
+ /** Opening tag prefix pi `buildSystemPrompt()` uses for each context file (path attribute only). */
22
+ export const PI_PROJECT_INSTRUCTIONS_OPEN_PREFIX = '<project_instructions path="';
23
+ const PI_PROJECT_INSTRUCTIONS_CLOSE = "</project_instructions>";
24
+ const PI_PROJECT_CONTEXT_OPEN = "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n";
25
+ const PI_PROJECT_CONTEXT_CLOSE = "</project_context>\n";
26
+
27
+ function normalizeContextPath(filePath: string): string {
28
+ return filePath.replace(/\\/g, "/");
29
+ }
30
+
31
+ function normalizeDirPath(dirPath: string): string {
32
+ const normalized = normalizeContextPath(dirPath).replace(/\/+$/, "");
33
+ return normalized || "/";
34
+ }
35
+
36
+ export type PiAgentsContextFile = {
37
+ path: string;
38
+ content: string;
39
+ };
40
+
41
+ /** Overlap classes for pi context files that Cursor also loads via `settingSources`. */
42
+ export type PiAgentsContextOverlap = "none" | "cursor-user-agents" | "cursor-project-rules";
43
+
44
+ /** Pi context filenames that can overlap Cursor project/user ambient rules. */
45
+ const CURSOR_OVERLAPPING_CONTEXT_BASE_NAMES = new Set(["agents.md", "claude.md"]);
46
+
47
+ export function getAgentsContextFileBaseName(filePath: string): string {
48
+ const normalized = normalizeContextPath(filePath);
49
+ return normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
50
+ }
51
+
52
+ /** Actual pi agent dir `AGENTS.md` — overlaps Cursor `user` setting source (global agent instructions). */
53
+ export function isPiAgentDirAgentsMdPath(filePath: string, agentDir: string = getAgentDir()): boolean {
54
+ const normalized = normalizeContextPath(filePath);
55
+ const agentsMdPath = `${normalizeDirPath(agentDir)}/agents.md`;
56
+ return normalized.toLowerCase() === agentsMdPath.toLowerCase();
57
+ }
58
+
59
+ /** Actual pi agent dir `CLAUDE.md` — kept because Cursor user rules use `~/.claude/CLAUDE.md`. */
60
+ export function isPiAgentDirClaudeMdPath(filePath: string, agentDir: string = getAgentDir()): boolean {
61
+ const normalized = normalizeContextPath(filePath);
62
+ const claudeMdPath = `${normalizeDirPath(agentDir)}/claude.md`;
63
+ return normalized.toLowerCase() === claudeMdPath.toLowerCase();
64
+ }
65
+
66
+ /**
67
+ * Classify whether a pi-loaded context file overlaps Cursor ambient rules.
68
+ * Project/repo `AGENTS.md` and `CLAUDE.md` overlap Cursor `project` sources.
69
+ * Only the actual pi agent dir `AGENTS.md` overlaps Cursor `user`; agent-dir `CLAUDE.md` is kept
70
+ * because Cursor user rules use `~/.claude/CLAUDE.md`, not pi's agent dir path.
71
+ */
72
+ export function classifyContextFileOverlap(
73
+ filePath: string,
74
+ agentDir: string = getAgentDir(),
75
+ ): PiAgentsContextOverlap {
76
+ const base = getAgentsContextFileBaseName(filePath);
77
+ if (!CURSOR_OVERLAPPING_CONTEXT_BASE_NAMES.has(base)) return "none";
78
+ if (base === "agents.md" && isPiAgentDirAgentsMdPath(filePath, agentDir)) return "cursor-user-agents";
79
+ if (base === "claude.md" && isPiAgentDirClaudeMdPath(filePath, agentDir)) return "none";
80
+ return "cursor-project-rules";
81
+ }
82
+
83
+ export function shouldRemovePiAgentsContextFile(
84
+ file: PiAgentsContextFile,
85
+ settingSources: SettingSource[] | undefined,
86
+ agentDir?: string,
87
+ ): boolean {
88
+ switch (classifyContextFileOverlap(file.path, agentDir)) {
89
+ case "cursor-user-agents":
90
+ return cursorSettingSourcesLoadUserAgentsRules(settingSources);
91
+ case "cursor-project-rules":
92
+ return cursorSettingSourcesLoadProjectAgentsRules(settingSources);
93
+ default:
94
+ return false;
95
+ }
96
+ }
97
+
98
+ export function shouldSuppressPiAgentsContext(
99
+ model: ExtensionContext["model"],
100
+ contextFiles: readonly PiAgentsContextFile[],
101
+ settingSources: SettingSource[] | undefined,
102
+ agentDir?: string,
103
+ ): boolean {
104
+ if (!isCursorModel(model)) return false;
105
+ if (parseEnvBoolean(process.env[CURSOR_PRESERVE_PI_AGENTS_MD_ENV], false)) return false;
106
+ if (contextFiles.length === 0) return false;
107
+ return contextFiles.some((file) => shouldRemovePiAgentsContextFile(file, settingSources, agentDir));
108
+ }
109
+
110
+ /** Exact pi `buildSystemPrompt()` serialization for one context file block (including trailing blank line). */
111
+ export function serializePiProjectInstructionsBlock(file: PiAgentsContextFile): string {
112
+ return `${PI_PROJECT_INSTRUCTIONS_OPEN_PREFIX}${file.path}">\n${file.content}\n${PI_PROJECT_INSTRUCTIONS_CLOSE}\n\n`;
113
+ }
114
+
115
+ /** Exact pi `buildSystemPrompt()` serialization for the full project context section. */
116
+ export function serializePiProjectContextSection(contextFiles: readonly PiAgentsContextFile[]): string {
117
+ if (contextFiles.length === 0) return "";
118
+ return `${PI_PROJECT_CONTEXT_OPEN}${contextFiles.map(serializePiProjectInstructionsBlock).join("")}${PI_PROJECT_CONTEXT_CLOSE}`;
119
+ }
120
+
121
+ /** Remove pi context blocks that overlap Cursor setting sources. */
122
+ export function removePiAgentsContextFromSystemPrompt(
123
+ systemPrompt: string,
124
+ contextFiles: readonly PiAgentsContextFile[],
125
+ settingSources: SettingSource[] | undefined,
126
+ agentDir?: string,
127
+ ): string {
128
+ const retainedContextFiles: PiAgentsContextFile[] = [];
129
+ let removedAny = false;
130
+ for (const file of contextFiles) {
131
+ if (shouldRemovePiAgentsContextFile(file, settingSources, agentDir)) {
132
+ removedAny = true;
133
+ continue;
134
+ }
135
+ retainedContextFiles.push(file);
136
+ }
137
+ if (!removedAny) return systemPrompt;
138
+
139
+ const originalSection = serializePiProjectContextSection(contextFiles);
140
+ const start = systemPrompt.indexOf(originalSection);
141
+ if (start < 0) return systemPrompt;
142
+
143
+ const replacementSection = serializePiProjectContextSection(retainedContextFiles);
144
+ return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
145
+ }
146
+
147
+ export function resolveCursorFacingSystemPrompt(
148
+ systemPrompt: string,
149
+ model: ExtensionContext["model"],
150
+ systemPromptOptions?: BuildSystemPromptOptions,
151
+ settingSourcesRaw?: string,
152
+ agentDir?: string,
153
+ ): string {
154
+ if (!systemPromptOptions) return systemPrompt;
155
+ const contextFiles = systemPromptOptions.contextFiles ?? [];
156
+ const settingSources = getEffectiveCursorSettingSources(settingSourcesRaw);
157
+ if (!shouldSuppressPiAgentsContext(model, contextFiles, settingSources, agentDir)) {
158
+ return systemPrompt;
159
+ }
160
+ return removePiAgentsContextFromSystemPrompt(systemPrompt, contextFiles, settingSources, agentDir);
161
+ }
162
+
163
+ type CursorAgentsContextExtensionApi = Pick<ExtensionAPI, "on">;
164
+
165
+ export function registerCursorAgentsContextDedup(pi: CursorAgentsContextExtensionApi): void {
166
+ const handler: ExtensionHandler<BeforeAgentStartEvent, BeforeAgentStartEventResult> = (event, ctx) => {
167
+ const resolved = resolveCursorFacingSystemPrompt(
168
+ event.systemPrompt,
169
+ ctx.model,
170
+ event.systemPromptOptions,
171
+ );
172
+ if (resolved === event.systemPrompt) return;
173
+ return { systemPrompt: resolved };
174
+ };
175
+ pi.on("before_agent_start", handler);
176
+ }
@@ -0,0 +1,124 @@
1
+ import { CURSOR_REPLAY_ACTIVITY_TOOL_NAME } from "./cursor-tool-names.js";
2
+ import { truncateCursorDisplayLine } from "./cursor-display-text.js";
3
+ import { scrubSensitiveText } from "./cursor-sensitive-text.js";
4
+ import {
5
+ DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
6
+ type DiscardedIncompleteStartedToolCallReason,
7
+ } from "./cursor-sdk-event-debug.js";
8
+ import { truncateArg, type CursorPiToolDisplay } from "./cursor-transcript-utils.js";
9
+ import { classifyCursorToolVisibility } from "./cursor-tool-visibility.js";
10
+
11
+ export type IncompleteCursorToolDiscardReason = DiscardedIncompleteStartedToolCallReason;
12
+
13
+ export interface IncompleteCursorToolRunOutcome {
14
+ reason: IncompleteCursorToolDiscardReason;
15
+ assistantTextProduced: boolean;
16
+ }
17
+
18
+ export interface IncompleteCursorToolRunOutcomeInput {
19
+ reason?: IncompleteCursorToolDiscardReason;
20
+ status?: string;
21
+ signalAborted?: boolean;
22
+ assistantTextProduced?: boolean;
23
+ }
24
+
25
+ export type IncompleteCursorToolVisibilityDecision = "emit" | "suppress" | "debugOnly";
26
+
27
+ export function buildIncompleteCursorToolRunOutcome(
28
+ outcome: IncompleteCursorToolRunOutcomeInput = {},
29
+ ): IncompleteCursorToolRunOutcome {
30
+ return {
31
+ reason:
32
+ outcome.reason ??
33
+ (outcome.status === "cancelled" || outcome.signalAborted
34
+ ? "abort"
35
+ : outcome.status === "error"
36
+ ? "sdk-failure"
37
+ : DISCARDED_INCOMPLETE_TOOL_CALL_REASON),
38
+ assistantTextProduced: outcome.assistantTextProduced ?? false,
39
+ };
40
+ }
41
+
42
+ export function resolveIncompleteCursorToolVisibility(
43
+ toolCall: unknown,
44
+ outcome: IncompleteCursorToolRunOutcome,
45
+ ): IncompleteCursorToolVisibilityDecision {
46
+ const visibility = classifyCursorToolVisibility(toolCall);
47
+ if (
48
+ outcome.reason === DISCARDED_INCOMPLETE_TOOL_CALL_REASON &&
49
+ outcome.assistantTextProduced &&
50
+ visibility.fastLocalDiscovery
51
+ ) {
52
+ return "debugOnly";
53
+ }
54
+ return "emit";
55
+ }
56
+
57
+ function buildGenericIncompleteActivityTitle(displayName: string): string {
58
+ if (!displayName || displayName === "unknown") return "Cursor tool";
59
+ return `Cursor ${truncateArg(displayName)}`;
60
+ }
61
+
62
+ export function formatIncompleteCursorToolReasonText(reason: IncompleteCursorToolDiscardReason): string {
63
+ switch (reason) {
64
+ case DISCARDED_INCOMPLETE_TOOL_CALL_REASON:
65
+ return "missing completion";
66
+ case "abort":
67
+ return "aborted";
68
+ case "sdk-failure":
69
+ return "SDK run failed";
70
+ case "run-drain":
71
+ return "run ended during drain";
72
+ }
73
+ }
74
+
75
+ export function getIncompleteCursorToolActivityTitle(toolCall: unknown): string {
76
+ const visibility = classifyCursorToolVisibility(toolCall);
77
+ return visibility.incompleteTitle ?? buildGenericIncompleteActivityTitle(visibility.displayName);
78
+ }
79
+
80
+ export function buildIncompleteCursorToolDisplay(
81
+ toolCall: unknown,
82
+ reason: IncompleteCursorToolDiscardReason,
83
+ options: { apiKey?: string } = {},
84
+ ): CursorPiToolDisplay {
85
+ const visibility = classifyCursorToolVisibility(toolCall);
86
+ const activityTitle = getIncompleteCursorToolActivityTitle(toolCall);
87
+ const headline = `${activityTitle} did not complete`;
88
+ const reasonText = scrubSensitiveText(formatIncompleteCursorToolReasonText(reason), options.apiKey);
89
+ const contentText = `${headline}\n${reasonText}`;
90
+ return {
91
+ toolName: CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
92
+ args: {
93
+ cursorToolName: visibility.normalizedName,
94
+ activityTitle,
95
+ activitySummary: reasonText,
96
+ incomplete: true,
97
+ },
98
+ result: {
99
+ content: [{ type: "text", text: contentText }],
100
+ details: {
101
+ cursorToolName: visibility.normalizedName,
102
+ title: headline,
103
+ summary: reasonText,
104
+ },
105
+ },
106
+ isError: true,
107
+ };
108
+ }
109
+
110
+ export function formatIncompleteCursorToolTrace(display: CursorPiToolDisplay): string {
111
+ const details = display.result.details;
112
+ const detailRecord = details && typeof details === "object" ? (details as Record<string, unknown>) : undefined;
113
+ const argsRecord = display.args;
114
+ const title =
115
+ (typeof detailRecord?.title === "string" && detailRecord.title.trim()) ||
116
+ (typeof argsRecord.activityTitle === "string" && argsRecord.activityTitle.trim()
117
+ ? `${argsRecord.activityTitle} did not complete`
118
+ : "Cursor tool did not complete");
119
+ const summary =
120
+ (typeof detailRecord?.summary === "string" && detailRecord.summary.trim()) ||
121
+ (typeof argsRecord.activitySummary === "string" && argsRecord.activitySummary.trim()) ||
122
+ formatIncompleteCursorToolReasonText(DISCARDED_INCOMPLETE_TOOL_CALL_REASON);
123
+ return `${truncateCursorDisplayLine(title)}: ${truncateCursorDisplayLine(summary)}\n`;
124
+ }
@@ -10,6 +10,7 @@ import {
10
10
  import type { CursorNativeToolDisplayItem } from "./cursor-native-tool-display.js";
11
11
  import type { CursorPiBridgeToolRequest, CursorPiToolBridgeRun } from "./cursor-pi-tool-bridge.js";
12
12
  import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
13
+ import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
13
14
 
14
15
  export class CursorLiveRunAbortError extends Error {
15
16
  constructor() {
@@ -46,7 +47,9 @@ export interface CursorLiveRun {
46
47
  cancelled: boolean;
47
48
  disposed: boolean;
48
49
  errorMessage?: string;
50
+ abortMessage?: string;
49
51
  chainUserInputAfterCompletion: boolean;
52
+ debugRecorder?: CursorSdkEventDebugRecorder;
50
53
  }
51
54
 
52
55
  export interface CursorLiveRunCreateParams {
@@ -57,6 +60,7 @@ export interface CursorLiveRunCreateParams {
57
60
  sessionAgentScopeKey?: string;
58
61
  promptInputTokens: number;
59
62
  textDeltas?: string[];
63
+ debugRecorder?: CursorSdkEventDebugRecorder;
60
64
  }
61
65
 
62
66
  export interface CursorLiveRunCoordinatorDeps {
@@ -70,7 +74,7 @@ export interface CursorLiveRunCoordinator {
70
74
  start(params: CursorLiveRunCreateParams): CursorLiveRun;
71
75
  attachSdkRun(run: CursorLiveRun, sdkRun: CursorLiveSdkRun): void;
72
76
  markFinished(run: CursorLiveRun, finalText: string): void;
73
- markCancelled(run: CursorLiveRun): void;
77
+ markCancelled(run: CursorLiveRun, abortMessage?: string): void;
74
78
  markError(run: CursorLiveRun, errorMessage: string): void;
75
79
  queueEvent(run: CursorLiveRun, event: CursorLiveQueuedEvent): void;
76
80
  peekEvent(run: CursorLiveRun): CursorLiveQueuedEvent | undefined;
@@ -268,6 +272,7 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
268
272
  cancelled: false,
269
273
  disposed: false,
270
274
  chainUserInputAfterCompletion: false,
275
+ debugRecorder: params.debugRecorder,
271
276
  };
272
277
  privateStates.set(run, {
273
278
  waiters: new Set(),
@@ -294,9 +299,10 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
294
299
  coordinator.requestIdleDispose(run);
295
300
  },
296
301
 
297
- markCancelled(run): void {
302
+ markCancelled(run, abortMessage): void {
298
303
  if (run.disposed) return;
299
304
  run.cancelled = true;
305
+ run.abortMessage = abortMessage;
300
306
  run.done = true;
301
307
  notifyProgress(run);
302
308
  coordinator.requestIdleDispose(run);
@@ -313,6 +319,7 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
313
319
  queueEvent(run, event): void {
314
320
  if (run.disposed) return;
315
321
  run.pendingEvents.push(event);
322
+ run.debugRecorder?.recordLiveRunEvent(event);
316
323
  notifyProgress(run);
317
324
  },
318
325
 
@@ -433,7 +440,9 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
433
440
  if (state.leased || state.leaseQueue.length > 0) return;
434
441
  state.idleDisposeRequested = false;
435
442
  state.idleDisposeTimer = setTimeout(() => {
436
- void coordinator.release(run);
443
+ void coordinator.release(run).catch(() => {
444
+ // Idle dispose must not leave release failures as unhandled rejections.
445
+ });
437
446
  }, deps.getIdleDisposeMs());
438
447
  state.idleDisposeTimer.unref?.();
439
448
  },
@@ -463,10 +472,12 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
463
472
  }
464
473
  }
465
474
  if (abandoned) {
466
- try {
467
- await run.sdkRun?.cancel();
468
- } catch {
469
- // cancellation failure should not block session-agent abandonment
475
+ if (!run.done) {
476
+ try {
477
+ await run.sdkRun?.cancel();
478
+ } catch {
479
+ // cancellation failure should not block session-agent abandonment
480
+ }
470
481
  }
471
482
  await deps.abandonSessionAgent(run.sessionAgentScopeKey);
472
483
  }
@@ -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;
@@ -0,0 +1,12 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const CURSOR_PROVIDER = "cursor";
4
+ export const CURSOR_SDK_API = "cursor-sdk";
5
+
6
+ export type CursorModelRef =
7
+ | Pick<NonNullable<ExtensionContext["model"]>, "provider" | "api">
8
+ | undefined;
9
+
10
+ export function isCursorModel(model: CursorModelRef): boolean {
11
+ return model?.provider === CURSOR_PROVIDER || model?.api === CURSOR_SDK_API;
12
+ }
@@ -6,6 +6,7 @@ import {
6
6
  registerNativeCursorTool,
7
7
  type NativeCursorToolName,
8
8
  } from "./cursor-native-tool-display-tools.js";
9
+ import { isCursorModel } from "./cursor-model.js";
9
10
  import {
10
11
  isCursorNativeToolDisplayRequested,
11
12
  isCursorNativeToolRegistrationRequested,
@@ -33,10 +34,6 @@ function hasNonBuiltinTool(pi: Pick<ExtensionAPI, "getAllTools">, toolName: Nati
33
34
 
34
35
  type NativeRegistrationContext = { hasUI: boolean; ui: Pick<ExtensionContext["ui"], "notify">; model?: ExtensionContext["model"] };
35
36
 
36
- function isCursorModel(model: ExtensionContext["model"]): boolean {
37
- return model?.provider === "cursor" || model?.api === "cursor-sdk";
38
- }
39
-
40
37
  export function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
41
38
  if (registeredNativeToolNames.size === 0) return;
42
39
  const activeToolNames = new Set(pi.getActiveTools());