pi-cursor-sdk 0.3.6 → 0.3.8

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 (40) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +16 -5
  3. package/dist/context.js +19 -4
  4. package/dist/cursor-agents-context.js +22 -8
  5. package/dist/cursor-context-tools.js +3 -1
  6. package/dist/cursor-live-run-coordinator.js +9 -6
  7. package/dist/cursor-partial-content-emitter.js +12 -0
  8. package/dist/cursor-pi-context.js +30 -0
  9. package/dist/cursor-provider-live-run-drain.js +19 -6
  10. package/dist/cursor-provider-turn-coordinator.js +18 -0
  11. package/dist/cursor-provider-turn-emit.js +6 -0
  12. package/dist/cursor-provider-turn-prepare.js +10 -5
  13. package/dist/cursor-record-utils.js +19 -0
  14. package/dist/cursor-skill-tool.js +1 -1
  15. package/docs/cursor-dogfood-checklist.md +1 -1
  16. package/docs/cursor-live-smoke-checklist.md +1 -1
  17. package/docs/cursor-model-ux-spec.md +2 -0
  18. package/docs/cursor-testing-lessons.md +25 -1
  19. package/docs/platform-smoke-implementation.md +2 -0
  20. package/docs/platform-smoke.md +1 -1
  21. package/package.json +2 -2
  22. package/scripts/build.mjs +2 -1
  23. package/scripts/lib/local-resume-smoke-harness.mjs +1 -6
  24. package/scripts/local-resume-smoke.mjs +43 -4
  25. package/scripts/platform-smoke/card-detect.mjs +2 -4
  26. package/scripts/platform-smoke/live-suite-runner.mjs +5 -7
  27. package/scripts/platform-smoke/pty-capture.mjs +3 -10
  28. package/scripts/platform-smoke/render-ansi.mjs +2 -5
  29. package/src/context.ts +27 -4
  30. package/src/cursor-agents-context.ts +24 -8
  31. package/src/cursor-context-tools.ts +3 -1
  32. package/src/cursor-live-run-coordinator.ts +10 -6
  33. package/src/cursor-partial-content-emitter.ts +13 -0
  34. package/src/cursor-pi-context.ts +44 -0
  35. package/src/cursor-provider-live-run-drain.ts +16 -9
  36. package/src/cursor-provider-turn-coordinator.ts +15 -0
  37. package/src/cursor-provider-turn-emit.ts +6 -0
  38. package/src/cursor-provider-turn-prepare.ts +10 -5
  39. package/src/cursor-record-utils.ts +19 -0
  40. package/src/cursor-skill-tool.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.8 - 2026-09-19
4
+
5
+ ### Fixed
6
+
7
+ - Narrow SDK replay and MCP bridge arguments to JSON objects before emitting Pi tool calls, preserving valid payloads without serialization or casts. This supports Pi's tightened transcript types and reports non-JSON arguments before dispatch.
8
+ - Compile source and test fixtures against published Pi 0.85.1, pinned official source, and the fork. Native packed-host checks require common provider, replay, and runtime behavior on every host. The fork additionally qualifies legacy `SystemMessage.replace` checkpoints; current official Pi removed that host feature, which is not required by this extension.
9
+ - Load the already-shipped TypeScript source graph through Pi's native loader so provider streams and replay helpers remain host-owned even with local Pi peer packages installed. Keep compiled output for scripts and programmatic consumers.
10
+ - Preserve hyperlink text and split terminal control sequences when checking platform-smoke read cards, using Node's terminal-control stripping rather than a custom regular expression.
11
+
12
+ ### Changed
13
+
14
+ - Native source loading fixes host peer identity at the cost of more initial startup/load work. On macOS/Node 24.21.0/Pi fork `90e6`, three fresh-process, warm-cache loader runs measured 0.85–1.24 s for source versus 0.08–0.13 s for dist; these are not TUI startup timings or guarantees for other environments or repeated `/reload`.
15
+ - **Filter migration required:** update old dist-targeting extension filters to preserve include/exclude intent (`+dist/index.js` → `+src/index.ts`, `-dist/index.js` → `-src/index.ts`, `!dist/**` → `!src/**`). The old exclusion alone no longer disables Cursor; retain `-dist/index.js` alongside `-src/index.ts` for rollback. See README's “Existing extension filters”; settings are not rewritten automatically.
16
+
17
+ ## 0.3.7 - 2026-09-16
18
+
19
+ ### Fixed
20
+
21
+ - 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.
22
+
23
+ - 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.
24
+ - Fingerprint replayed instructions and tool definitions plus individual system-message content/section/tool deltas, so unchanged transcripts stay incremental and changed context reboots correctly.
25
+ - Preserve current Pi instructions in fresh cloud prompts, and ignore system updates when finding pending replay/bridge tool results and steering input.
26
+ - 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.
27
+
3
28
  ## 0.3.6 - 2026-08-18
