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.
Files changed (51) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +38 -1
  3. package/docs/cursor-live-smoke-checklist.md +22 -2
  4. package/docs/cursor-model-ux-spec.md +5 -4
  5. package/docs/cursor-native-tool-replay.md +96 -2
  6. package/docs/cursor-testing-lessons.md +428 -0
  7. package/package.json +11 -2
  8. package/scripts/debug-provider-events.mjs +403 -0
  9. package/scripts/debug-sdk-events.mjs +413 -0
  10. package/scripts/isolated-cursor-smoke.sh +226 -0
  11. package/scripts/lib/cursor-probe-utils.mjs +52 -0
  12. package/scripts/lib/cursor-sdk-output-filter.mjs +86 -0
  13. package/scripts/validate-smoke-jsonl.mjs +86 -7
  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-context-tools.ts +6 -0
  18. package/src/cursor-display-text.ts +10 -0
  19. package/src/cursor-incomplete-tool-visibility.ts +118 -0
  20. package/src/cursor-live-run-coordinator.ts +18 -7
  21. package/src/cursor-model.ts +12 -0
  22. package/src/cursor-native-replay-routing.ts +48 -0
  23. package/src/cursor-native-replay-trace.ts +29 -0
  24. package/src/cursor-native-tool-display-registration.ts +14 -7
  25. package/src/cursor-native-tool-display-replay.ts +63 -5
  26. package/src/cursor-native-tool-display-tools.ts +20 -0
  27. package/src/cursor-pi-tool-bridge-diagnostics.ts +11 -1
  28. package/src/cursor-pi-tool-bridge-run.ts +16 -1
  29. package/src/cursor-pi-tool-bridge-types.ts +3 -0
  30. package/src/cursor-provider-errors.ts +96 -0
  31. package/src/cursor-provider-live-run-drain.ts +208 -63
  32. package/src/cursor-provider-turn-coordinator.ts +217 -47
  33. package/src/cursor-provider.ts +275 -83
  34. package/src/cursor-question-tool.ts +10 -5
  35. package/src/cursor-sdk-abort-error-guard.ts +109 -0
  36. package/src/cursor-sdk-event-debug-constants.ts +40 -0
  37. package/src/cursor-sdk-event-debug-session.ts +163 -0
  38. package/src/cursor-sdk-event-debug.ts +597 -0
  39. package/src/cursor-sensitive-text.ts +27 -7
  40. package/src/cursor-session-agent.ts +25 -3
  41. package/src/cursor-session-send-policy.ts +43 -0
  42. package/src/cursor-setting-sources.ts +29 -0
  43. package/src/cursor-state.ts +1 -5
  44. package/src/cursor-tool-lifecycle.ts +111 -0
  45. package/src/cursor-tool-names.ts +12 -0
  46. package/src/cursor-tool-transcript.ts +4 -2
  47. package/src/cursor-transcript-tool-formatters.ts +228 -5
  48. package/src/cursor-transcript-tool-specs.ts +113 -14
  49. package/src/cursor-transcript-utils.ts +12 -0
  50. package/src/cursor-web-tool-activity.ts +84 -0
  51. 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
- 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
+ }
@@ -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 CURSOR_REPLAY_TOOL_NAMES) {
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 (toolName === CURSOR_REPLAY_ACTIVITY_TOOL_NAME && activitySummary) return activitySummary;
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) return `${target} · ${diagnosticCount} diagnostic${diagnosticCount === 1 ? "" : "s"}`;
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 (typeof args?.path === "string") return args.path;
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";