pi-cursor-sdk 0.1.17 → 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.
- package/CHANGELOG.md +62 -0
- package/README.md +38 -1
- package/docs/cursor-live-smoke-checklist.md +22 -2
- package/docs/cursor-model-ux-spec.md +5 -4
- package/docs/cursor-native-tool-replay.md +96 -2
- package/docs/cursor-testing-lessons.md +428 -0
- package/package.json +11 -2
- package/scripts/debug-provider-events.mjs +403 -0
- package/scripts/debug-sdk-events.mjs +413 -0
- package/scripts/isolated-cursor-smoke.sh +226 -0
- package/scripts/lib/cursor-probe-utils.mjs +52 -0
- package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
- package/scripts/validate-smoke-jsonl.mjs +86 -7
- package/src/context.ts +45 -32
- package/src/cursor-agent-message-web-tools.ts +172 -0
- package/src/cursor-agents-context.ts +176 -0
- package/src/cursor-context-tools.ts +6 -0
- package/src/cursor-display-text.ts +10 -0
- package/src/cursor-incomplete-tool-visibility.ts +118 -0
- package/src/cursor-live-run-coordinator.ts +18 -7
- package/src/cursor-model.ts +12 -0
- package/src/cursor-native-replay-routing.ts +48 -0
- package/src/cursor-native-replay-trace.ts +29 -0
- package/src/cursor-native-tool-display-registration.ts +14 -7
- package/src/cursor-native-tool-display-replay.ts +63 -5
- package/src/cursor-native-tool-display-tools.ts +20 -0
- package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
- package/src/cursor-pi-tool-bridge-run.ts +16 -1
- package/src/cursor-pi-tool-bridge-types.ts +3 -0
- package/src/cursor-provider-errors.ts +96 -0
- package/src/cursor-provider-live-run-drain.ts +208 -63
- package/src/cursor-provider-turn-coordinator.ts +217 -47
- package/src/cursor-provider.ts +275 -83
- package/src/cursor-question-tool.ts +10 -5
- package/src/cursor-sdk-abort-error-guard.ts +109 -0
- package/src/cursor-sdk-event-debug-constants.ts +40 -0
- package/src/cursor-sdk-event-debug-session.ts +163 -0
- package/src/cursor-sdk-event-debug.ts +597 -0
- package/src/cursor-sensitive-text.ts +27 -7
- package/src/cursor-session-agent.ts +25 -3
- package/src/cursor-session-send-policy.ts +43 -0
- package/src/cursor-setting-sources.ts +29 -0
- package/src/cursor-state.ts +1 -5
- package/src/cursor-tool-lifecycle.ts +111 -0
- package/src/cursor-tool-names.ts +12 -0
- package/src/cursor-tool-transcript.ts +4 -2
- package/src/cursor-transcript-tool-formatters.ts +228 -5
- package/src/cursor-transcript-tool-specs.ts +113 -14
- package/src/cursor-transcript-utils.ts +12 -0
- package/src/cursor-web-tool-activity.ts +84 -0
- 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,6 @@
|
|
|
1
|
+
import type { Context } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
/** Tool names from the provider context snapshot at stream start (not live pi.getActiveTools()). */
|
|
4
|
+
export function getActiveContextToolNames(context: Context): ReadonlySet<string> | undefined {
|
|
5
|
+
return context.tools ? new Set(context.tools.map((tool) => tool.name)) : undefined;
|
|
6
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Canonical single-line sanitization and truncation for Cursor replay/trace display. */
|
|
2
|
+
export function sanitizeCursorDisplayLine(value: string): string {
|
|
3
|
+
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function truncateCursorDisplayLine(value: string, maxLength = 240): string {
|
|
7
|
+
const sanitized = sanitizeCursorDisplayLine(value);
|
|
8
|
+
if (sanitized.length <= maxLength) return sanitized;
|
|
9
|
+
return `${sanitized.slice(0, maxLength - 1)}…`;
|
|
10
|
+
}
|
|
@@ -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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { canRenderCursorToolNatively } from "./cursor-native-tool-display.js";
|
|
2
|
+
import { getActiveContextToolNames } from "./cursor-context-tools.js";
|
|
3
|
+
import type { Context } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
export type NativeReplayDisposition = "queue_replay" | "inactive_trace" | "transcript_trace";
|
|
6
|
+
|
|
7
|
+
export interface NativeReplayRoutingInput {
|
|
8
|
+
toolName: string;
|
|
9
|
+
useNativeToolReplay: boolean;
|
|
10
|
+
activeToolNames?: ReadonlySet<string>;
|
|
11
|
+
hasLiveRun: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isNativeToolActiveInContext(toolName: string, activeToolNames?: ReadonlySet<string>): boolean {
|
|
15
|
+
return activeToolNames === undefined || activeToolNames.has(toolName);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Canonical native replay routing for coordinator and live-run drain.
|
|
20
|
+
* Extension resync (pi active tools) is separate; this uses context.tools snapshot only.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveNativeReplayDisposition(input: NativeReplayRoutingInput): NativeReplayDisposition {
|
|
23
|
+
if (!input.useNativeToolReplay || !canRenderCursorToolNatively(input.toolName)) {
|
|
24
|
+
return "transcript_trace";
|
|
25
|
+
}
|
|
26
|
+
if (isNativeToolActiveInContext(input.toolName, input.activeToolNames) && input.hasLiveRun) {
|
|
27
|
+
return "queue_replay";
|
|
28
|
+
}
|
|
29
|
+
if (!isNativeToolActiveInContext(input.toolName, input.activeToolNames)) {
|
|
30
|
+
return "inactive_trace";
|
|
31
|
+
}
|
|
32
|
+
return "transcript_trace";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function partitionNativeToolsByActiveContext<T extends { toolName: string }>(
|
|
36
|
+
context: Context,
|
|
37
|
+
tools: readonly T[],
|
|
38
|
+
): { active: T[]; inactive: T[] } {
|
|
39
|
+
const activeToolNames = getActiveContextToolNames(context);
|
|
40
|
+
if (!activeToolNames) return { active: [...tools], inactive: [] };
|
|
41
|
+
const active: T[] = [];
|
|
42
|
+
const inactive: T[] = [];
|
|
43
|
+
for (const tool of tools) {
|
|
44
|
+
if (activeToolNames.has(tool.toolName)) active.push(tool);
|
|
45
|
+
else inactive.push(tool);
|
|
46
|
+
}
|
|
47
|
+
return { active, inactive };
|
|
48
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CursorPiToolDisplay } from "./cursor-tool-transcript.js";
|
|
2
|
+
import { asRecord } from "./cursor-record-utils.js";
|
|
3
|
+
import { truncateCursorDisplayLine } from "./cursor-display-text.js";
|
|
4
|
+
|
|
5
|
+
function getCursorReplayResultText(display: CursorPiToolDisplay): string | undefined {
|
|
6
|
+
for (const content of display.result.content) {
|
|
7
|
+
if (content.type !== "text") continue;
|
|
8
|
+
const text = truncateCursorDisplayLine(content.text);
|
|
9
|
+
if (text) return text;
|
|
10
|
+
}
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Unified inactive native-replay fallback: `title: summary` in thinking trace. */
|
|
15
|
+
export function formatInactiveCursorReplayTrace(display: CursorPiToolDisplay): string {
|
|
16
|
+
const details = asRecord(display.result.details);
|
|
17
|
+
const args = asRecord(display.args);
|
|
18
|
+
const title = typeof details?.title === "string" && details.title.trim()
|
|
19
|
+
? details.title.trim()
|
|
20
|
+
: typeof args?.activityTitle === "string" && args.activityTitle.trim()
|
|
21
|
+
? args.activityTitle.trim()
|
|
22
|
+
: `Cursor ${display.toolName}`;
|
|
23
|
+
const summary = typeof details?.summary === "string" && details.summary.trim()
|
|
24
|
+
? details.summary.trim()
|
|
25
|
+
: typeof args?.activitySummary === "string" && args.activitySummary.trim()
|
|
26
|
+
? args.activitySummary.trim()
|
|
27
|
+
: getCursorReplayResultText(display) ?? "completed";
|
|
28
|
+
return `${truncateCursorDisplayLine(title)}: ${truncateCursorDisplayLine(summary)}\n`;
|
|
29
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext, ExtensionHandler, SessionStartEvent } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext, ExtensionHandler, SessionStartEvent, TurnStartEvent } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import {
|
|
3
3
|
CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES,
|
|
4
|
-
CURSOR_REPLAY_TOOL_NAMES,
|
|
5
4
|
isNativeCursorToolName,
|
|
6
5
|
NATIVE_CURSOR_TOOL_NAMES,
|
|
7
6
|
registerNativeCursorTool,
|
|
8
7
|
type NativeCursorToolName,
|
|
9
8
|
} from "./cursor-native-tool-display-tools.js";
|
|
9
|
+
import { isCursorModel } from "./cursor-model.js";
|
|
10
10
|
import {
|
|
11
11
|
isCursorNativeToolDisplayRequested,
|
|
12
12
|
isCursorNativeToolRegistrationRequested,
|
|
@@ -16,10 +16,14 @@ import {
|
|
|
16
16
|
} from "./cursor-native-tool-display-state.js";
|
|
17
17
|
import { isCursorReplayToolName } from "./cursor-tool-names.js";
|
|
18
18
|
|
|
19
|
+
const CORE_PI_TOOL_NAMES = new Set(["read", "bash", "edit", "write"]);
|
|
20
|
+
|
|
19
21
|
type CursorNativeToolRegistryApi = Pick<ExtensionAPI, "getActiveTools" | "getAllTools" | "registerTool" | "setActiveTools">;
|
|
20
22
|
|
|
21
23
|
export interface CursorNativeToolDisplayExtensionApi extends CursorNativeToolRegistryApi {
|
|
22
24
|
on(event: "session_start", handler: ExtensionHandler<SessionStartEvent>): void;
|
|
25
|
+
on(event: "before_agent_start", handler: ExtensionHandler<BeforeAgentStartEvent>): void;
|
|
26
|
+
on(event: "turn_start", handler: ExtensionHandler<TurnStartEvent>): void;
|
|
23
27
|
on(event: "model_select", handler: (event: { model: ExtensionContext["model"] }, ctx: ExtensionContext) => Promise<void> | void): void;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -30,10 +34,6 @@ function hasNonBuiltinTool(pi: Pick<ExtensionAPI, "getAllTools">, toolName: Nati
|
|
|
30
34
|
|
|
31
35
|
type NativeRegistrationContext = { hasUI: boolean; ui: Pick<ExtensionContext["ui"], "notify">; model?: ExtensionContext["model"] };
|
|
32
36
|
|
|
33
|
-
function isCursorModel(model: ExtensionContext["model"]): boolean {
|
|
34
|
-
return model?.provider === "cursor" || model?.api === "cursor-sdk";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
37
|
export function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
|
|
38
38
|
if (registeredNativeToolNames.size === 0) return;
|
|
39
39
|
const activeToolNames = new Set(pi.getActiveTools());
|
|
@@ -46,7 +46,8 @@ export function syncRegisteredNativeCursorToolsForModel(pi: Pick<ExtensionAPI, "
|
|
|
46
46
|
changed = true;
|
|
47
47
|
}
|
|
48
48
|
} else {
|
|
49
|
-
for (const toolName of
|
|
49
|
+
for (const toolName of registeredNativeToolNames) {
|
|
50
|
+
if (CORE_PI_TOOL_NAMES.has(toolName)) continue;
|
|
50
51
|
if (!activeToolNames.delete(toolName)) continue;
|
|
51
52
|
changed = true;
|
|
52
53
|
}
|
|
@@ -85,6 +86,12 @@ export function registerCursorNativeToolDisplay(pi: CursorNativeToolDisplayExten
|
|
|
85
86
|
pi.on("session_start", (_event, ctx) => {
|
|
86
87
|
registerAvailableNativeCursorTools(pi, ctx);
|
|
87
88
|
});
|
|
89
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
90
|
+
syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
|
|
91
|
+
});
|
|
92
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
93
|
+
syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
|
|
94
|
+
});
|
|
88
95
|
pi.on("model_select", (event) => {
|
|
89
96
|
syncRegisteredNativeCursorToolsForModel(pi, event.model);
|
|
90
97
|
});
|
|
@@ -4,6 +4,7 @@ import { getLanguageFromPath, highlightCode, type ToolDefinition } from "@earend
|
|
|
4
4
|
import { Image, Text, type Component } from "@earendil-works/pi-tui";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { resolveCursorEditDiff } from "./cursor-edit-diff.js";
|
|
7
|
+
import { LOCAL_READ_PREVIEW_NOTICE, isLocalReadPreviewContent } from "./cursor-transcript-utils.js";
|
|
7
8
|
import {
|
|
8
9
|
CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
|
|
9
10
|
getCursorReplayDisplayLabel,
|
|
@@ -258,9 +259,27 @@ function getCursorReplayActivityTitle(toolName: CursorReplayToolName, args: Reco
|
|
|
258
259
|
return getCursorReplayToolLabel(toolName);
|
|
259
260
|
}
|
|
260
261
|
|
|
262
|
+
function formatReplayRecordingDurationMs(ms: number | undefined): string | undefined {
|
|
263
|
+
if (ms === undefined || !Number.isFinite(ms) || ms < 0) return undefined;
|
|
264
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
265
|
+
const seconds = ms / 1000;
|
|
266
|
+
return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formatReplaySemSearchQuery(args: Record<string, unknown> | undefined): string | undefined {
|
|
270
|
+
const query = typeof args?.query === "string" ? args.query.trim() : undefined;
|
|
271
|
+
if (!query) return undefined;
|
|
272
|
+
const targetDirectories = Array.isArray(args?.targetDirectories)
|
|
273
|
+
? args.targetDirectories.filter((entry): entry is string => typeof entry === "string")
|
|
274
|
+
: [];
|
|
275
|
+
const dirHint =
|
|
276
|
+
targetDirectories.length > 0 ? ` (${targetDirectories.length} dir${targetDirectories.length === 1 ? "" : "s"})` : "";
|
|
277
|
+
return `${query}${dirHint}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
261
280
|
function getCursorReplayCallSummary(toolName: CursorReplayToolName, args: Record<string, unknown> | undefined): string | undefined {
|
|
262
281
|
const activitySummary = typeof args?.activitySummary === "string" && args.activitySummary.trim() ? args.activitySummary.trim() : undefined;
|
|
263
|
-
if (
|
|
282
|
+
if (activitySummary) return activitySummary;
|
|
264
283
|
|
|
265
284
|
const path = typeof args?.path === "string" ? args.path : undefined;
|
|
266
285
|
const description = typeof args?.description === "string" ? args.description : undefined;
|
|
@@ -271,19 +290,33 @@ function getCursorReplayCallSummary(toolName: CursorReplayToolName, args: Record
|
|
|
271
290
|
|
|
272
291
|
if (toolName === "cursor_edit" || toolName === "cursor_write" || toolName === "cursor_delete") return path ?? "unknown";
|
|
273
292
|
if (toolName === "cursor_read_lints") {
|
|
274
|
-
const target = paths.length > 0 ? paths.join(" ") : path;
|
|
275
|
-
if (target && diagnosticCount !== undefined)
|
|
293
|
+
const target = paths.length > 0 ? paths.join(", ") : path;
|
|
294
|
+
if (target && diagnosticCount !== undefined) {
|
|
295
|
+
return `${diagnosticCount} diagnostic${diagnosticCount === 1 ? "" : "s"} in ${target}`;
|
|
296
|
+
}
|
|
276
297
|
return target;
|
|
277
298
|
}
|
|
278
299
|
if (toolName === "cursor_update_todos" || toolName === "cursor_create_plan") {
|
|
279
300
|
return totalCount !== undefined ? `${totalCount} item${totalCount === 1 ? "" : "s"}` : undefined;
|
|
280
301
|
}
|
|
281
302
|
if (toolName === "cursor_task") return description;
|
|
282
|
-
if (toolName === "cursor_generate_image") return prompt;
|
|
303
|
+
if (toolName === "cursor_generate_image") return path ?? prompt;
|
|
283
304
|
if (toolName === "cursor_mcp") return typeof args?.toolName === "string" ? args.toolName : undefined;
|
|
305
|
+
if (toolName === "cursor_sem_search") return formatReplaySemSearchQuery(args);
|
|
306
|
+
if (toolName === "cursor_record_screen") {
|
|
307
|
+
const duration = formatReplayRecordingDurationMs(
|
|
308
|
+
typeof args?.recordingDurationMs === "number" ? args.recordingDurationMs : undefined,
|
|
309
|
+
);
|
|
310
|
+
if (path && duration) return `${path} · ${duration}`;
|
|
311
|
+
if (path) return path;
|
|
312
|
+
if (typeof args?.mode === "string") return args.mode;
|
|
313
|
+
}
|
|
314
|
+
if (toolName === "cursor_web_search") return typeof args?.query === "string" ? args.query : undefined;
|
|
315
|
+
if (toolName === "cursor_web_fetch") return typeof args?.url === "string" ? args.url : undefined;
|
|
284
316
|
if (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME) {
|
|
285
|
-
if (
|
|
317
|
+
if (path) return path;
|
|
286
318
|
if (typeof args?.toolName === "string") return args.toolName;
|
|
319
|
+
return formatReplaySemSearchQuery(args);
|
|
287
320
|
}
|
|
288
321
|
return undefined;
|
|
289
322
|
}
|
|
@@ -440,6 +473,31 @@ export function renderCursorReplayResult(
|
|
|
440
473
|
return new Text(text || theme.fg("success", "Cursor tool result replayed"), 0, 0);
|
|
441
474
|
}
|
|
442
475
|
|
|
476
|
+
export function renderNativeLookingCursorReadReplayResult(
|
|
477
|
+
result: Parameters<CursorReplayRenderResult>[0],
|
|
478
|
+
options: Parameters<CursorReplayRenderResult>[1],
|
|
479
|
+
theme: Parameters<CursorReplayRenderResult>[2],
|
|
480
|
+
context: Parameters<CursorReplayRenderResult>[3],
|
|
481
|
+
renderBase: () => Component | undefined,
|
|
482
|
+
): Component {
|
|
483
|
+
const base = renderBase?.() ?? new Text("", 0, 0);
|
|
484
|
+
const readArgs = context.args as Record<string, unknown> | undefined;
|
|
485
|
+
const replayDetails = result.details as Record<string, unknown> | undefined;
|
|
486
|
+
const usesLocalPreview =
|
|
487
|
+
readArgs?.localReadPreview === true ||
|
|
488
|
+
replayDetails?.localReadPreview === true ||
|
|
489
|
+
isLocalReadPreviewContent(firstContentText(result));
|
|
490
|
+
if (usesLocalPreview && !options.expanded && !context.isError) {
|
|
491
|
+
const noticeText = `\n${theme.fg("warning", LOCAL_READ_PREVIEW_NOTICE)}`;
|
|
492
|
+
if (base instanceof Text) {
|
|
493
|
+
base.setText(noticeText);
|
|
494
|
+
return base;
|
|
495
|
+
}
|
|
496
|
+
return new Text(noticeText, 0, 0);
|
|
497
|
+
}
|
|
498
|
+
return base;
|
|
499
|
+
}
|
|
500
|
+
|
|
443
501
|
export function createCursorReplayOnlyToolDefinition(toolName: CursorReplayToolName): ToolDefinition<typeof cursorReplayToolSchema, unknown> {
|
|
444
502
|
const cursorToolName = toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME ? "activity" : getCursorReplaySourceToolName(toolName);
|
|
445
503
|
const sideEffectDescription = toolName === "cursor_edit" || toolName === "cursor_write" || toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME ? "file mutations" : "real tool work";
|