4
29
 
5
30
  ### 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
 
@@ -70,6 +70,15 @@ Alternative GitHub install:
70
70
  pi install https://github.com/fitchmultz/pi-cursor-sdk
71
71
  ```
72
72
 
73
+ ### Existing extension filters
74
+
75
+ The Pi entrypoint is now `src/index.ts`. Update any filters in this package's `extensions` setting that target the old `dist/` entry, preserving their include/exclude intent:
76
+
77
+ - Exact filters: `+dist/index.js` → `+src/index.ts`; `-dist/index.js` → `-src/index.ts`.
78
+ - Glob filters: for example, `dist/**` → `src/**` and `!dist/**` → `!src/**`. Adjust other old-dist patterns to match the new entrypoint with the same intent.
79
+
80
+ To keep Cursor disabled across upgrades and rollback, retain `-dist/index.js` and add `-src/index.ts`; the old exclusion alone no longer disables Cursor. The package does not rewrite user or project settings.
81
+
73
82
  ### Project-local install
74
83
 
75
84
  Use `-l` if you want the package recorded in the current project's `.pi/settings.json` instead of your global pi settings:
@@ -91,11 +100,13 @@ Without `--approve`, the project-local extension still runs after Pi trusts the
91
100
  For development from this repository:
92
101
 
93
102
  ```bash
94
- npm install # runs prepare, which compiles src/ into dist/ (the manifest entry pi loads)
103
+ npm install # runs prepare to build dist/ for scripts and programmatic consumers
95
104
  pi --approve -e . --model cursor/grok-4.6
96
105
  ```
97
106
 
98
- After editing `src/`, run `npm run build` before the next `pi -e .` run, or pi loads the previous build.
107
+ Pi loads the packaged `src/index.ts` graph through its native TypeScript loader. This keeps Pi imports bound to the running host even when another Pi version is installed alongside the extension. `dist/` remains shipped for scripts and programmatic consumers. After editing `src/`, run `npm run build` for validation and compiled consumers, then restart Pi.
108
+
109
+ The host-identity fix adds initial startup/load work: three fresh-process loader measurements on macOS with Node 24.21.0, Pi fork `90e6`, a warm Jiti disk cache, and network denied took 0.85–1.24 s for source versus 0.08–0.13 s for dist. These are environment-specific loader timings, not full TUI startup measurements or timing guarantees. That host caches extension factories for the same cwd; the initial-load cost does not describe every in-process `/reload`.
99
110
 
100
111
  ## Configure your Cursor SDK API key
101
112
 
@@ -708,7 +719,7 @@ npm install
708
719
  CURSOR_API_KEY="your-key" pi --approve -e . --model cursor/grok-4.6
709
720
  ```
710
721
 
711
- After editing `src/`, run `npm run build` before the next `pi -e .` run, or pi loads the previous build.
722
+ After editing `src/`, run `npm run build` for validation and compiled consumers, then restart Pi to load the changed source graph.
712
723
 
713
724
  Maintainer design notes live in [`docs/cursor-model-ux-spec.md`](docs/cursor-model-ux-spec.md).
714
725
 
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,12 +3,13 @@ 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";
10
10
  import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
11
11
  import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
12
+ import { isJsonObject } from "./cursor-record-utils.js";
12
13
  export const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
13
14
  const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
14
15
  let cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
@@ -50,8 +51,7 @@ function splitTextIntoReplayDeltas(text) {
50
51
  }
51
52
  return deltas;
52
53
  }
53
- async function emitTextDeltas(stream, partial, deltas) {
54
- const emitter = new CursorPartialContentEmitter(stream, partial, -1, true);
54
+ async function emitTextDeltas(emitter, deltas) {
55
55
  for (const delta of deltas) {
56
56
  emitter.appendTextDelta(delta);
57
57
  await Promise.resolve();
@@ -80,7 +80,7 @@ export function flushPendingCursorLiveRunTraceEventsToStream(stream, partial, ru
80
80
  if (run.disposed)
81
81
  return;
82
82
  const turn = {
83
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
83
+ emitter: options?.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
84
84
  emittedText: "",
85
85
  };
86
86
  while (true) {
@@ -110,6 +110,15 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
110
110
  else if (event.type === "thinking-completed") {
111
111
  turn.emitter.closeThinking();
112
112
  }
113
+ else if (event.type === "text-completed") {
114
+ turn.emitter.completeTextMessage();
115
+ // Logical separators also keep suffix/prefix dedup aware of SDK messages,
116
+ // including boundaries queued across Pi tool-use turns.
117
+ if (turn.emittedText)
118
+ turn.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
119
+ if (run?.emittedText)
120
+ run.emittedText += CURSOR_TEXT_MESSAGE_SEPARATOR;
121
+ }
113
122
  else if (event.type === "text-delta") {
114
123
  turn.emittedText += event.text;
115
124
  if (run)
@@ -120,6 +129,8 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
120
129
  function emitCursorNativeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, tools, debugRecorder) {
121
130
  const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
122
131
  for (const tool of tools) {
132
+ if (!isJsonObject(tool.args))
133
+ throw new Error("Cursor replay tool arguments must be a JSON object");
123
134
  const contentIndex = partial.content.length;
124
135
  partial.content.push({
125
136
  type: "toolCall",
@@ -164,6 +175,8 @@ function emitInactiveCursorReplayTrace(turn, tools, debugRecorder) {
164
175
  }
165
176
  function emitCursorBridgeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, requests) {
166
177
  for (const request of requests) {
178
+ if (!isJsonObject(request.args))
179
+ throw new Error("Cursor bridge tool arguments must be a JSON object");
167
180
  const contentIndex = partial.content.length;
168
181
  partial.content.push({
169
182
  type: "toolCall",
@@ -231,7 +244,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
231
244
  let outcome;
232
245
  let outcomeDetails = {};
233
246
  const turn = {
234
- emitter: new CursorPartialContentEmitter(stream, partial, -1, true),
247
+ emitter: options.emitter ?? new CursorPartialContentEmitter(stream, partial, -1, true),
235
248
  emittedText: "",
236
249
  };
237
250
  try {
@@ -291,7 +304,7 @@ export async function drainCursorLiveRunTurn(stream, partial, model, context, ru
291
304
  turn.emitter.closeAll();
292
305
  const finalText = trimCurrentTurnAlreadyEmittedCursorText(run.finalText ?? run.textDeltas.join(""), turn.emittedText, run.emittedText);
293
306
  if (finalText) {
294
- await emitTextDeltas(stream, partial, splitTextIntoReplayDeltas(finalText));
307
+ await emitTextDeltas(turn.emitter, splitTextIntoReplayDeltas(finalText));
295
308
  }
296
309
  applyCursorUsage(partial, model, context, cursorLiveRuns.takeTurnInputTokens(run, toolResultInputTokens), {
297
310
  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));
@@ -1,3 +1,22 @@
1
+ /** Narrow untyped SDK/MCP arguments at the Pi transcript boundary without rewriting them. */
2
+ export function isJsonObject(value) {
3
+ return value !== null && typeof value === "object" && !Array.isArray(value)
4
+ && isJsonValue(value, new Set());
5
+ }
6
+ function isJsonValue(value, ancestors) {
7
+ if (value === null || typeof value === "string" || typeof value === "boolean")
8
+ return true;
9
+ if (typeof value === "number")
10
+ return Number.isFinite(value);
11
+ if (typeof value !== "object" || ancestors.has(value))
12
+ return false;
13
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
14
+ return false;
15
+ ancestors.add(value);
16
+ const valid = Object.values(value).every(entry => isJsonValue(entry, ancestors));
17
+ ancestors.delete(value);
18
+ return valid;
19
+ }
1
20
  export function asRecord(value) {
2
21
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
3
22
  }
