pi-cursor-sdk 0.3.6 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.7 - 2026-09-16
4
+
5
+ ### Fixed
6
+
7
+ - Preserve completed Cursor SDK assistant-message boundaries in streamed and persisted output: separate progress messages from the next message without splitting token chunks, duplicating the SDK final result, or adding whitespace to an exact final answer. Carry boundaries through local native-replay drain as well as direct/cloud turns.
8
+
9
+ - Support transcript-only Pi provider requests via the host's public replay helpers, without requiring those exports on stock Pi 0.84.0 or 0.85.1. Preserve current instructions, section updates, and explicit empty replay-tool snapshots.
10
+ - Fingerprint replayed instructions and tool definitions plus individual system-message content/section/tool deltas, so unchanged transcripts stay incremental and changed context reboots correctly.
11
+ - Preserve current Pi instructions in fresh cloud prompts, and ignore system updates when finding pending replay/bridge tool results and steering input.
12
+ - Handle Pi's XML-wrapped tool/rule/skill sections and project-context serialization without duplicating local rules or skill catalogs. Cloud keeps project instructions and omits the local skill catalog.
13
+
3
14
  ## 0.3.6 - 2026-08-18
4
15
 
5
16
  ### Fixed
package/README.md CHANGED
@@ -51,10 +51,10 @@ If pi started without a key, run `/cursor-refresh-models` after `/login` to refr
51
51
  ## Requirements
52
52
 
53
53
  - Node.js 22.19+
54
- - Pi 0.84.0 or later; pi core peer metadata remains optional and uses `"*"` ranges per Pi package guidance
54
+ - Pi 0.84.0 or later; stock Pi 0.84.0 and 0.85.1 are supported without a fork. Pi core peer metadata remains optional and uses `"*"` ranges per Pi package guidance
55
55
  - a Cursor SDK API key saved through `/login`, available as `CURSOR_API_KEY`, or passed with pi's `--api-key`
56
56
 
57
- No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.27`, so normal package installation brings in the SDK version this extension was built and tested against. Cursor SDK 1.0.27 declares its Node ConnectRPC transport dependency directly, so npm installs place `@connectrpc/connect-node` where the SDK can resolve it. The extension intentionally does not bundle `@cursor/sdk` or its platform packages, because packing from one maintainer OS can otherwise ship the wrong optional SDK binary for another OS. Cursor SDK 1.0.27 keeps the older `sqlite3 -> node-gyp@8` dependency chain out of the runtime tree, so deprecated install warnings for `inflight`, `rimraf`, `glob@7`, `npmlog`, `gauge`, `are-we-there-yet`, and `tar@6` from that chain are not expected. Older Pi and Cursor SDK compatibility paths are not maintained.
57
+ No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.27`, so normal package installation brings in the SDK version this extension was built and tested against. Cursor SDK 1.0.27 declares its Node ConnectRPC transport dependency directly, so npm installs place `@connectrpc/connect-node` where the SDK can resolve it. The extension intentionally does not bundle `@cursor/sdk` or its platform packages, because packing from one maintainer OS can otherwise ship the wrong optional SDK binary for another OS. Cursor SDK 1.0.27 keeps the older `sqlite3 -> node-gyp@8` dependency chain out of the runtime tree, so deprecated install warnings for `inflight`, `rimraf`, `glob@7`, `npmlog`, `gauge`, `are-we-there-yet`, and `tar@6` from that chain are not expected. Pi hosts that pass transcript-only provider contexts are also supported through their public replay helpers, detected at runtime; no fork-only API or dependency is required. Older Pi (before 0.84.0) and Cursor SDK compatibility paths are not maintained.
58
58
 
59
59
  ## Install
60
60
 
package/dist/context.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
2
3
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
3
4
  import { CURSOR_PI_BRIDGE_PREFERENCE_TEXT } from "./cursor-bridge-contract.js";
4
5
  import { getCursorReplayPromptLabel } from "./cursor-tool-presentation-registry.js";
@@ -53,7 +54,7 @@ function getCursorBootstrapTailSections(options = {}) {
53
54
  ];
54
55
  }
55
56
  function normalizePiContextMessages(messages) {
56
- return convertToLlm(messages);
57
+ return convertToLlm(getCursorConversationMessages({ messages }));
57
58
  }
