pi-cursor-sdk 0.2.0 → 0.3.7

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 (177) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +55 -51
  3. package/dist/bundled-context-windows.js +39 -0
  4. package/dist/context-window-cache.js +92 -0
  5. package/dist/context.js +353 -0
  6. package/dist/cursor-active-tools.js +3 -0
  7. package/dist/cursor-agent-message-web-tools.js +165 -0
  8. package/dist/cursor-agents-context-registration.js +17 -0
  9. package/dist/cursor-agents-context.js +127 -0
  10. package/dist/cursor-api-key.js +34 -0
  11. package/dist/cursor-bridge-contract.js +27 -0
  12. package/dist/cursor-cloud-lifecycle.js +650 -0
  13. package/dist/cursor-cloud-local-state.js +460 -0
  14. package/dist/cursor-cloud-options.js +145 -0
  15. package/dist/cursor-cloud-reporting.js +222 -0
  16. package/dist/cursor-compact-tool-summary.js +75 -0
  17. package/dist/cursor-config.js +613 -0
  18. package/dist/cursor-context-tools.js +6 -0
  19. package/dist/cursor-display-only-trace.js +11 -0
  20. package/dist/cursor-display-text.js +18 -0
  21. package/dist/cursor-durable-fs.js +53 -0
  22. package/dist/cursor-edit-diff.js +13 -0
  23. package/dist/cursor-env-boolean.js +19 -0
  24. package/dist/cursor-fallback-models.generated.js +7760 -0
  25. package/dist/cursor-fallback-warning.js +15 -0
  26. package/dist/cursor-http1.js +45 -0
  27. package/dist/cursor-incomplete-tool-visibility.js +83 -0
  28. package/dist/cursor-live-run-accounting.js +55 -0
  29. package/dist/cursor-live-run-coordinator.js +409 -0
  30. package/dist/cursor-mcp-timeout-override.js +116 -0
  31. package/dist/cursor-model-lifecycle.js +32 -0
  32. package/dist/cursor-model.js +5 -0
  33. package/dist/cursor-native-replay-routing.js +35 -0
  34. package/dist/cursor-native-replay-trace.js +28 -0
  35. package/dist/cursor-native-tool-display-registration.js +102 -0
  36. package/dist/cursor-native-tool-display-replay.js +428 -0
  37. package/dist/cursor-native-tool-display-state.js +70 -0
  38. package/dist/cursor-native-tool-display-tools.js +123 -0
  39. package/dist/cursor-native-tool-names.js +11 -0
  40. package/dist/cursor-partial-content-emitter.js +130 -0
  41. package/dist/cursor-pi-context.js +30 -0
  42. package/dist/cursor-pi-tool-bridge-abort.js +114 -0
  43. package/dist/cursor-pi-tool-bridge-constants.js +6 -0
  44. package/dist/cursor-pi-tool-bridge-diagnostics.js +125 -0
  45. package/dist/cursor-pi-tool-bridge-env.js +18 -0
  46. package/dist/cursor-pi-tool-bridge-mcp.js +100 -0
  47. package/dist/cursor-pi-tool-bridge-run.js +355 -0
  48. package/dist/cursor-pi-tool-bridge-server.js +163 -0
  49. package/dist/cursor-pi-tool-bridge-snapshot.js +63 -0
  50. package/dist/cursor-pi-tool-bridge-types.js +1 -0
  51. package/dist/cursor-pi-tool-bridge.js +123 -0
  52. package/dist/cursor-provider-errors.js +311 -0
  53. package/dist/cursor-provider-lazy.js +39 -0
  54. package/dist/cursor-provider-live-run-drain.js +420 -0
  55. package/dist/cursor-provider-overflow.js +90 -0
  56. package/dist/cursor-provider-run-finalizer.js +176 -0
  57. package/dist/cursor-provider-run-outcome.js +76 -0
  58. package/dist/cursor-provider-turn-coordinator.js +293 -0
  59. package/dist/cursor-provider-turn-display-router.js +158 -0
  60. package/dist/cursor-provider-turn-emit.js +41 -0
  61. package/dist/cursor-provider-turn-finalize.js +154 -0
  62. package/dist/cursor-provider-turn-lifecycle-emitter.js +126 -0
  63. package/dist/cursor-provider-turn-prepare.js +379 -0
  64. package/dist/cursor-provider-turn-runner.js +146 -0
  65. package/dist/cursor-provider-turn-sdk-normalizer.js +74 -0
  66. package/dist/cursor-provider-turn-send.js +138 -0
  67. package/dist/cursor-provider-turn-shell-output.js +117 -0
  68. package/dist/cursor-provider-turn-tool-ledger.js +99 -0
  69. package/dist/cursor-provider-turn-types.js +1 -0
  70. package/dist/cursor-provider.js +72 -0
  71. package/dist/cursor-question-tool.js +194 -0
  72. package/dist/cursor-record-utils.js +61 -0
  73. package/dist/cursor-replay-activity-builders.js +114 -0
  74. package/dist/cursor-replay-source-names.js +27 -0
  75. package/dist/cursor-replay-summary-args.js +105 -0
  76. package/dist/cursor-replay-tool-details.js +167 -0
  77. package/dist/cursor-ripgrep-path.js +27 -0
  78. package/dist/cursor-run-final-text.js +63 -0
  79. package/dist/cursor-runtime-state.js +380 -0
  80. package/dist/cursor-sdk-billed-usage.js +86 -0
  81. package/dist/cursor-sdk-event-debug-constants.js +33 -0
  82. package/dist/cursor-sdk-event-debug-session.js +107 -0
  83. package/dist/cursor-sdk-event-debug.js +461 -0
  84. package/dist/cursor-sdk-output-filter.js +1 -0
  85. package/dist/cursor-sdk-process-error-guard.js +221 -0
  86. package/dist/cursor-sdk-runtime.js +3 -0
  87. package/dist/cursor-sensitive-text.js +23 -0
  88. package/dist/cursor-session-agent-cleanup.js +311 -0
  89. package/dist/cursor-session-agent-lifecycle.js +32 -0
  90. package/dist/cursor-session-agent-lineage.js +103 -0
  91. package/dist/cursor-session-agent-resume.js +417 -0
  92. package/dist/cursor-session-agent.js +507 -0
  93. package/dist/cursor-session-compaction-prep.js +21 -0
  94. package/dist/cursor-session-scope.js +113 -0
  95. package/dist/cursor-session-send-policy.js +18 -0
  96. package/dist/cursor-session-store.js +105 -0
  97. package/dist/cursor-session-turn-queue.js +50 -0
  98. package/dist/cursor-setting-sources.js +15 -0
  99. package/dist/cursor-skill-tool.js +230 -0
  100. package/dist/cursor-state.js +519 -0
  101. package/dist/cursor-task-presentation.js +59 -0
  102. package/dist/cursor-tool-lifecycle.js +94 -0
  103. package/dist/cursor-tool-manifest.js +34 -0
  104. package/dist/cursor-tool-presentation-registry.js +305 -0
  105. package/dist/cursor-tool-result-display-readers.js +158 -0
  106. package/dist/cursor-tool-transcript.js +32 -0
  107. package/dist/cursor-tool-visibility.js +28 -0
  108. package/dist/cursor-transcript-tool-formatters.js +618 -0
  109. package/dist/cursor-transcript-tool-specs.js +284 -0
  110. package/dist/cursor-transcript-utils.js +170 -0
  111. package/dist/cursor-usage-accounting.js +166 -0
  112. package/dist/cursor-web-tool-activity.js +23 -0
  113. package/dist/cursor-web-tool-args.js +12 -0
  114. package/dist/index.js +82 -0
  115. package/dist/model-discovery.js +305 -0
  116. package/dist/model-list-cache.js +156 -0
  117. package/docs/cursor-dogfood-checklist.md +2 -1
  118. package/docs/cursor-live-smoke-checklist.md +13 -12
  119. package/docs/cursor-model-ux-spec.md +27 -6
  120. package/docs/cursor-native-tool-replay.md +13 -13
  121. package/docs/cursor-native-tool-visual-audit.md +6 -3
  122. package/docs/cursor-testing-lessons.md +14 -8
  123. package/docs/cursor-tool-surfaces.md +5 -5
  124. package/docs/platform-smoke.md +3 -3
  125. package/package.json +19 -7
  126. package/platform-smoke.config.mjs +1 -1
  127. package/scripts/build.mjs +126 -0
  128. package/scripts/cloud-runtime-smoke.mjs +3 -1
  129. package/scripts/debug-provider-events.mjs +3 -1
  130. package/scripts/debug-sdk-events.mjs +1 -1
  131. package/scripts/isolated-cursor-smoke.sh +5 -4
  132. package/scripts/lib/cloud-smoke-cleanup-evidence.mjs +1 -1
  133. package/scripts/lib/cursor-visual-render.mjs +1 -1
  134. package/scripts/lib/ensure-built.mjs +16 -0
  135. package/scripts/lib/local-resume-smoke-harness.mjs +1 -1
  136. package/scripts/local-resume-smoke.mjs +3 -1
  137. package/scripts/platform-smoke/card-detect.mjs +1 -1
  138. package/scripts/platform-smoke/live-suite-runner.mjs +1 -1
  139. package/scripts/platform-smoke/render-ansi.mjs +1 -1
  140. package/scripts/platform-smoke/targets.mjs +2 -2
  141. package/scripts/prepare.mjs +102 -0
  142. package/scripts/steering-rpc-smoke.mjs +4 -2
  143. package/scripts/tmux-live-smoke.sh +11 -11
  144. package/scripts/visual-tui-smoke-self-test.mjs +4 -1
  145. package/scripts/visual-tui-smoke.mjs +51 -7
  146. package/src/bundled-context-windows.ts +4 -1
  147. package/src/context.ts +27 -4
  148. package/src/cursor-agents-context-registration.ts +2 -2
  149. package/src/cursor-agents-context.ts +24 -10
  150. package/src/cursor-api-key.ts +5 -4
  151. package/src/cursor-cloud-reporting.ts +23 -2
  152. package/src/cursor-context-tools.ts +3 -1
  153. package/src/cursor-fallback-models.generated.ts +592 -204
  154. package/src/cursor-live-run-coordinator.ts +11 -6
  155. package/src/cursor-native-tool-display-registration.ts +10 -11
  156. package/src/cursor-partial-content-emitter.ts +13 -0
  157. package/src/cursor-pi-context.ts +44 -0
  158. package/src/cursor-provider-errors.ts +1 -1
  159. package/src/cursor-provider-lazy.ts +5 -4
  160. package/src/cursor-provider-live-run-drain.ts +14 -9
  161. package/src/cursor-provider-run-finalizer.ts +1 -0
  162. package/src/cursor-provider-turn-coordinator.ts +15 -0
  163. package/src/cursor-provider-turn-emit.ts +6 -0
  164. package/src/cursor-provider-turn-finalize.ts +12 -0
  165. package/src/cursor-provider-turn-prepare.ts +10 -5
  166. package/src/cursor-provider-turn-types.ts +2 -0
  167. package/src/cursor-sdk-billed-usage.ts +107 -0
  168. package/src/cursor-sdk-process-error-guard.ts +6 -4
  169. package/src/cursor-session-agent-lifecycle.ts +8 -9
  170. package/src/cursor-session-agent-resume.ts +23 -0
  171. package/src/cursor-session-compaction-prep.ts +4 -0
  172. package/src/cursor-skill-tool.ts +1 -1
  173. package/src/cursor-tool-manifest.ts +1 -1
  174. package/src/cursor-usage-accounting.ts +70 -9
  175. package/src/index.ts +1 -1
  176. package/tsconfig.build.json +8 -0
  177. package/tsconfig.json +16 -0
