pi-cursor-sdk 0.1.18 → 0.1.19

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 (46) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +37 -0
  3. package/docs/cursor-live-smoke-checklist.md +3 -0
  4. package/docs/cursor-model-ux-spec.md +4 -3
  5. package/docs/cursor-native-tool-replay.md +96 -2
  6. package/docs/cursor-testing-lessons.md +234 -5
  7. package/package.json +8 -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/validate-smoke-jsonl.mjs +27 -3
  13. package/src/context.ts +45 -32
  14. package/src/cursor-agent-message-web-tools.ts +172 -0
  15. package/src/cursor-agents-context.ts +176 -0
  16. package/src/cursor-incomplete-tool-visibility.ts +118 -0
  17. package/src/cursor-live-run-coordinator.ts +18 -7
  18. package/src/cursor-model.ts +12 -0
  19. package/src/cursor-native-tool-display-registration.ts +1 -4
  20. package/src/cursor-native-tool-display-replay.ts +63 -5
  21. package/src/cursor-native-tool-display-tools.ts +20 -0
  22. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  23. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  24. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  25. package/src/cursor-provider-errors.ts +96 -0
  26. package/src/cursor-provider-live-run-drain.ts +181 -62
  27. package/src/cursor-provider-turn-coordinator.ts +198 -32
  28. package/src/cursor-provider.ts +270 -83
  29. package/src/cursor-question-tool.ts +1 -4
  30. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  31. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  32. package/src/cursor-sdk-event-debug-session.ts +163 -0
  33. package/src/cursor-sdk-event-debug.ts +597 -0
  34. package/src/cursor-sensitive-text.ts +27 -7
  35. package/src/cursor-session-agent.ts +25 -3
  36. package/src/cursor-session-send-policy.ts +43 -0
  37. package/src/cursor-setting-sources.ts +29 -0
  38. package/src/cursor-state.ts +1 -5
  39. package/src/cursor-tool-lifecycle.ts +111 -0
  40. package/src/cursor-tool-names.ts +12 -0
  41. package/src/cursor-tool-transcript.ts +4 -2
  42. package/src/cursor-transcript-tool-formatters.ts +228 -5
  43. package/src/cursor-transcript-tool-specs.ts +113 -14
  44. package/src/cursor-transcript-utils.ts +12 -0
  45. package/src/cursor-web-tool-activity.ts +84 -0
  46. package/src/index.ts +4 -1
package/src/context.ts CHANGED
@@ -20,6 +20,33 @@ export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
20
20
  export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
21
21
  const SECTION_SEPARATOR = "\n\n";
22
22
 