@@ -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();
@@ -4,7 +4,7 @@ Short maintainer checklist for **minimal-surface** validation after prompt, brid
4
4
 
5
5
  ## Minimal environment
6
6
 
7
- - Build first after any `src/` edit: `npm run build` (the pi manifest loads compiled `dist/`)
7
+ - Build first after any `src/` edit: `npm run build` (validation and compiled-script consumers); restart Pi to load the changed `src/index.ts` manifest graph
8
8
  - Extension only: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`
9
9
  - Fresh session dir: `--session-dir /tmp/pi-cursor-dogfood-<id>`
10
10
  - Baseline surface (no ambient Cursor MCP/rules):
@@ -8,7 +8,7 @@ Use this manual checklist during development and debugging of Cursor provider/ru
8
8
 
9
9
  ## Inner-loop rule
10
10
 
11
- - Build first: `npm run build` after any `src/` edit — the pi manifest loads compiled `dist/`, so unbuilt runs validate stale code. (the cloud/steering/local-resume/provider-debug launchers rebuild automatically even when run directly with `node scripts/...`; `smoke:live`/`smoke:visual`/`smoke:isolated` build via their npm scripts; direct `pi -e .` invocations do not build.)
11
+ - Build first: `npm run build` after any `src/` edit validates emit and updates `dist/` for scripts/programmatic consumers. Pi loads the packaged `src/index.ts` graph; restart Pi after source changes. The cloud/steering/local-resume/provider-debug launchers rebuild automatically even when run directly with `node scripts/...`; `smoke:live`/`smoke:visual`/`smoke:isolated` build via their npm scripts. Packed-install gates must exercise the tarball's actual manifest, not a checkout path.
12
12
  - Run from a clean working tree except for the intended branch diff.
13
13
  - Use the local extension under test: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`.
14
14
  - Use a temporary `--session-dir` for every run.
@@ -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.