58
59
  function isTextBlock(block) {
59
60
  return block.type === "text";
@@ -102,6 +103,9 @@ function formatToolCall(toolCall) {
102
103
  }
103
104
  function sanitizeSystemPromptForCursor(systemPrompt) {
104
105
  let sanitized = systemPrompt;
106
+ // Transcript-era Pi wraps its built-in tool catalog/rules in XML sections.
107
+ sanitized = sanitized.replace(/<tools>\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n<\/tools>/g, "Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.");
108
+ sanitized = sanitized.replace(/<rules>\n[\s\S]*?\n<\/rules>\n\n(?=<docs>\nPi documentation )/g, "<rules>\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n</rules>\n\n");
105
109
  sanitized = sanitized.replace(/Available tools:\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n\n/g, "Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.\n\n");
106
110
  sanitized = sanitized.replace(/Guidelines:\n[\s\S]*?\n\nPi documentation /g, "Guidelines:\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n\nPi documentation ");
107
111
  // Keep the Agent Skills catalog. Cursor-specific skill activation wording is normalized
@@ -210,11 +214,21 @@ function serializeMessageForFingerprint(message, index) {
210
214
  return hashCursorContextValue(`assistant:${message.timestamp ?? index}:${JSON.stringify(message.content)}`);
211
215
  case "toolResult":
212
216
  return hashCursorContextValue(`toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`);
217
+ default:
218
+ // System messages are handled by serializeRawPiMessageForFingerprint.
219
+ throw new Error("Unsupported Pi message role in Cursor context fingerprint.");
213
220
  }
214
221
  }
215
222
  function serializeRawPiMessageForFingerprint(message, index) {
216
223
  const role = message.role;
217
224
  switch (role) {
225
+ case "system": {
226
+ const entry = message;
227
+ return hashCursorContextValue(JSON.stringify({
228
+ role, content: entry.content, sections: entry.sections,
229
+ toolsAdded: entry.toolsAdded, toolsRemoved: entry.toolsRemoved,
230
+ }));
231
+ }
218
232
  case "branchSummary": {
219
233
  const entry = message;
220
234
  return hashCursorContextValue(`branchSummary:${entry.timestamp ?? index}:${entry.fromId ?? ""}:${entry.summary ?? ""}`);
@@ -253,7 +267,7 @@ function parseCursorContextFingerprint(fingerprint) {
253
267
  }
254
268
  export function computeCursorContextFingerprint(context) {
255
269
  const payload = {
256
- systemHash: hashCursorContextValue(context.systemPrompt ?? ""),
270
+ systemHash: hashCursorContextValue(JSON.stringify(resolveCursorPiContext(context))),
257
271
  messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
258
272
  };
259
273
  return JSON.stringify(payload);
@@ -316,8 +330,9 @@ export function buildCursorPrompt(context, options = {}) {
316
330
  if (options.toolManifest) {
317
331
  sectionsBeforeMessages.push(options.toolManifest);
318
332
  }
319
- if (context.systemPrompt) {
320
- sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
333
+ const { systemPrompt } = resolveCursorPiContext(context);
334
+ if (systemPrompt) {
335
+ sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(systemPrompt)}`);
321
336
  }
322
337
  const messages = normalizePiContextMessages(context.messages);
323
338
  const messageSections = messages
@@ -73,10 +73,19 @@ export function shouldSuppressPiAgentsContext(model, contextFiles, settingSource
73
73
  export function serializePiProjectInstructionsBlock(file) {
74
74
  return `${PI_PROJECT_INSTRUCTIONS_OPEN_PREFIX}${file.path}">\n${file.content}\n${PI_PROJECT_INSTRUCTIONS_CLOSE}\n\n`;
75
75
  }
76
- /** Exact pi `buildSystemPrompt()` serialization for the full project context section. */
77
- export function serializePiProjectContextSection(contextFiles) {
76
+ /** The two supported Pi project-context serializations; legacy includes outer spacing. */
77
+ export function serializePiProjectContextSection(contextFiles, format = "legacy") {
78
78
  if (contextFiles.length === 0)
79
79
  return "";
80
+ if (format === "transcript") {
81
+ return [
82
+ "<project_context>",
83
+ "Project-specific instructions and guidelines:",
84
+ "",
85
+ contextFiles.map((file) => serializePiProjectInstructionsBlock(file).trimEnd()).join("\n\n"),
86
+ "</project_context>",
87
+ ].join("\n");
88
+ }
80
89
  return `${PI_PROJECT_CONTEXT_OPEN}${contextFiles.map(serializePiProjectInstructionsBlock).join("")}${PI_PROJECT_CONTEXT_CLOSE}`;
81
90
  }
82
91
  /** Remove pi context blocks that overlap Cursor setting sources. */
@@ -92,12 +101,17 @@ export function removePiAgentsContextFromSystemPrompt(systemPrompt, contextFiles
92
101
  }
93
102
  if (!removedAny)
94
103
  return systemPrompt;
95
- const originalSection = serializePiProjectContextSection(contextFiles);
96
- const start = systemPrompt.indexOf(originalSection);
97
- if (start < 0)
98
- return systemPrompt;
99
- const replacementSection = serializePiProjectContextSection(retainedContextFiles);
100
- return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
104
+ // Match only the exact known files in either supported serialization; never
105
+ // strip arbitrary user-authored XML or neighboring custom sections.
106
+ for (const format of ["legacy", "transcript"]) {
107
+ const originalSection = serializePiProjectContextSection(contextFiles, format);
108
+ const start = systemPrompt.indexOf(originalSection);
109
+ if (start < 0)
110
+ continue;
111
+ const replacementSection = serializePiProjectContextSection(retainedContextFiles, format);
112
+ return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
113
+ }
114
+ return systemPrompt;
101
115
  }
102
116
  export function resolveCursorFacingSystemPrompt(systemPrompt, model, systemPromptOptions, settingSourcesRaw, agentDir, runtime = "local") {
103
117
  if (runtime === "cloud" || !systemPromptOptions)
@@ -1,4 +1,6 @@
1
+ import { resolveCursorPiContext } from "./cursor-pi-context.js";
1
2
  /** Tool names from the provider context snapshot at stream start (not live pi.getActiveTools()). */
2
3
  export function getActiveContextToolNames(context) {
3
- return context.tools ? new Set(context.tools.map((tool) => tool.name)) : undefined;
4
+ const { tools } = resolveCursorPiContext(context);
5
+ return tools === undefined ? undefined : new Set(tools.map((tool) => tool.name));
4
6
  }
@@ -1,3 +1,4 @@
1
+ import { getCursorConversationMessages } from "./cursor-pi-context.js";
1
2
  import { consumeCursorLiveToolResults, createCursorLiveRunAccountingState, recordCursorLiveSdkTurnEnded, takeCursorLiveSdkTurnUsage, takeCursorLiveTurnInputTokens, } from "./cursor-live-run-accounting.js";
2
3
  import { getCursorSessionScopeKey } from "./cursor-session-scope.js";
3
4
  import { installCursorSdkProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
@@ -24,16 +25,17 @@ async function cancelCursorLiveSdkRun(run) {
24
25
  }
25
26
  }
26
27
  export function hasTrailingUserMessagesAfterToolResults(context) {
27
- let index = context.messages.length - 1;
28
+ const messages = getCursorConversationMessages(context);
29
+ let index = messages.length - 1;
28
30
  let sawTrailingUser = false;
29
- while (index >= 0 && context.messages[index]?.role === "user") {
31
+ while (index >= 0 && messages[index]?.role === "user") {
30
32
  sawTrailingUser = true;
31
33
  index -= 1;
32
34
  }
33
35
  if (!sawTrailingUser)
34
36
  return false;
35
37
  let sawToolResult = false;
36
- while (index >= 0 && context.messages[index]?.role === "toolResult") {
38
+ while (index >= 0 && messages[index]?.role === "toolResult") {
37
39
  sawToolResult = true;
38
40
  index -= 1;
39
41
  }
@@ -269,12 +271,13 @@ export function createCursorLiveRunCoordinator(deps) {
269
271
  return taken.sdkTurnUsage;
270
272
  },
271
273
  getPendingFromContext(context, getReplayId) {
272
- let index = context.messages.length - 1;
273
- while (index >= 0 && context.messages[index]?.role === "user") {
274
+ const messages = getCursorConversationMessages(context);
275
+ let index = messages.length - 1;
276
+ while (index >= 0 && messages[index]?.role === "user") {
274
277
  index -= 1;
275
278
  }
276
279
  for (; index >= 0; index -= 1) {
277
- const message = context.messages[index];
280
+ const message = messages[index];
278
281
  if (message.role !== "toolResult")
279
282
  break;
280
283
  const replayId = getReplayId(message.toolCallId);
@@ -1,4 +1,5 @@
1
1
  const DEFAULT_THINKING_TRACE_MAX_CHARS = 50000;
2
+ export const CURSOR_TEXT_MESSAGE_SEPARATOR = "\n\n";
2
3
  export class CursorPartialContentEmitter {
3
4
  stream;
4
5
  partial;
@@ -6,6 +7,7 @@ export class CursorPartialContentEmitter {
6
7
  mutuallyExclusive;
7
8
  thinkingContentIndex = -1;
8
9
  textContentIndex = -1;
10
+ textMessageCompleted = false;
9
11
  activityTraceChars = 0;
10
12
  activityTraceTruncated = false;
11
13
  constructor(stream, partial, thinkingMaxChars = DEFAULT_THINKING_TRACE_MAX_CHARS, mutuallyExclusive = true) {
@@ -79,12 +81,22 @@ export class CursorPartialContentEmitter {
79
81
  partial: this.partial,
80
82
  });
81
83
  }
84
+ completeTextMessage() {
85
+ // Defer the separator until more text arrives: an exact final answer must
86
+ // not gain trailing whitespace. A fresh Pi tool-use turn needs no prefix.
87
+ this.textMessageCompleted = this.partial.content.some((block) => block.type === "text" && block.text.length > 0);
88
+ }
82
89
  appendTextDelta(delta, options) {
83
90
  const closeThinking = options?.closeThinking ?? this.mutuallyExclusive;
84
91
  if (closeThinking)
85
92
  this.closeThinking();
86
93
  if (!delta)
87
94
  return;
95
+ if (this.textMessageCompleted) {
96
+ this.textMessageCompleted = false;
97
+ this.appendTextDelta(CURSOR_TEXT_MESSAGE_SEPARATOR, { closeThinking: false });
98
+ this.closeText();
99
+ }
88
100
  if (this.textContentIndex < 0) {
89
101
  this.textContentIndex = this.partial.content.length;
90
102
  this.partial.content.push({ type: "text", text: "" });
@@ -0,0 +1,30 @@
1
+ import * as piAi from "@earendil-works/pi-ai";
2
+ const optionalHelpers = piAi;
3
+ const transcriptHelpers = typeof optionalHelpers.normalizeContext === "function"
4
+ && typeof optionalHelpers.getCurrentSystemPrompt === "function"
5
+ && typeof optionalHelpers.getCurrentTools === "function"
6
+ ? optionalHelpers : undefined;
7
+ export function isCursorSystemMessage(message) {
8
+ return message.role === "system";
9
+ }
10
+ /** Conversation-only view for Cursor text/history and trailing tool-result scans. */
11
+ export function getCursorConversationMessages(context) {
12
+ return context.messages.filter((message) => !isCursorSystemMessage(message));
13
+ }
14
+ export function resolveCursorPiContext(context) {
15
+ if (!transcriptHelpers) {
16
+ if (context.messages.some(isCursorSystemMessage)) {
17
+ throw new Error("Pi transcript context requires the host's public transcript replay helpers.");
18
+ }
19
+ return { systemPrompt: context.systemPrompt ?? "", tools: context.tools };
20
+ }
21
+ const messages = transcriptHelpers.normalizeContext(context).messages;
22
+ const legacyWithoutTools = ("systemPrompt" in context || "tools" in context)
23
+ && context.tools === undefined && !context.messages.some(isCursorSystemMessage);
24
+ return {
25
+ systemPrompt: transcriptHelpers.getCurrentSystemPrompt(messages),
26
+ // A messages-only provider request is authoritative, including no declarations.
27
+ // An explicitly legacy shorthand context retains its absent-snapshot semantics.
28
+ tools: legacyWithoutTools ? undefined : transcriptHelpers.getCurrentTools(messages),
29
+ };
30
+ }
@@ -3,7 +3,7 @@ import { CursorLiveRunAbortError, createCursorLiveRunCoordinator, hasTrailingUse
3
3
  import { deleteCursorNativeToolDisplay, recordCursorNativeToolDisplay, } from "./cursor-native-tool-display-state.js";
4
4
  import { resetSessionCursorAgent } from "./cursor-session-agent.js";
5
5
  import { applyCursorUsage } from "./cursor-usage-accounting.js";
6
- import { CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
6
+ import { CURSOR_TEXT_MESSAGE_SEPARATOR, CursorPartialContentEmitter } from "./cursor-partial-content-emitter.js";
7
7
  import { emitDisplayOnlyTraceBlock } from "./cursor-display-only-trace.js";
8
8
  import { trimCurrentTurnAlreadyEmittedCursorText } from "./cursor-run-final-text.js";
9
9
  import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./cursor-provider-errors.js";
@@ -50,8 +50,7 @@ function splitTextIntoReplayDeltas(text) {
50
50
  }
51
51
  return deltas;
52
52
  }
53
- async function emitTextDeltas(stream, partial, deltas) {
54
- const emitter = new CursorPartialContentEmitter(stream, partial, -1, true);
53
+ async function emitTextDeltas(emitter, deltas) {
55
54
  for (const delta of deltas) {
56
55
  emitter.appendTextDelta(delta);
57
56
  await Promise.resolve();
@@ -80,7 +79,7 @@ export function flushPendingCursorLiveRunTraceEventsToStream(stream, partial, ru
80
79
  if (run.disposed)
81
80
  return;
82
81
  const turn = {
83
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
82
+ emitter: options?.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
84
83
  emittedText: "",
85
84
  };
86
85
  while (true) {
@@ -110,6 +109,15 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
110
109
  else if (event.type === "thinking-completed") {
111
110
  turn.emitter.closeThinking();
112
111
  }
112
+ else if (event.type === "text-completed") {
113
+ turn.emitter.completeTextMessage();
114
+ // Logical separators also keep suffix/prefix dedup aware of SDK messages,
115
+ // including boundaries queued across Pi tool-use turns.
116
+ if (turn.emittedText)
117
+ turn.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
118
+ if (run?.emittedText)
119
+ run.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
120
+ }
113
121
  else if (event.type === "text-delta") {
114
122
  turn.emittedText += event.text;
115
123
  if (run)
@@ -231,7 +239,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
231
239
  let outcome;
232
240
  let outcomeDetails = {};
233
241
  const turn = {
234
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
242
+ emitter: options.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
235
243
  emittedText: "",
236
244
  };
237
245
  try {
@@ -291,7 +299,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
291
299
  turn.emitter.closeAll();
292
300
  const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
293
301
  if (finalText) {
294
- await emitTextDeltas(stream, partial, splitTextIntoReplayDeltas(finalText));
302
+ await emitTextDeltas(turn.emitter, splitTextIntoReplayDeltas(finalText));
295
303
  }
296
304
  applyCursorUsage(partial, model, context, cursorLiveRuns.takeTurnInputTokens(run, toolResultInputTokens), {
297
305
  runtime: "local",
@@ -1,3 +1,4 @@
1
+ import { CURSOR_TEXT_MESSAGE_SEPARATOR } from "./cursor-partial-content-emitter.js";
1
2
  import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
2
3
  import { truncateCursorDisplayLine } from "./cursor-display-text.js";
3
4
  import { recordDiscardedIncompleteStartedToolCall, } from "./cursor-sdk-event-debug.js";
@@ -29,6 +30,7 @@ export class CursorSdkTurnCoordinator {
29
30
  lifecycleEmitter;
30
31
  contentEmitter;
31
32
  sdkTurnUsage;
33
+ hasPendingTextMessage = false;
32
34
  constructor(options) {
33
35
  this.stream = options.stream;
34
36
  this.partial = options.partial;
@@ -106,6 +108,8 @@ export class CursorSdkTurnCoordinator {
106
108
  cursorLiveRuns.recordSdkTurnEnded(this.liveRun, sdkTurnUsage);
107
109
  }
108
110
  if (update.type === "text-delta") {
111
+ if (update.text)
112
+ this.hasPendingTextMessage = true;
109
113
  this.textDeltas.push(update.text);
110
114
  if (this.liveRun) {
111
115
  cursorLiveRuns.queueEvent(this.liveRun, { type: "text-delta", text: update.text });
@@ -206,6 +210,20 @@ export class CursorSdkTurnCoordinator {
206
210
  }
207
211
  handleStep(stepEnvelope) {
208
212
  const stepType = getField(stepEnvelope, "type");
213
+ if (stepType === "assistantMessage") {
214
+ // SDK onStep completes a message; onDelta chunks within it are tokens,
215
+ // not separate messages. Keep the boundary in both output and final-text
216
+ // deduplication, without replaying the completed step's text a second time.
217
+ if (this.hasPendingTextMessage) {
218
+ this.hasPendingTextMessage = false;
219
+ this.textDeltas.push(CURSOR_TEXT_MESSAGE_SEPARATOR);
220
+ if (this.liveRun)
221
+ cursorLiveRuns.queueEvent(this.liveRun, { type: "text-completed" });
222
+ else
223
+ this.contentEmitter.completeTextMessage();
224
+ }
225
+ return;
226
+ }
209
227
  const step = getField(stepEnvelope, "message") ? stepEnvelope : undefined;
210
228
  const rawStepToolCall = getField(step, "message");
211
229
  if (stepType !== "toolCall")
@@ -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 { cursorLiveRuns, drainCursorLiveRunTurn, flushPendingCursorLiveRunTraceEventsToStream, settleCursorLiveToolBatch, } from "./cursor-provider-live-run-drain.js";
3
4
  import { buildIncompleteCursorToolRunOutcome, } from "./cursor-incomplete-tool-visibility.js";
4
5
  export async function emitCursorLiveTurn(emitParams) {
@@ -7,6 +8,9 @@ export async function emitCursorLiveTurn(emitParams) {
7
8
  throw new Error("emitCursorLiveTurn requires a live run");
8
9
  const { liveRun, turnCoordinator } = prepared.runtime;
9
10
  const { options, model } = params;
11
+ // Drain and abort recovery write to the same partial. Keep its open text block
12
+ // and deferred message boundary together; a new Pi turn still gets a new emitter.
13
+ const emitter = new CursorPartialContentEmitter(params.stream, params.partial, -1, true);
10
14
  try {
11
15
  await cursorLiveRuns.withRunLease(liveRun, options?.signal, async () => {
12
16
  await cursorLiveRuns.waitForProgress(liveRun, options?.signal);
@@ -14,6 +18,7 @@ export async function emitCursorLiveTurn(emitParams) {
14
18
  turnCoordinator.closeTraceBlock();
15
19
  await drainCursorLiveRunTurn(params.stream, params.partial, model, params.context, liveRun, 0, {
16
20
  mode: "emit",
21
+ emitter,
17
22
  signal: options?.signal,
18
23
  debugRecorder: sdkEventDebug,
19
24
  });
@@ -25,6 +30,7 @@ export async function emitCursorLiveTurn(emitParams) {
25
30
  turnCoordinator.closeTraceBlock();
26
31
  flushPendingCursorLiveRunTraceEventsToStream(params.stream, params.partial, liveRun, {
27
32
  includeTracesBehindQueuedTools: true,
33
+ emitter,
28
34
  });
29
35
  }
30
36
  throw caught;
@@ -1,3 +1,4 @@
1
+ import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
1
2
  import { configureCursorSdkHttp1 } from "./cursor-http1.js";
2
3
  import { installCursorMcpToolTimeoutOverride } from "./cursor-mcp-timeout-override.js";
3
4
  import { ensureCursorRipgrepPath } from "./cursor-ripgrep-path.js";
@@ -25,12 +26,16 @@ import { loadCursorSdk } from "./cursor-sdk-runtime.js";
25
26
  function buildCursorCloudPromptContext(context, handoff) {
26
27
  if (handoff === "bootstrap")
27
28
  return context;
28
- for (let index = context.messages.length - 1; index >= 0; index -= 1) {
29
- const message = context.messages[index];
29
+ // Fresh cloud runs omit history, not the current Pi instructions. Replay before
30
+ // selecting the user message, otherwise transcript-only inputs lose the prompt.
31
+ const current = resolveCursorPiContext(context);
32
+ const messages = getCursorConversationMessages(context);
33
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
34
+ const message = messages[index];
30
35
  if (message.role === "user")
31
- return { ...context, messages: [message] };
36
+ return { ...current, messages: [message] };
32
37
  }
33
- return { ...context, messages: context.messages.slice(-1) };
38
+ return { ...current, messages: messages.slice(-1) };
34
39
  }
35
40
  const CLOUD_SEND_PLAN = { mode: "bootstrap", resetAgent: false, reason: "initial" };
36
41
  export function resolveCursorProviderTurnConfig(cwd) {
@@ -69,7 +74,7 @@ async function prepareCursorCloudProviderTurn(prepareParams) {
69
74
  repo: resolvedConfig.cloud.repo.value,
70
75
  branch: resolvedConfig.cloud.branch.value,
71
76
  }),
72
- hasPriorContext: context.messages.length > 1,
77
+ hasPriorContext: getCursorConversationMessages(context).length > 1,
73
78
  });
74
79
  if (!preflight.ok)
75
80
  throw new Error(formatCursorCloudPreflightError(preflight));
@@ -8,7 +8,7 @@ import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge-env.js
8
8
  import { resolveEffectiveCursorConfigForContext } from "./cursor-runtime-state.js";
9
9
  export const CURSOR_ACTIVATE_SKILL_TOOL_NAME = "cursor_activate_skill";
10
10
  export const CURSOR_ACTIVATE_SKILL_MCP_NAME = "pi__cursor_activate_skill";
11
- const AVAILABLE_SKILLS_SECTION_PATTERN = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
11
+ const AVAILABLE_SKILLS_SECTION_PATTERN = /<skills>\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>\n<\/skills>|\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
12
12
  const MAX_SKILL_RESOURCES = 80;
13
13
  const RESOURCE_DIR_NAMES = ["scripts", "references", "assets"];
14
14
  let currentSkillsByName = new Map();
@@ -8,6 +8,8 @@ Implemented design target. This file describes the intended Cursor model UX and
8
8
 
9
9
  Current implementation notes:
10
10
 
11
+ - Provider contexts support stock Pi 0.84.0/0.85.1 shorthand fields and transcript-only hosts. `cursor-pi-context.ts` feature-detects public Pi replay helpers through a static namespace import; no new export is required from stock Pi. Current instructions and tools are replayed natively, system deltas are fingerprinted, and system messages are excluded from Cursor conversation formatting and trailing-result scans. Fresh cloud context preserves current instructions while excluding history. Native replay uses the request tool snapshot (including empty); the pi bridge retains its separate registry-owned surface. XML prompt sections are sanitized/deduplicated alongside the stock format.
12
+
11
13
  - Cursor context variants use `base@context` pi model IDs.
12
14
  - Cursor `reasoning`, `effort`, and boolean `thinking` parameters are driven by pi native thinking when the Cursor SDK exposes those controls.
13
15
  - Cursor `fast` is extension state by default; models that expose `fast` also get selection-only `:fast` / `:slow` virtual aliases for per-agent overrides.
@@ -33,7 +33,7 @@ When changing provider/runtime behavior, ask whether the bug spans **pi extensio
33
33
  Native replay routing intentionally uses two layers:
34
34
 
35
35
  1. **Extension resync** (`before_agent_start`, `turn_start`) updates pi's active tool set via `syncRegisteredNativeCursorToolsForModel`. This fixes the common case where plan-mode execute strips `grep`/`find`/`cursor` before the next turn.
36
- 2. **Provider routing** uses the **`context.tools` snapshot** captured when `streamCursor()` starts (`getActiveContextToolNames` in `src/cursor-context-tools.ts`). It does not read live `pi.getActiveTools()` mid-stream.
36
+ 2. **Provider routing** uses the **request tool snapshot** captured when `streamCursor()` starts (`getActiveContextToolNames` in `src/cursor-context-tools.ts`): legacy `context.tools` on stock Pi 0.84/0.85, or tools replayed by the host's public helpers on transcript-only Pi. An explicit empty snapshot is not an absent legacy snapshot. It does not read live `pi.getActiveTools()` mid-stream. The bridge intentionally retains its separate registry-owned surface.
37
37
 
38
38
  `src/cursor-native-replay-routing.ts` centralizes provider-side routing against the same `context.tools` snapshot:
39
39
 
@@ -48,6 +48,12 @@ Disposition outcomes:
48
48
 
49
49
  If resync runs but `context.tools` is still stale (e.g. only `read` listed), the provider must **not** emit `toolUse` for inactive tools. `test/cursor-native-replay-stress.test.ts` covers that stale-snapshot path.
50
50
 
51
+ ## Stock/transcript provider contract
52
+
53
+ `test/cursor-provider-pi-context.test.ts` drives real `ModelRuntime` / `ModelRegistry` requests into `streamCursor`, with only Cursor SDK execution mocked. Run it against each supported host with all Pi peer imports pinned to that host (including nested native imports); a top-level package version alone is not resolution evidence. Cover stock 0.84.0, stock 0.85.1, and the transcript host. Transcript-only cases are not stock features and are skipped there.
54
+
55
+ The test covers bootstrap/incremental prompts, empty-vs-absent request tools, native replay/drain, cloud fresh/bootstrap selection, and actual host prompt serialization for context files and skills. It is offline contract evidence, not a replacement for the required live platform/cloud release gates.
56
+
51
57
  ## Auth: use `auth.json`, not only env
52
58
 
53
59
  pi resolves Cursor auth in this order:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "pi provider extension backed by @cursor/sdk local and cloud agents",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
package/src/context.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
2
3
  import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
3
4
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
4
5
  import type { AgentModeOption, SDKImage } from "@cursor/sdk";
@@ -85,7 +86,7 @@ function getCursorBootstrapTailSections(
85
86
  }
86
87
 
87
88
  function normalizePiContextMessages(messages: Context["messages"]): Message[] {
88
- return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
89
+ return convertToLlm(getCursorConversationMessages({ messages }) as Parameters<typeof convertToLlm>[0]);
89
90
  }
90
91
 
91
92
  function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
@@ -137,6 +138,15 @@ function formatToolCall(toolCall: ToolCall): string {
137
138
 
138
139
  function sanitizeSystemPromptForCursor(systemPrompt: string): string {
139
140
  let sanitized = systemPrompt;
141
+ // Transcript-era Pi wraps its built-in tool catalog/rules in XML sections.
142
+ sanitized = sanitized.replace(
143
+ /<tools>\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n<\/tools>/g,
144
+ "Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.",
145
+ );
146
+ sanitized = sanitized.replace(
147
+ /<rules>\n[\s\S]*?\n<\/rules>\n\n(?=<docs>\nPi documentation )/g,
148
+ "<rules>\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n</rules>\n\n",
149
+ );
140
150
  sanitized = sanitized.replace(
141
151
  /Available tools:\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n\n/g,
142
152
  "Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.\n\n",
@@ -279,12 +289,24 @@ function serializeMessageForFingerprint(message: Message, index: number): string
279
289
  return hashCursorContextValue(
280
290
  `toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`,
281
291
  );
292
+ default:
293
+ // System messages are handled by serializeRawPiMessageForFingerprint.
294
+ throw new Error("Unsupported Pi message role in Cursor context fingerprint.");
282
295
  }
283
296
  }
284
297
 
285
298
  function serializeRawPiMessageForFingerprint(message: Context["messages"][number], index: number): string {
286
299
  const role = (message as { role?: string }).role;
287
300
  switch (role) {
301
+ case "system": {
302
+ const entry = message as {
303
+ content: unknown; sections?: unknown; toolsAdded?: unknown; toolsRemoved?: unknown;
304
+ };
305
+ return hashCursorContextValue(JSON.stringify({
306
+ role, content: entry.content, sections: entry.sections,
307
+ toolsAdded: entry.toolsAdded, toolsRemoved: entry.toolsRemoved,
308
+ }));
309
+ }
288
310
  case "branchSummary": {
289
311
  const entry = message as { summary?: string; fromId?: string; timestamp?: number };
290
312
  return hashCursorContextValue(
@@ -337,7 +359,7 @@ function parseCursorContextFingerprint(fingerprint: string): CursorContextFinger
337
359
 
338
360
  export function computeCursorContextFingerprint(context: Context): string {
339
361
  const payload: CursorContextFingerprintPayload = {
340
- systemHash: hashCursorContextValue(context.systemPrompt ?? ""),
362
+ systemHash: hashCursorContextValue(JSON.stringify(resolveCursorPiContext(context))),
341
363
  messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
342
364
  };
343
365
  return JSON.stringify(payload);
@@ -412,8 +434,9 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
412
434
  sectionsBeforeMessages.push(options.toolManifest);
413
435
  }
414
436
 
415
- if (context.systemPrompt) {
416
- sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
437
+ const { systemPrompt } = resolveCursorPiContext(context);
438
+ if (systemPrompt) {
439
+ sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(systemPrompt)}`);
417
440
  }
418
441
 
419
442
  const messages = normalizePiContextMessages(context.messages);
@@ -114,9 +114,21 @@ export function serializePiProjectInstructionsBlock(file: PiAgentsContextFile):
114
114
  return `${PI_PROJECT_INSTRUCTIONS_OPEN_PREFIX}${file.path}">\n${file.content}\n${PI_PROJECT_INSTRUCTIONS_CLOSE}\n\n`;
115
115
  }
116
116
 
117
- /** Exact pi `buildSystemPrompt()` serialization for the full project context section. */
118
- export function serializePiProjectContextSection(contextFiles: readonly PiAgentsContextFile[]): string {
117
+ /** The two supported Pi project-context serializations; legacy includes outer spacing. */
118
+ export function serializePiProjectContextSection(
119
+ contextFiles: readonly PiAgentsContextFile[],
120
+ format: "legacy" | "transcript" = "legacy",
121
+ ): string {
119
122
  if (contextFiles.length === 0) return "";
123
+ if (format === "transcript") {
124
+ return [
125
+ "<project_context>",
126
+ "Project-specific instructions and guidelines:",
127
+ "",
128
+ contextFiles.map((file) => serializePiProjectInstructionsBlock(file).trimEnd()).join("\n\n"),
129
+ "</project_context>",
130
+ ].join("\n");
131
+ }
120
132
  return `${PI_PROJECT_CONTEXT_OPEN}${contextFiles.map(serializePiProjectInstructionsBlock).join("")}${PI_PROJECT_CONTEXT_CLOSE}`;
121
133
  }
122
134
 
@@ -138,12 +150,16 @@ export function removePiAgentsContextFromSystemPrompt(
138
150
  }
139
151
  if (!removedAny) return systemPrompt;
140
152
 
141
- const originalSection = serializePiProjectContextSection(contextFiles);
142
- const start = systemPrompt.indexOf(originalSection);
143
- if (start < 0) return systemPrompt;
144
-
145
- const replacementSection = serializePiProjectContextSection(retainedContextFiles);
146
- return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
153
+ // Match only the exact known files in either supported serialization; never
154
+ // strip arbitrary user-authored XML or neighboring custom sections.
155
+ for (const format of ["legacy", "transcript"] as const) {
156
+ const originalSection = serializePiProjectContextSection(contextFiles, format);
157
+ const start = systemPrompt.indexOf(originalSection);
158
+ if (start < 0) continue;
159
+ const replacementSection = serializePiProjectContextSection(retainedContextFiles, format);
160
+ return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
161
+ }
162
+ return systemPrompt;
147
163
  }
148
164
 
149
165
  export function resolveCursorFacingSystemPrompt(
@@ -1,6 +1,8 @@
1
1
  import type { Context } from "@earendil-works/pi-ai";
2
+ import { resolveCursorPiContext } from "./cursor-pi-context.js";
2
3
 
3
4
  /** Tool names from the provider context snapshot at stream start (not live pi.getActiveTools()). */
4
5
  export function getActiveContextToolNames(context: Context): ReadonlySet<string> | undefined {
5
- return context.tools ? new Set(context.tools.map((tool) => tool.name)) : undefined;
6
+ const { tools } = resolveCursorPiContext(context);
7
+ return tools === undefined ? undefined : new Set(tools.map((tool) => tool.name));
6
8
  }
@@ -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
 
@@ -155,16 +157,17 @@ interface CursorLiveRunPrivateState {
155
157
  }
156
158
 
157
159
  export function hasTrailingUserMessagesAfterToolResults(context: Context): boolean {
158
- let index = context.messages.length - 1;
160
+ const messages = getCursorConversationMessages(context);
161
+ let index = messages.length - 1;
159
162
  let sawTrailingUser = false;
160
- while (index >= 0 && context.messages[index]?.role === "user") {
163
+ while (index >= 0 && messages[index]?.role === "user") {
161
164
  sawTrailingUser = true;
162
165
  index -= 1;
163
166
  }
164
167
  if (!sawTrailingUser) return false;
165
168
 
166
169
  let sawToolResult = false;
167
- while (index >= 0 && context.messages[index]?.role === "toolResult") {
170
+ while (index >= 0 && messages[index]?.role === "toolResult") {
168
171
  sawToolResult = true;
169
172
  index -= 1;
170
173
  }
@@ -418,13 +421,14 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
418
421
  },
419
422
 
420
423
  getPendingFromContext(context, getReplayId): CursorLiveRun | undefined {
421
- let index = context.messages.length - 1;
422
- 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") {
423
427
  index -= 1;
424
428
  }
425
429
 
426
430
  for (; index >= 0; index -= 1) {
427
- const message = context.messages[index];
431
+ const message = messages[index];
428
432
  if (message.role !== "toolResult") break;
429
433
  const replayId = getReplayId(message.toolCallId);
430
434
  if (replayId) {
@@ -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
+ }
@@ -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,7 +382,7 @@ 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",
@@ -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;
@@ -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()) {
@@ -18,7 +18,7 @@ import { resolveEffectiveCursorConfigForContext } from "./cursor-runtime-state.j
18
18
  export const CURSOR_ACTIVATE_SKILL_TOOL_NAME = "cursor_activate_skill";
19
19
  export const CURSOR_ACTIVATE_SKILL_MCP_NAME = "pi__cursor_activate_skill";
20
20
 
21
- const AVAILABLE_SKILLS_SECTION_PATTERN = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
21
+ const AVAILABLE_SKILLS_SECTION_PATTERN = /<skills>\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>\n<\/skills>|\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
22
22
  const MAX_SKILL_RESOURCES = 80;
23
23
  const RESOURCE_DIR_NAMES = ["scripts", "references", "assets"] as const;
24
24