23
+ 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
+ }
26
+
27
+ function getCursorToolBoundaryText(): string {
28
+ return [
29
+ "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.",
33
+ "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
+ }
39
+
40
+ function getCursorBootstrapTailSections(): string[] {
41
+ return [
42
+ [
43
+ "Answer the latest user request above using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from the system prompt as if they were available.",
44
+ "If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
45
+ ].join("\n"),
46
+ getCursorToolTailGuardText(),
47
+ ];
48
+ }
49
+
23
50
  function normalizePiContextMessages(messages: Context["messages"]): Message[] {
24
51
  return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
25
52
  }
@@ -281,7 +308,7 @@ export function computeCursorContextFingerprint(context: Context): string {
281
308
  return JSON.stringify(payload);
282
309
  }
283
310
 
284
- export function shouldBootstrapCursorSend(
311
+ export function shouldBootstrapCursorContext(
285
312
  sendState: { bootstrapped: boolean; contextFingerprint: string },
286
313
  context: Context,
287
314
  ): boolean {
@@ -304,6 +331,14 @@ export function shouldBootstrapCursorSend(
304
331
  return false;
305
332
  }
306
333
 
334
+ /** @deprecated Use planCursorSessionSend() for send mode and shouldBootstrapCursorContext() for context-only checks. */
335
+ export function shouldBootstrapCursorSend(
336
+ sendState: { bootstrapped: boolean; contextFingerprint: string },
337
+ context: Context,
338
+ ): boolean {
339
+ return shouldBootstrapCursorContext(sendState, context);
340
+ }
341
+
307
342
  export function buildCursorIncrementalPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
308
343
  // Incremental sends omit the full Cursor SDK tool boundary block; the session agent retains prior bootstrap context.
309
344
  const messages = normalizePiContextMessages(context.messages);
@@ -324,35 +359,18 @@ export function buildCursorIncrementalPrompt(context: Context, options: CursorPr
324
359
  options.maxInputTokens === undefined
325
360
  ? options
326
361
  : { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
327
- const parts = applyPromptBudget(sectionsBeforeMessages, latestUserMessageSections, [], latestUserMessageIndex, budgetOptions);
362
+ const parts = applyPromptBudget(
363
+ sectionsBeforeMessages,
364
+ latestUserMessageSections,
365
+ [getCursorToolTailGuardText()],
366
+ latestUserMessageIndex,
367
+ budgetOptions,
368
+ );
328
369
  return { text: parts.join(SECTION_SEPARATOR), images };
329
370
  }
330
371
 
331
- export function buildCursorSendPrompt(
332
- context: Context,
333
- options: CursorPromptOptions,
334
- sendState: { bootstrapped: boolean; contextFingerprint: string },
335
- ): { prompt: CursorPrompt; bootstrap: boolean } {
336
- const bootstrap = shouldBootstrapCursorSend(sendState, context);
337
- if (bootstrap) {
338
- return { prompt: buildCursorPrompt(context, options), bootstrap: true };
339
- }
340
- return { prompt: buildCursorIncrementalPrompt(context, options), bootstrap: false };
341
- }
342
-
343
372
  export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
344
- const sectionsBeforeMessages: string[] = [
345
- [
346
- "Cursor SDK tool boundary:",
347
- "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.",
348
- getCursorPiBridgeContractText(),
349
- "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.",
350
- "Use pi__cursor_ask_question for material choices if exposed.",
351
- "Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
352
- "Replay: pi may display recorded Cursor tool activity as pi-style cards, but replay is display-only and not a capability to invoke.",
353
- "Images: only latest user images are sent; ask to reattach or describe prior images.",
354
- ].join("\n"),
355
- ];
373
+ const sectionsBeforeMessages: string[] = [getCursorToolBoundaryText()];
356
374
 
357
375
  if (context.systemPrompt) {
358
376
  sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
@@ -365,12 +383,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
365
383
  return text ? { index, text } : undefined;
366
384
  })
367
385
  .filter((section): section is { index: number; text: string } => section !== undefined);
368
- const sectionsAfterMessages = [
369
- [
370
- "Answer the latest user request above using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from the system prompt as if they were available.",
371
- "If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
372
- ].join("\n"),
373
- ];
386
+ const sectionsAfterMessages = getCursorBootstrapTailSections();
374
387
  const images = extractLatestImages(messages);
375
388
  const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
376
389
  const budgetOptions =
@@ -0,0 +1,172 @@
1
+ import { Agent, type AgentMessage } from "@cursor/sdk";
2
+ import { asRecord, getArray, getString, stringifyUnknown } from "./cursor-transcript-utils.js";
3
+
4
+ const CURSOR_AGENT_MESSAGE_PAGE_LIMIT = 8;
5
+
6
+ export interface CursorTranscriptCompletedToolCall {
7
+ identity: string;
8
+ toolCall: unknown;
9
+ }
10
+
11
+ interface CursorTranscriptWebToolPayload {
12
+ kind: "webSearch" | "webFetch";
13
+ payload: unknown;
14
+ }
15
+
16
+ function getOneofCaseValue(value: unknown, caseName: string): unknown {
17
+ const record = asRecord(value);
18
+ if (!record) return undefined;
19
+ if (record.case === caseName) return record.value;
20
+ return record[caseName];
21
+ }
22
+
23
+ async function hasCursorAgentMessageAt(agentId: string, cwd: string, offset: number): Promise<boolean> {
24
+ const messages = await Agent.messages.list(agentId, { runtime: "local", cwd, limit: 1, offset });
25
+ return messages.length > 0;
26
+ }
27
+
28
+ export async function countCursorAgentMessages(agentId: string, cwd: string): Promise<number> {
29
+ let high = 1;
30
+ while (await hasCursorAgentMessageAt(agentId, cwd, high)) {
31
+ high *= 2;
32
+ }
33
+
34
+ let low = 0;
35
+ while (low < high) {
36
+ const mid = Math.floor((low + high) / 2);
37
+ if (await hasCursorAgentMessageAt(agentId, cwd, mid)) low = mid + 1;
38
+ else high = mid;
39
+ }
40
+ return low;
41
+ }
42
+
43
+ export async function loadCursorTranscriptWebToolCallsAfterOffset(options: {
44
+ agentId: string;
45
+ cwd: string;
46
+ offset: number | undefined;
47
+ }): Promise<CursorTranscriptCompletedToolCall[]> {
48
+ if (options.offset === undefined) return [];
49
+ const messages = await Agent.messages.list(options.agentId, {
50
+ runtime: "local",
51
+ cwd: options.cwd,
52
+ limit: CURSOR_AGENT_MESSAGE_PAGE_LIMIT,
53
+ offset: options.offset,
54
+ });
55
+ return collectCursorTranscriptWebToolCalls(messages);
56
+ }
57
+
58
+ export function collectCursorTranscriptWebToolCalls(messages: readonly AgentMessage[]): CursorTranscriptCompletedToolCall[] {
59
+ const toolCalls: CursorTranscriptCompletedToolCall[] = [];
60
+ for (const [messageIndex, message] of messages.entries()) {
61
+ const messageId = message.uuid || `${message.agent_id || "cursor-agent"}:${messageIndex}`;
62
+ const steps = getAgentConversationSteps(message.message);
63
+ for (const [stepIndex, step] of steps.entries()) {
64
+ const webTool = getStepWebToolPayload(step);
65
+ if (!webTool) continue;
66
+ const converted = convertCursorTranscriptWebTool(webTool);
67
+ if (!converted) continue;
68
+ const args = asRecord(converted.args);
69
+ const toolCallId = getString(args, "toolCallId") ?? getString(args, "tool_call_id") ?? `${stepIndex}`;
70
+ toolCalls.push({
71
+ identity: `cursor-transcript:${messageId}:${webTool.kind}:${toolCallId}`,
72
+ toolCall: converted,
73
+ });
74
+ }
75
+ }
76
+ return toolCalls;
77
+ }
78
+
79
+ function getAgentConversationSteps(message: unknown): unknown[] {
80
+ const record = asRecord(message);
81
+ const turn = getOneofCaseValue(record?.turn, "agentConversationTurn") ?? record?.agentConversationTurn;
82
+ return getArray(asRecord(turn), "steps") ?? [];
83
+ }
84
+
85
+ function getStepToolCall(step: unknown): unknown {
86
+ const stepRecord = asRecord(step);
87
+ const message = asRecord(stepRecord?.message);
88
+ return getOneofCaseValue(message, "toolCall") ?? stepRecord?.toolCall ?? message?.toolCall;
89
+ }
90
+
91
+ function getStepWebToolPayload(step: unknown): CursorTranscriptWebToolPayload | undefined {
92
+ const toolCall = getStepToolCall(step);
93
+ const toolCallRecord = asRecord(toolCall);
94
+ const tool = toolCallRecord?.tool;
95
+ const webSearchPayload =
96
+ getOneofCaseValue(tool, "webSearchToolCall") ??
97
+ getOneofCaseValue(toolCall, "webSearchToolCall") ??
98
+ toolCallRecord?.webSearchToolCall;
99
+ if (webSearchPayload) return { kind: "webSearch", payload: webSearchPayload };
100
+
101
+ const webFetchPayload =
102
+ getOneofCaseValue(tool, "webFetchToolCall") ??
103
+ getOneofCaseValue(toolCall, "webFetchToolCall") ??
104
+ toolCallRecord?.webFetchToolCall;
105
+ if (webFetchPayload) return { kind: "webFetch", payload: webFetchPayload };
106
+ return undefined;
107
+ }
108
+
109
+ function convertCursorTranscriptWebTool(webTool: CursorTranscriptWebToolPayload): { name: string; args: Record<string, unknown>; result: unknown } | undefined {
110
+ const payload = asRecord(webTool.payload);
111
+ if (!payload) return undefined;
112
+ const rawArgs = asRecord(payload.args) ?? {};
113
+ const args = normalizeWebToolArgs(webTool.kind, rawArgs);
114
+ const result = normalizeWebToolResult(payload.result);
115
+ if (!result) return undefined;
116
+ return {
117
+ name: webTool.kind,
118
+ args,
119
+ result,
120
+ };
121
+ }
122
+
123
+ function normalizeWebToolArgs(kind: "webSearch" | "webFetch", rawArgs: Record<string, unknown>): Record<string, unknown> {
124
+ const args = { ...rawArgs };
125
+ if (kind === "webSearch") {
126
+ const query = getString(args, "searchTerm") ?? getString(args, "search_term") ?? getString(args, "query") ?? getString(args, "q");
127
+ if (query && !args.searchTerm) args.searchTerm = query;
128
+ return args;
129
+ }
130
+ const url = getString(args, "url") ?? getString(args, "uri") ?? getString(args, "href");
131
+ if (url && !args.url) args.url = url;
132
+ return args;
133
+ }
134
+
135
+ function normalizeWebToolResult(result: unknown): unknown | undefined {
136
+ if (result === undefined) return undefined;
137
+ const success = getTranscriptResultCase(result, "success");
138
+ if (success !== undefined) {
139
+ return {
140
+ status: "success",
141
+ value: { content: [{ type: "text", text: transcriptWebSuccessText(success) }] },
142
+ };
143
+ }
144
+
145
+ const error = getTranscriptResultCase(result, "error");
146
+ if (error !== undefined) return { status: "error", error };
147
+
148
+ return {
149
+ status: "success",
150
+ value: { content: [{ type: "text", text: transcriptWebSuccessText(result) }] },
151
+ };
152
+ }
153
+
154
+ function getTranscriptResultCase(result: unknown, caseName: "success" | "error"): unknown {
155
+ const record = asRecord(result);
156
+ return getOneofCaseValue(record?.result, caseName) ?? record?.[caseName];
157
+ }
158
+
159
+ function transcriptWebSuccessText(success: unknown): string {
160
+ const successRecord = asRecord(success);
161
+ const references = getArray(successRecord, "references");
162
+ const chunks = references
163
+ ?.map((reference) => getString(asRecord(reference), "chunk"))
164
+ .filter((chunk): chunk is string => Boolean(chunk?.trim()));
165
+ if (chunks && chunks.length > 0) return chunks.join("\n\n");
166
+ const content = getArray(successRecord, "content");
167
+ const text = content
168
+ ?.map((entry) => getString(asRecord(entry), "text"))
169
+ .filter((entry): entry is string => Boolean(entry?.trim()));
170
+ if (text && text.length > 0) return text.join("\n");
171
+ return stringifyUnknown(success).trim() || "Cursor web activity completed.";
172
+ }
@@ -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,118 @@
1
+ import {
2
+ CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
3
+ getCursorReplayDisplayLabel,
4
+ type CursorReplayLegacyToolName,
5
+ } from "./cursor-tool-names.js";
6
+ import { truncateCursorDisplayLine } from "./cursor-display-text.js";
7
+ import { scrubSensitiveText } from "./cursor-sensitive-text.js";
8
+ import {
9
+ DISCARDED_INCOMPLETE_TOOL_CALL_REASON,
10
+ type DiscardedIncompleteStartedToolCallReason,
11
+ } 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";
14
+
15
+ export type IncompleteCursorToolDiscardReason = DiscardedIncompleteStartedToolCallReason;
16
+
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
+ };
32
+
33
+ function buildGenericIncompleteActivityTitle(displayName: string): string {
34
+ if (!displayName || displayName === "unknown") return "Cursor tool";
35
+ return `Cursor ${truncateArg(displayName)}`;
36
+ }
37
+
38
+ export function formatIncompleteCursorToolReasonText(reason: IncompleteCursorToolDiscardReason): string {
39
+ switch (reason) {
40
+ case DISCARDED_INCOMPLETE_TOOL_CALL_REASON:
41
+ return "missing completion";
42
+ case "abort":
43
+ return "aborted";
44
+ case "sdk-failure":
45
+ return "SDK run failed";
46
+ case "run-drain":
47
+ return "run ended during drain";
48
+ }
49
+ }
50
+
51
+ 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
+ }
71
+ }
72
+
73
+ export function buildIncompleteCursorToolDisplay(
74
+ toolCall: unknown,
75
+ reason: IncompleteCursorToolDiscardReason,
76
+ options: { apiKey?: string } = {},
77
+ ): CursorPiToolDisplay {
78
+ const args = getToolArgs(toolCall);
79
+ const transcriptName = resolveTranscriptToolName(getToolName(toolCall), args);
80
+ const activityTitle = getIncompleteCursorToolActivityTitle(toolCall);
81
+ const headline = `${activityTitle} did not complete`;
82
+ const reasonText = scrubSensitiveText(formatIncompleteCursorToolReasonText(reason), options.apiKey);
83
+ const contentText = `${headline}\n${reasonText}`;
84
+ return {
85
+ toolName: CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
86
+ args: {
87
+ cursorToolName: normalizeToolName(transcriptName),
88
+ activityTitle,
89
+ activitySummary: reasonText,
90
+ incomplete: true,
91
+ },
92
+ result: {
93
+ content: [{ type: "text", text: contentText }],
94
+ details: {
95
+ cursorToolName: normalizeToolName(transcriptName),
96
+ title: headline,
97
+ summary: reasonText,
98
+ },
99
+ },
100
+ isError: true,
101
+ };
102
+ }
103
+
104
+ export function formatIncompleteCursorToolTrace(display: CursorPiToolDisplay): string {
105
+ const details = display.result.details;
106
+ const detailRecord = details && typeof details === "object" ? (details as Record<string, unknown>) : undefined;
107
+ const argsRecord = display.args;
108
+ const title =
109
+ (typeof detailRecord?.title === "string" && detailRecord.title.trim()) ||
110
+ (typeof argsRecord.activityTitle === "string" && argsRecord.activityTitle.trim()
111
+ ? `${argsRecord.activityTitle} did not complete`
112
+ : "Cursor tool did not complete");
113
+ const summary =
114
+ (typeof detailRecord?.summary === "string" && detailRecord.summary.trim()) ||
115
+ (typeof argsRecord.activitySummary === "string" && argsRecord.activitySummary.trim()) ||
116
+ formatIncompleteCursorToolReasonText(DISCARDED_INCOMPLETE_TOOL_CALL_REASON);
117
+ return `${truncateCursorDisplayLine(title)}: ${truncateCursorDisplayLine(summary)}\n`;
118
+ }
@@ -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
  }
@@ -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
+ }