@@ -1,4 +1,5 @@
1
1
  import type { Context, ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import { getCursorConversationMessages } from "./cursor-pi-context.js";
2
3
  import type { SDKAgent } from "@cursor/sdk";
3
4
  import {
4
5
  consumeCursorLiveToolResults,
@@ -27,6 +28,7 @@ export type CursorLiveQueuedEvent =
27
28
  | { type: "thinking-delta"; text: string }
28
29
  | { type: "thinking-completed" }
29
30
  | { type: "text-delta"; text: string }
31
+ | { type: "text-completed" }
30
32
  | { type: "tool"; tool: CursorNativeToolDisplayItem }
31
33
  | { type: "bridge-tool"; request: CursorPiBridgeToolRequest };
32
34
 
@@ -43,6 +45,7 @@ export interface CursorLiveRun {
43
45
  sdkRun?: CursorLiveSdkRun;
44
46
  ignoreFutureSdkTurnUsage?: boolean;
45
47
  accounting: CursorLiveRunAccountingState;
48
+ billedTurnUsage?: CursorSdkTurnUsage;
46
49
  pendingEvents: CursorLiveQueuedEvent[];
47
50
  textDeltas: string[];
48
51
  emittedText: string;
@@ -154,16 +157,17 @@ interface CursorLiveRunPrivateState {
154
157
  }
155
158
 
156
159
  export function hasTrailingUserMessagesAfterToolResults(context: Context): boolean {
157
- let index = context.messages.length - 1;
160
+ const messages = getCursorConversationMessages(context);
161
+ let index = messages.length - 1;
158
162
  let sawTrailingUser = false;
159
- while (index >= 0 && context.messages[index]?.role === "user") {
163
+ while (index >= 0 && messages[index]?.role === "user") {
160
164
  sawTrailingUser = true;
161
165
  index -= 1;
162
166
  }
163
167
  if (!sawTrailingUser) return false;
164
168
 
165
169
  let sawToolResult = false;
166
- while (index >= 0 && context.messages[index]?.role === "toolResult") {
170
+ while (index >= 0 && messages[index]?.role === "toolResult") {
167
171
  sawToolResult = true;
168
172
  index -= 1;
169
173
  }
@@ -417,13 +421,14 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
417
421
  },
418
422
 
419
423
  getPendingFromContext(context, getReplayId): CursorLiveRun | undefined {
420
- let index = context.messages.length - 1;
421
- while (index >= 0 && context.messages[index]?.role === "user") {
424
+ const messages = getCursorConversationMessages(context);
425
+ let index = messages.length - 1;
426
+ while (index >= 0 && messages[index]?.role === "user") {
422
427
  index -= 1;
423
428
  }
424
429
 
425
430
  for (; index >= 0; index -= 1) {
426
- const message = context.messages[index];
431
+ const message = messages[index];
427
432
  if (message.role !== "toolResult") break;
428
433
  const replayId = getReplayId(message.toolCallId);
429
434
  if (replayId) {
@@ -18,6 +18,7 @@ import {
18
18
  skippedNativeToolNames,
19
19
  } from "./cursor-native-tool-display-state.js";
20
20
  import { isCursorReplayToolName } from "./cursor-tool-presentation-registry.js";
21
+ import { registerNativeCursorTool } from "./cursor-native-tool-display-tools.js";
21
22
 
22
23
  export const CURSOR_CORE_PI_REPLAY_TOOL_NAMES = ["read", "bash", "edit", "write"] as const;
23
24
  const CORE_PI_TOOL_NAMES = new Set<string>(CURSOR_CORE_PI_REPLAY_TOOL_NAMES);
@@ -40,12 +41,11 @@ type NativeRegistrationContext = Pick<ExtensionContext, "mode" | "model"> & {
40
41
  ui: Pick<ExtensionContext["ui"], "notify">;
41
42
  };
42
43
 
43
- async function registerNativeCursorToolsFromSet(
44
+ function registerNativeCursorToolsFromSet(
44
45
  pi: CursorNativeToolRegistryApi,
45
46
  toolNames: readonly NativeCursorToolName[],
46
- ): Promise<NativeCursorToolName[]> {
47
+ ): NativeCursorToolName[] {
47
48
  const newlySkippedToolNames: NativeCursorToolName[] = [];
48
- let registerNativeCursorTool: ((pi: CursorNativeToolRegistryApi, toolName: NativeCursorToolName) => void) | undefined;
49
49
  for (const toolName of toolNames) {
50
50
  if (registeredNativeToolNames.has(toolName) || skippedNativeToolNames.has(toolName)) continue;
51
51
  if (hasNonBuiltinTool(pi, toolName)) {
@@ -53,7 +53,6 @@ async function registerNativeCursorToolsFromSet(
53
53
  newlySkippedToolNames.push(toolName);
54
54
  continue;
55
55
  }
56
- registerNativeCursorTool ??= (await import("./cursor-native-tool-display-tools.js")).registerNativeCursorTool;
57
56
  registerNativeCursorTool(pi, toolName);
58
57
  registeredNativeToolNames.add(toolName);
59
58
  }
@@ -105,31 +104,31 @@ export function syncRegisteredNativeCursorToolsForModel(
105
104
  if (changed) pi.setActiveTools([...activeToolNames]);
106
105
  }
107
106
 
108
- async function ensureNativeCursorToolsRegisteredForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): Promise<void> {
107
+ function ensureNativeCursorToolsRegisteredForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
109
108
  if (!isCursorModel(ctx.model) || hasAttemptedNativeCursorToolRegistration()) return;
110
109
 
111
110
  const nonCoreToolNames = NATIVE_CURSOR_TOOL_NAMES.filter((toolName) => !isCursorCorePiReplayToolName(toolName));
112
111
  const skippedToolNames = [
113
- ...(await registerNativeCursorToolsFromSet(pi, nonCoreToolNames)),
114
- ...(await registerNativeCursorToolsFromSet(pi, CURSOR_CORE_PI_REPLAY_TOOL_NAMES)),
112
+ ...registerNativeCursorToolsFromSet(pi, nonCoreToolNames),
113
+ ...registerNativeCursorToolsFromSet(pi, CURSOR_CORE_PI_REPLAY_TOOL_NAMES),
115
114
  ];
116
115
  notifySkippedNativeCursorToolsIfNeeded(ctx, skippedToolNames);
117
116
  }
118
117
 
119
- async function ensureThenSyncNativeCursorToolsForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): Promise<void> {
118
+ function ensureThenSyncNativeCursorToolsForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): void {
120
119
  const requested = isCursorNativeToolRegistrationRequested(ctx.mode);
121
120
  setCursorNativeToolDisplayRuntimeRequested(requested);
122
121
  if (!requested) {
123
122
  removeRegisteredNonCoreNativeCursorTools(pi);
124
123
  return;
125
124
  }
126
- await ensureNativeCursorToolsRegisteredForModel(pi, ctx);
125
+ ensureNativeCursorToolsRegisteredForModel(pi, ctx);
127
126
  syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
128
127
  }
129
128
 
130
129
  export function registerCursorNativeToolDisplay(pi: CursorNativeToolDisplayExtensionApi): void {
131
- registerCursorModelLifecycle(pi, async (ctx) => {
132
- await ensureThenSyncNativeCursorToolsForModel(pi, ctx);
130
+ registerCursorModelLifecycle(pi, (ctx) => {
131
+ ensureThenSyncNativeCursorToolsForModel(pi, ctx);
133
132
  });
134
133
  }
135
134
 
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
2
2
 
3
3
  const DEFAULT_THINKING_TRACE_MAX_CHARS = 50000;
4
+ export const CURSOR_TEXT_MESSAGE_SEPARATOR = "\n\n";
4
5
 
5
6
  export interface CursorPartialContentEmitterOptions {
6
7
  stream: AssistantMessageEventStream;
@@ -12,6 +13,7 @@ export interface CursorPartialContentEmitterOptions {
12
13
  export class CursorPartialContentEmitter {
13
14
  private thinkingContentIndex = -1;
14
15
  private textContentIndex = -1;
16
+ private textMessageCompleted = false;
15
17
  private activityTraceChars = 0;
16
18
  private activityTraceTruncated = false;
17
19
 
@@ -86,10 +88,21 @@ export class CursorPartialContentEmitter {
86
88
  });
87
89
  }
88
90
 
91
+ completeTextMessage(): void {
92
+ // Defer the separator until more text arrives: an exact final answer must
93
+ // not gain trailing whitespace. A fresh Pi tool-use turn needs no prefix.
94
+ this.textMessageCompleted = this.partial.content.some((block) => block.type === "text" && block.text.length > 0);
95
+ }
96
+
89
97
  appendTextDelta(delta: string, options?: { closeThinking?: boolean }): void {
90
98
  const closeThinking = options?.closeThinking ?? this.mutuallyExclusive;
91
99
  if (closeThinking) this.closeThinking();
92
100
  if (!delta) return;
101
+ if (this.textMessageCompleted) {
102
+ this.textMessageCompleted = false;
103
+ this.appendTextDelta(CURSOR_TEXT_MESSAGE_SEPARATOR, { closeThinking: false });
104
+ this.closeText();
105
+ }
93
106
  if (this.textContentIndex < 0) {
94
107
  this.textContentIndex = this.partial.content.length;
95
108
  this.partial.content.push({ type: "text", text: "" });
@@ -0,0 +1,44 @@
1
+ import * as piAi from "@earendil-works/pi-ai";
2
+ import type { Context, Message, Tool } from "@earendil-works/pi-ai";
3
+
4
+ // Stock 0.84/0.85 do not export transcript helpers. Keep the namespace import in
5
+ // Pi's static extension graph: the host loader supplies its own peer module.
6
+ // Context remains the common structural input (its shorthand fields are optional);
7
+ // transcript hosts pass messages only. Never implement Pi's replay rules here.
8
+ interface PiTranscriptHelpers {
9
+ normalizeContext(context: Context): { messages: readonly { role: string }[] };
10
+ getCurrentSystemPrompt(messages: readonly { role: string }[]): string;
11
+ getCurrentTools(messages: readonly { role: string }[]): Tool[];
12
+ }
13
+ const optionalHelpers = piAi as typeof piAi & Partial<PiTranscriptHelpers>;
14
+ const transcriptHelpers = typeof optionalHelpers.normalizeContext === "function"
15
+ && typeof optionalHelpers.getCurrentSystemPrompt === "function"
16
+ && typeof optionalHelpers.getCurrentTools === "function"
17
+ ? optionalHelpers as PiTranscriptHelpers : undefined;
18
+
19
+ export function isCursorSystemMessage(message: { role: string }): boolean {
20
+ return message.role === "system";
21
+ }
22
+
23
+ /** Conversation-only view for Cursor text/history and trailing tool-result scans. */
24
+ export function getCursorConversationMessages(context: Pick<Context, "messages">): Message[] {
25
+ return context.messages.filter((message) => !isCursorSystemMessage(message));
26
+ }
27
+
28
+ export function resolveCursorPiContext(context: Context): { systemPrompt: string; tools: Tool[] | undefined } {
29
+ if (!transcriptHelpers) {
30
+ if (context.messages.some(isCursorSystemMessage)) {
31
+ throw new Error("Pi transcript context requires the host's public transcript replay helpers.");
32
+ }
33
+ return { systemPrompt: context.systemPrompt ?? "", tools: context.tools };
34
+ }
35
+ const messages = transcriptHelpers.normalizeContext(context).messages;
36
+ const legacyWithoutTools = ("systemPrompt" in context || "tools" in context)
37
+ && context.tools === undefined && !context.messages.some(isCursorSystemMessage);
38
+ return {
39
+ systemPrompt: transcriptHelpers.getCurrentSystemPrompt(messages),
40
+ // A messages-only provider request is authoritative, including no declarations.
41
+ // An explicitly legacy shorthand context retains its absent-snapshot semantics.
42
+ tools: legacyWithoutTools ? undefined : transcriptHelpers.getCurrentTools(messages),
43
+ };
44
+ }
@@ -166,7 +166,7 @@ function isCursorSdkStallAbortNetworkError(code: unknown, evidence: string, stac
166
166
  }
167
167
 
168
168
  /**
169
- * @cursor/sdk@1.0.23 throws internal RetriableError (name/kind "RetriableError") with message
169
+ * @cursor/sdk@1.0.27 throws internal RetriableError (name/kind "RetriableError") with message
170
170
  * "Connection stalled" or "Connection stalled repeatedly" after fetchWithRetry exhausts stalls.
171
171
  * The ConnectError is only the cause; the top-level error is not a ConnectError.
172
172
  */
@@ -7,8 +7,10 @@ import {
7
7
  type Model,
8
8
  type SimpleStreamOptions,
9
9
  } from "@earendil-works/pi-ai";
10
+ import { streamCursor } from "./cursor-provider.js";
11
+ import { sanitizeCursorProviderError } from "./cursor-provider-errors.js";
10
12
 
11
- function makeProviderLoadErrorMessage(model: Model<Api>, error: unknown): AssistantMessage {
13
+ function makeProviderRuntimeErrorMessage(model: Model<Api>, error: unknown, apiKey?: string): AssistantMessage {
12
14
  return {
13
15
  role: "assistant",
14
16
  content: [],
@@ -25,7 +27,7 @@ function makeProviderLoadErrorMessage(model: Model<Api>, error: unknown): Assist
25
27
  },
26
28
  stopReason: "error",
27
29
  timestamp: Date.now(),
28
- errorMessage: `Failed to load Cursor provider runtime: ${error instanceof Error ? error.message : String(error)}`,
30
+ errorMessage: `Cursor provider runtime failed: ${sanitizeCursorProviderError(error, apiKey)}`,
29
31
  };
30
32
  }
31
33
 
@@ -37,12 +39,11 @@ export function streamCursorLazy(
37
39
  const outer = createAssistantMessageEventStream();
38
40
  queueMicrotask(async () => {
39
41
  try {
40
- const { streamCursor } = await import("./cursor-provider.js");
41
42
  for await (const event of streamCursor(model, context, options)) {
42
43
  outer.push(event);
43
44
  }
44
45
  } catch (error) {
45
- const message = makeProviderLoadErrorMessage(model, error);
46
+ const message = makeProviderRuntimeErrorMessage(model, error, options?.apiKey);
46
47
  outer.push({ type: "error", reason: "error", error: message });
47
48
  outer.end(message);
48
49
  }
@@ -21,7 +21,7 @@ import {
21
21
  import { type CursorPiBridgeToolRequest } from "./cursor-pi-tool-bridge.js";
22
22
  import { resetSessionCursorAgent } from "./cursor-session-agent.js";
23
23
  import { applyCursorUsage } from "./cursor-usage-accounting.js";
24
- import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
24
+ import { CURSOR_TEXT_MESSAGE_SEPARATOR, CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
25
25
  import { emitDisplayOnlyTraceBlock } from "./cursor-display-only-trace.js";
26
26
  import { trimCurrentTurnAlreadyEmittedCursorText } from "./cursor-run-final-text.js";
27
27
  import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./cursor-provider-errors.js";
@@ -88,11 +88,9 @@ function splitTextIntoReplayDeltas(text: string): string[] {
88
88
  }
89
89
 
90
90
  async function emitTextDeltas(
91
- stream: AssistantMessageEventStream,
92
- partial: AssistantMessage,
91
+ emitter: CursorPartialContentEmitter,
93
92
  deltas: string[],
94
93
  ): Promise<string> {
95
- const emitter = new CursorPartialContentEmitter(stream, partial, -1, true);
96
94
  for (const delta of deltas) {
97
95
  emitter.appendTextDelta(delta);
98
96
  await Promise.resolve();
@@ -121,11 +119,11 @@ export function flushPendingCursorLiveRunTraceEventsToStream(
121
119
  stream: AssistantMessageEventStream,
122
120
  partial: AssistantMessage,
123
121
  run: CursorLiveRun,
124
- options?: { includeTracesBehindQueuedTools?: boolean },
122
+ options?: { includeTracesBehindQueuedTools?: boolean; emitter?: CursorPartialContentEmitter },
125
123
  ): void {
126
124
  if (run.disposed) return;
127
125
  const turn: CursorLiveTurnState = {
128
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
126
+ emitter: options?.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
129
127
  emittedText: "",
130
128
  };
131
129
  while (true) {
@@ -157,6 +155,12 @@ function emitCursorLiveQueuedEvent(
157
155
  turn.emitter.appendThinkingDelta(event.text);
158
156
  } else if (event.type === "thinking-completed") {
159
157
  turn.emitter.closeThinking();
158
+ } else if (event.type === "text-completed") {
159
+ turn.emitter.completeTextMessage();
160
+ // Logical separators also keep suffix/prefix dedup aware of SDK messages,
161
+ // including boundaries queued across Pi tool-use turns.
162
+ if (turn.emittedText) turn.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
163
+ if (run?.emittedText) run.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
160
164
  } else if (event.type === "text-delta") {
161
165
  turn.emittedText += event.text;
162
166
  if (run) run.emittedText += event.text;
@@ -296,7 +300,7 @@ export async function drainCursorLiveRunTurn(
296
300
  context: Context,
297
301
  run: CursorLiveRun,
298
302
  toolResultInputTokens: number,
299
- options: { mode: CursorLiveRunDrainMode; signal?: AbortSignal; debugRecorder?: CursorSdkEventDebugRecorder },
303
+ options: { mode: CursorLiveRunDrainMode; signal?: AbortSignal; debugRecorder?: CursorSdkEventDebugRecorder; emitter?: CursorPartialContentEmitter },
300
304
  ): Promise<CursorLiveRunDrainOutcome> {
301
305
  const debugRecorder = options.debugRecorder ?? run.debugRecorder;
302
306
  debugRecorder?.recordDrainEvent("turn_start", {
@@ -308,7 +312,7 @@ export async function drainCursorLiveRunTurn(
308
312
  let outcome: CursorLiveRunDrainOutcome | undefined;
309
313
  let outcomeDetails: Record<string, unknown> = {};
310
314
  const turn: CursorLiveTurnState = {
311
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
315
+ emitter: options.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
312
316
  emittedText: "",
313
317
  };
314
318
 
@@ -378,11 +382,12 @@ export async function drainCursorLiveRunTurn(
378
382
  turn.emitter.closeAll();
379
383
  const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
380
384
  if (finalText) {
381
- await emitTextDeltas(stream, partial, splitTextIntoReplayDeltas(finalText));
385
+ await emitTextDeltas(turn.emitter, splitTextIntoReplayDeltas(finalText));
382
386
  }
383
387
  applyCursorUsage(partial, model, context, cursorLiveRuns.takeTurnInputTokens(run, toolResultInputTokens), {
384
388
  runtime: "local",
385
389
  turn: cursorLiveRuns.takeSdkTurnUsage(run),
390
+ billed: run.billedTurnUsage,
386
391
  });
387
392
  if (run.resumeNotice) {
388
393
  emitDisplayOnlyTraceBlock(stream, partial, run.resumeNotice);
@@ -186,6 +186,7 @@ export class CursorRunFinalizer {
186
186
  applyCursorUsage(partial, model, context, prepared.meta.promptInputTokens, {
187
187
  runtime: prepared.runtimeTarget,
188
188
  turn: prepared.runtime.turnCoordinator.lastSdkTurnUsage,
189
+ billed: prepared.runtime.billedTurnUsage,
189
190
  });
190
191
  if (prepared.meta.resumeNotice) emitDisplayOnlyTraceBlock(stream, partial, prepared.meta.resumeNotice);
191
192
  if (displayOnlyTraceBlock) emitDisplayOnlyTraceBlock(stream, partial, displayOnlyTraceBlock);
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
2
2
  import type { InteractionUpdate } from "@cursor/sdk";
3
+ import { CURSOR_TEXT_MESSAGE_SEPARATOR } from "./cursor-partial-content-emitter.js";
3
4
  import type { CursorLiveRun } from "./cursor-live-run-coordinator.js";
4
5
  import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
5
6
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
@@ -65,6 +66,7 @@ export class CursorSdkTurnCoordinator {
65
66
  private readonly lifecycleEmitter: CursorToolLifecycleEmitter;
66
67
  private readonly contentEmitter;
67
68
  private sdkTurnUsage?: CursorSdkTurnUsage;
69
+ private hasPendingTextMessage = false;
68
70
 
69
71
  constructor(options: CursorSdkTurnCoordinatorOptions) {
70
72
  this.stream = options.stream;
@@ -153,6 +155,7 @@ export class CursorSdkTurnCoordinator {
153
155
  cursorLiveRuns.recordSdkTurnEnded(this.liveRun, sdkTurnUsage);
154
156
  }
155
157
  if (update.type === "text-delta") {
158
+ if (update.text) this.hasPendingTextMessage = true;
156
159
  this.textDeltas.push(update.text);
157
160
  if (this.liveRun) {
158
161
  cursorLiveRuns.queueEvent(this.liveRun, { type: "text-delta", text: update.text });
@@ -247,6 +250,18 @@ export class CursorSdkTurnCoordinator {
247
250
 
248
251
  handleStep(stepEnvelope: unknown): void {
249
252
  const stepType = getField(stepEnvelope, "type");
253
+ if (stepType === "assistantMessage") {
254
+ // SDK onStep completes a message; onDelta chunks within it are tokens,
255
+ // not separate messages. Keep the boundary in both output and final-text
256
+ // deduplication, without replaying the completed step's text a second time.
257
+ if (this.hasPendingTextMessage) {
258
+ this.hasPendingTextMessage = false;
259
+ this.textDeltas.push(CURSOR_TEXT_MESSAGE_SEPARATOR);
260
+ if (this.liveRun) cursorLiveRuns.queueEvent(this.liveRun, { type: "text-completed" });
261
+ else this.contentEmitter.completeTextMessage();
262
+ }
263
+ return;
264
+ }
250
265
  const step = getField(stepEnvelope, "message") ? stepEnvelope : undefined;
251
266
  const rawStepToolCall = getField(step, "message");
252
267
  if (stepType !== "toolCall") return;
@@ -1,4 +1,5 @@
1
1
  import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
2
+ import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
2
3
  import {
3
4
  cursorLiveRuns,
4
5
  drainCursorLiveRunTurn,
@@ -28,6 +29,9 @@ export async function emitCursorLiveTurn(emitParams: EmitCursorLiveTurnParams):
28
29
  const { liveRun, turnCoordinator } = prepared.runtime;
29
30
 
30
31
  const { options, model } = params;
32
+ // Drain and abort recovery write to the same partial. Keep its open text block
33
+ // and deferred message boundary together; a new Pi turn still gets a new emitter.
34
+ const emitter = new CursorPartialContentEmitter(params.stream, params.partial, -1, true);
31
35
  try {
32
36
  await cursorLiveRuns.withRunLease(liveRun, options?.signal, async () => {
33
37
  await cursorLiveRuns.waitForProgress(liveRun, options?.signal);
@@ -35,6 +39,7 @@ export async function emitCursorLiveTurn(emitParams: EmitCursorLiveTurnParams):
35
39
  turnCoordinator.closeTraceBlock();
36
40
  await drainCursorLiveRunTurn(params.stream, params.partial, model, params.context, liveRun, 0, {
37
41
  mode: "emit",
42
+ emitter,
38
43
  signal: options?.signal,
39
44
  debugRecorder: sdkEventDebug,
40
45
  });
@@ -45,6 +50,7 @@ export async function emitCursorLiveTurn(emitParams: EmitCursorLiveTurnParams):
45
50
  turnCoordinator.closeTraceBlock();
46
51
  flushPendingCursorLiveRunTraceEventsToStream(params.stream, params.partial, liveRun, {
47
52
  includeTracesBehindQueuedTools: true,
53
+ emitter,
48
54
  });
49
55
  }
50
56
  throw caught;
@@ -17,6 +17,7 @@ import {
17
17
  } from "./cursor-provider-run-outcome.js";
18
18
  import type { CursorProviderTurnPrepareResult } from "./cursor-provider-turn-types.js";
19
19
  import { loadCursorSdk } from "./cursor-sdk-runtime.js";
20
+ import { attachCursorSdkBilledTurnUsage } from "./cursor-sdk-billed-usage.js";
20
21
 
21
22
  export async function cacheSdkContextWindow(
22
23
  agentId: string,
@@ -149,6 +150,16 @@ export async function awaitFinalizeCursorRunOutcome(params: AwaitFinalizeCursorR
149
150
  resolvedApiKey: params.resolvedApiKey,
150
151
  optionsApiKey: params.optionsApiKey,
151
152
  });
153
+ const billed = await attachCursorSdkBilledTurnUsage({
154
+ agent: params.prepared.agent,
155
+ agentId: params.run.agentId,
156
+ runtime: params.prepared.runtimeTarget,
157
+ runId: params.run.id,
158
+ });
159
+ params.prepared.runtime.billedTurnUsage = billed.turn;
160
+ if (params.prepared.runtime.liveRun) {
161
+ params.prepared.runtime.liveRun.billedTurnUsage = billed.turn;
162
+ }
152
163
  let displayOnlyTraceBlock: string | undefined;
153
164
  if (params.prepared.runtimeTarget === "cloud" && isCursorRunFinishedSuccessfully(outcome)) {
154
165
  let report: CursorCloudRunReport = { agentId: params.run.agentId, runId: params.run.id, branches: [] };
@@ -158,6 +169,7 @@ export async function awaitFinalizeCursorRunOutcome(params: AwaitFinalizeCursorR
158
169
  run: params.run,
159
170
  waitResult,
160
171
  apiKey,
172
+ agentUsage: billed.agentUsage,
161
173
  });
162
174
  } catch (error) {
163
175
  recordCursorCloudReportingError(params.sdkEventDebug, error, apiKey);
@@ -1,4 +1,5 @@
1
1
  import type { Context, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
+ import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
2
3
  import type { AgentModeOption, ModelSelection, SDKAgent } from "@cursor/sdk";
3
4
  import { configureCursorSdkHttp1 } from "./cursor-http1.js";
4
5
  import { installCursorMcpToolTimeoutOverride } from "./cursor-mcp-timeout-override.js";
@@ -80,11 +81,15 @@ interface PrepareCursorProviderTurnContext extends PrepareCursorProviderTurnPara
80
81
 
81
82
  function buildCursorCloudPromptContext(context: Context, handoff: "fresh" | "bootstrap" | "never"): Context {
82
83
  if (handoff === "bootstrap") return context;
83
- for (let index = context.messages.length - 1; index >= 0; index -= 1) {
84
- const message = context.messages[index];
85
- if (message.role === "user") return { ...context, messages: [message] };
84
+ // Fresh cloud runs omit history, not the current Pi instructions. Replay before
85
+ // selecting the user message, otherwise transcript-only inputs lose the prompt.
86
+ const current = resolveCursorPiContext(context);
87
+ const messages = getCursorConversationMessages(context);
88
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
89
+ const message = messages[index];
90
+ if (message.role === "user") return { ...current, messages: [message] };
86
91
  }
87
- return { ...context, messages: context.messages.slice(-1) };
92
+ return { ...current, messages: messages.slice(-1) };
88
93
  }
89
94
 
90
95
  const CLOUD_SEND_PLAN: CursorSessionSendPlan = { mode: "bootstrap", resetAgent: false, reason: "initial" };
@@ -135,7 +140,7 @@ async function prepareCursorCloudProviderTurn(
135
140
  repo: resolvedConfig.cloud.repo.value,
136
141
  branch: resolvedConfig.cloud.branch.value,
137
142
  }),
138
- hasPriorContext: context.messages.length > 1,
143
+ hasPriorContext: getCursorConversationMessages(context).length > 1,
139
144
  });
140
145
  if (!preflight.ok) throw new Error(formatCursorCloudPreflightError(preflight));
141
146
  if (getPendingCursorLiveRun(context) || getActiveCursorLiveRunForCurrentScope()) {
@@ -14,6 +14,7 @@ import type { CursorSdkEventDebugSink } from "./cursor-sdk-event-debug.js";
14
14
  import type { CursorSdkTurnCoordinator } from "./cursor-provider-turn-coordinator.js";
15
15
  import type { CursorPrompt } from "./context.js";
16
16
  import type { CursorResolvedSetting } from "./cursor-config.js";
17
+ import type { CursorSdkTurnUsage } from "./cursor-usage-accounting.js";
17
18
 
18
19
  export interface CursorProviderTurnRunnerParams {
19
20
  model: Model<Api>;
@@ -44,6 +45,7 @@ export interface CursorProviderTurnSendMeta {
44
45
 
45
46
  interface CursorProviderTurnRuntimeBase {
46
47
  turnCoordinator: CursorSdkTurnCoordinator;
48
+ billedTurnUsage?: CursorSdkTurnUsage;
47
49
  }
48
50
 
49
51
  /**
@@ -0,0 +1,107 @@
1
+ import type { SDKAgent } from "@cursor/sdk";
2
+ import type { CursorRuntime } from "./cursor-config.js";
3
+ import { asRecord, getArray, getString } from "./cursor-record-utils.js";
4
+ import { readCursorSdkTurnUsage, type CursorSdkTurnUsage } from "./cursor-usage-accounting.js";
5
+
6
+ const BILLED_USAGE_TIMEOUT_MS = 5000;
7
+
8
+ // ponytail: process-lifetime watermark of billed usage UUIDs per agentId; reset on process exit.
9
+ // Upgrade to session-scoped storage if multi-day processes retain enough agent IDs to matter.
10
+ const seenBilledRunIdsByAgent = new Map<string, Set<string>>();
11
+
12
+ export function isCursorSdkClientMintedRunId(runId: string): boolean {
13
+ return runId.startsWith("run-");
14
+ }
15
+
16
+ export function sumCursorSdkTurnUsage(usages: readonly CursorSdkTurnUsage[]): CursorSdkTurnUsage | undefined {
17
+ if (usages.length === 0) return undefined;
18
+ return usages.reduce(
19
+ (total, usage) => ({
20
+ inputTokens: total.inputTokens + usage.inputTokens,
21
+ outputTokens: total.outputTokens + usage.outputTokens,
22
+ cacheReadTokens: total.cacheReadTokens + usage.cacheReadTokens,
23
+ cacheWriteTokens: total.cacheWriteTokens + usage.cacheWriteTokens,
24
+ }),
25
+ );
26
+ }
27
+
28
+ export function peekCursorBilledUsageRunIds(agentId: string): ReadonlySet<string> {
29
+ return seenBilledRunIdsByAgent.get(agentId) ?? new Set();
30
+ }
31
+
32
+ export function rememberCursorBilledUsageRunIds(agentId: string, runIds: readonly string[]): void {
33
+ if (runIds.length === 0) return;
34
+ let seen = seenBilledRunIdsByAgent.get(agentId);
35
+ if (!seen) {
36
+ seen = new Set();
37
+ seenBilledRunIdsByAgent.set(agentId, seen);
38
+ }
39
+ for (const runId of runIds) seen.add(runId);
40
+ }
41
+
42
+ function withTimeout<T>(promise: Promise<T>, timeoutMs = BILLED_USAGE_TIMEOUT_MS): Promise<T | undefined> {
43
+ let timer: ReturnType<typeof setTimeout> | undefined;
44
+ const timeout = new Promise<undefined>((resolve) => {
45
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
46
+ timer.unref?.();
47
+ });
48
+ return Promise.race([promise.catch(() => undefined), timeout]).finally(() => {
49
+ if (timer) clearTimeout(timer);
50
+ });
51
+ }
52
+
53
+ export function selectCursorBilledTurnUsage(
54
+ agentUsage: unknown,
55
+ options: { runtime: CursorRuntime; runId?: string; seenRunIds?: ReadonlySet<string> },
56
+ ): { turn?: CursorSdkTurnUsage; runIds: string[] } {
57
+ const runs = (getArray(asRecord(agentUsage), "runs") ?? []).flatMap((item) => {
58
+ const record = asRecord(item);
59
+ const runId = getString(record, "runId");
60
+ const usage = readCursorSdkTurnUsage(record?.usage);
61
+ return runId && usage ? [{ runId, usage }] : [];
62
+ });
63
+ if (options.runtime === "cloud" && options.runId) {
64
+ const match = runs.find((run) => run.runId === options.runId);
65
+ return match ? { turn: match.usage, runIds: [match.runId] } : { runIds: [] };
66
+ }
67
+ const unseen = runs.filter((run) => !options.seenRunIds?.has(run.runId));
68
+ return { turn: sumCursorSdkTurnUsage(unseen.map((run) => run.usage)), runIds: unseen.map((run) => run.runId) };
69
+ }
70
+
71
+ export async function fetchCursorSdkAgentUsage(
72
+ agent: SDKAgent,
73
+ options: { runtime: CursorRuntime; runId?: string },
74
+ ): Promise<unknown | undefined> {
75
+ if (typeof agent.getUsage !== "function") return undefined;
76
+ const query =
77
+ options.runtime === "cloud" && options.runId && isCursorSdkClientMintedRunId(options.runId)
78
+ ? { runId: options.runId }
79
+ : undefined;
80
+ return withTimeout(Promise.resolve(agent.getUsage(query)));
81
+ }
82
+
83
+ export async function attachCursorSdkBilledTurnUsage(options: {
84
+ agent: SDKAgent;
85
+ agentId: string;
86
+ runtime: CursorRuntime;
87
+ runId?: string;
88
+ }): Promise<{ agentUsage?: unknown; turn?: CursorSdkTurnUsage }> {
89
+ const agentUsage = await fetchCursorSdkAgentUsage(options.agent, {
90
+ runtime: options.runtime,
91
+ runId: options.runId,
92
+ });
93
+ if (!agentUsage) return {};
94
+ const selected = selectCursorBilledTurnUsage(agentUsage, {
95
+ runtime: options.runtime,
96
+ runId: options.runtime === "cloud" ? options.runId : undefined,
97
+ seenRunIds: peekCursorBilledUsageRunIds(options.agentId),
98
+ });
99
+ rememberCursorBilledUsageRunIds(options.agentId, selected.runIds);
100
+ return { agentUsage, turn: selected.turn };
101
+ }
102
+
103
+ export const __testUtils = {
104
+ reset(): void {
105
+ seenBilledRunIdsByAgent.clear();
106
+ },
107
+ };
@@ -75,10 +75,12 @@ function isCursorSdkAbortError(error: unknown): boolean {
75
75
  // The exact observed incident: the Cursor SDK 1.0.23 local shell executor writes a
76
76
  // spawned child's stdin without a stream 'error' listener, so a child exiting while
77
77
  // a write is in flight surfaces a raw `write EPIPE` uncaught exception whose stack
78
- // is exactly the single async pipe-write completion frame. Pi's own piped-stdout or
79
- // dead-terminal EPIPE normally surfaces through the synchronous write-dispatch path
80
- // with multiple frames (afterWriteDispatched/Socket._writeGeneric) and must stay
81
- // fatal per Unix convention, so anything beyond this one-frame contract is rejected.
78
+ // is exactly the single async pipe-write completion frame. Installed 1.0.27 attaches
79
+ // a no-op `error` listener before that write; keep this guard as defense in depth.
80
+ // Pi's own piped-stdout or dead-terminal EPIPE normally surfaces through the
81
+ // synchronous write-dispatch path with multiple frames (afterWriteDispatched /
82
+ // Socket._writeGeneric) and must stay fatal per Unix convention, so anything beyond
83
+ // this one-frame contract is rejected.
82
84
  const OBSERVED_CLOSED_PIPE_STACK_FRAME =
83
85
  /^\s+at WriteWrap\.onWriteComplete \[as oncomplete\] \(node:internal\/stream_base_commons:\d+:\d+\)$/;
84
86