pi-cursor-sdk 0.1.40 → 0.1.41

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 (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +10 -9
  3. package/docs/cursor-dogfood-checklist.md +6 -0
  4. package/docs/cursor-live-smoke-checklist.md +4 -4
  5. package/docs/cursor-model-ux-spec.md +6 -6
  6. package/docs/cursor-native-tool-replay.md +4 -4
  7. package/docs/cursor-native-tool-visual-audit.md +2 -2
  8. package/docs/cursor-testing-lessons.md +1 -1
  9. package/docs/cursor-tool-surfaces.md +4 -0
  10. package/docs/platform-smoke.md +9 -1
  11. package/package.json +8 -5
  12. package/scripts/lib/cursor-visual-manifest.d.mts +3 -0
  13. package/scripts/lib/cursor-visual-manifest.mjs +82 -0
  14. package/scripts/platform-smoke/artifacts.mjs +147 -2
  15. package/scripts/platform-smoke/card-detect.mjs +1 -1
  16. package/scripts/platform-smoke/doctor.mjs +53 -8
  17. package/scripts/platform-smoke/scenarios.mjs +1 -1
  18. package/scripts/platform-smoke.mjs +69 -7
  19. package/scripts/visual-tui-smoke-self-test.mjs +229 -0
  20. package/scripts/visual-tui-smoke.mjs +45 -179
  21. package/src/context.ts +25 -10
  22. package/src/cursor-active-tools.ts +7 -0
  23. package/src/cursor-native-tool-display-registration.ts +31 -21
  24. package/src/cursor-native-tool-display-state.ts +13 -4
  25. package/src/cursor-pi-tool-bridge-run.ts +6 -3
  26. package/src/cursor-pi-tool-bridge-types.ts +2 -2
  27. package/src/cursor-provider-errors.ts +2 -1
  28. package/src/cursor-provider-live-run-drain.ts +1 -1
  29. package/src/cursor-provider-turn-prepare.ts +1 -1
  30. package/src/cursor-provider-turn-send.ts +2 -0
  31. package/src/cursor-question-tool.ts +2 -1
  32. package/src/cursor-sdk-event-debug.ts +3 -1
  33. package/src/cursor-skill-tool.ts +2 -1
  34. package/src/cursor-tool-manifest.ts +2 -1
  35. package/src/cursor-usage-accounting.ts +5 -4
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
4
- import { tmpdir } from "node:os";
3
+ import { accessSync, constants, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
5
4
  import { delimiter, dirname, join, resolve } from "node:path";
6
5
  import { fileURLToPath } from "node:url";
7
6
  import { commonBooleanFlag, commonRepeatStringFlag, parseArgv } from "./lib/cursor-cli-args.mjs";
8
7
  import { buildCursorSmokeEnvPlan, CURSOR_SDK_EVENT_DEBUG_ENV_NAMES, sealedNodePath } from "./lib/cursor-smoke-env.mjs";
8
+ import { writeVisualManifest } from "./lib/cursor-visual-manifest.mjs";
9
+ import { runVisualSmokeSelfTest } from "./visual-tui-smoke-self-test.mjs";
9
10
  import { buildTerminalHtml, writeTerminalScreenshot } from "./lib/cursor-visual-render.mjs";
10
11
 
11
12
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -71,6 +72,7 @@ Artifacts written:
71
72
  <label>.html Self-contained browser/xterm render.
72
73
  <label>.png Browser-rendered screenshot, unless --no-screenshot.
73
74
  <label>.jsonl.path Latest persisted pi session JSONL path.
75
+ <label>.manifest.json Agent-readable artifact index for this run.
74
76
 
75
77
  Prerequisites:
76
78
  - pi, node, tmux, and npm-installed dev dependencies on PATH / in node_modules.
@@ -448,205 +450,60 @@ function runVisualSmoke(options) {
448
450
  const htmlPath = `${base}.html`;
449
451
  const pngPath = `${base}.png`;
450
452
  const jsonlPathFile = `${base}.jsonl.path`;
453
+ const manifestPath = `${base}.manifest.json`;
451
454
 
452
455
  writeUtf8(ansiPath, ansi);
453
456
  writeUtf8(textPath, plain);
454
457
  writeUtf8(htmlPath, buildTerminalHtml({ ansi, plain, options }));
455
458
 
459
+ const partialArtifacts = { ansiPath, textPath, htmlPath, pngPath, jsonlPathFile, manifestPath };
456
460
  const jsonlPath = findLatestJsonl(options.sessionDir, { sinceMs: runStartedAtMs, previousMtimes: jsonlMtimesBeforeRun });
457
- if (!jsonlPath) throw new Error(`no current-run persisted .jsonl found under ${options.sessionDir}`);
461
+ if (!jsonlPath) {
462
+ const message = `no current-run persisted .jsonl found under ${options.sessionDir}`;
463
+ writeVisualManifest(manifestPath, options, partialArtifacts, { message, writtenAt: new Date().toISOString() });
464
+ throw new Error(message);
465
+ }
458
466
  writeUtf8(jsonlPathFile, `${jsonlPath}\n`);
459
467
 
460
- return { ansiPath, textPath, htmlPath, pngPath, jsonlPathFile, jsonlPath };
468
+ return { ...partialArtifacts, jsonlPath };
461
469
  } finally {
462
470
  if (bufferLoaded) run(commands.tmux, ["delete-buffer", "-b", bufferName]);
463
471
  if (sessionStarted) run(commands.tmux, ["kill-session", "-t", sessionName]);
464
472
  }
465
473
  }
466
474
 
467
- function assertSelfTest(condition, message) {
468
- if (!condition) throw new Error(`self-test failed: ${message}`);
469
- }
470
-
471
- function envMap(assignments) {
472
- return new Map(assignments.map(([name, value]) => [name, value]));
473
- }
474
-
475
- function parseEnvCapture(path) {
476
- return new Map(
477
- readFileSync(path, "utf8")
478
- .split("\n")
479
- .filter(Boolean)
480
- .map((line) => {
481
- const index = line.indexOf("=");
482
- return index === -1 ? [line, ""] : [line.slice(0, index), line.slice(index + 1)];
483
- }),
484
- );
485
- }
486
-
487
- function runSelfTest() {
488
- const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-visual-self-test-"));
489
- try {
490
- const binDir = join(tempDir, "bin");
491
- mkdirSync(binDir, { recursive: true });
492
- const fakePi = join(binDir, "pi");
493
- const fakeNode = join(binDir, "node");
494
- const fakeNodeMarker = join(tempDir, "fake-node-used");
495
- const envCapture = join(tempDir, "fake-pi.env");
496
- writeFileSync(
497
- fakePi,
498
- `#!/usr/bin/env node\nconst { writeFileSync } = require("node:fs");\nwriteFileSync(${JSON.stringify(envCapture)}, Object.entries(process.env).map(([key, value]) => key + "=" + (value ?? "")).join("\\n") + "\\n", "utf8");\n`,
499
- "utf8",
500
- );
501
- writeFileSync(fakeNode, `#!/bin/sh\necho fake-node-used > ${shellQuote(fakeNodeMarker)}\nexit 99\n`, "utf8");
502
- chmodSync(fakePi, 0o755);
503
- chmodSync(fakeNode, 0o755);
504
-
505
- const promptFile = join(tempDir, "prompt.txt");
506
- writeFileSync(promptFile, "file prompt", "utf8");
507
- assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt-file", promptFile, "--prompt", "inline prompt"]).prompt === "inline prompt", "--prompt should override an earlier --prompt-file");
508
- assertSelfTest(parseArgs(["--label", "prompt-dash", "--prompt", "--starts-with-dash"]).prompt === "--starts-with-dash", "--prompt should accept dash-prefixed free-form text");
509
- assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt", "inline prompt", "--prompt-file", promptFile]).prompt === "file prompt", "--prompt-file should override an earlier --prompt");
510
-
511
- const jsonlDir = join(tempDir, "jsonl-filter");
512
- mkdirSync(jsonlDir, { recursive: true });
513
- const staleJsonl = join(jsonlDir, "stale.jsonl");
514
- const freshJsonl = join(jsonlDir, "fresh.jsonl");
515
- writeFileSync(staleJsonl, "{}\n", "utf8");
516
- utimesSync(staleJsonl, new Date(1_000), new Date(1_000));
517
- const previousJsonlMtimes = snapshotJsonlMtimes(jsonlDir);
518
- writeFileSync(freshJsonl, "{}\n", "utf8");
519
- utimesSync(freshJsonl, new Date(3_000), new Date(3_000));
520
- assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 2_000, previousMtimes: previousJsonlMtimes }) === freshJsonl, "JSONL discovery should ignore unchanged stale files before run start");
521
- assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 4_000, previousMtimes: snapshotJsonlMtimes(jsonlDir) }) === undefined, "JSONL discovery should not return stale evidence when current run has no changed JSONL");
522
-
523
- assertSelfTest(!sealedNodePath(process.execPath, "").includes(delimiter), "empty inherited PATH must not leave an empty PATH segment");
524
- const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
525
- const sealedHostilePath = sealedNodePath(process.execPath, hostilePath);
526
- assertSelfTest(resolveCommand("pi", hostilePath) === fakePi, "direct PATH resolver did not prefer fake PATH head");
527
- assertSelfTest(requireNode() === process.execPath, "node resolver must use process.execPath");
528
- assertSelfTest(requireCommand("pi", { envPath: hostilePath, env: { ...process.env, PATH: sealedHostilePath } }) === fakePi, "pi prereq should use sealed PATH when executing the shim");
529
- assertSelfTest(!existsSync(fakeNodeMarker), "pi prereq should not use hostile fake node");
530
-
531
- const baseOptions = {
532
- ext: ROOT,
533
- cwd: ROOT,
534
- mode: DEFAULT_MODE,
535
- model: DEFAULT_MODEL,
536
- outDir: tempDir,
537
- safeLabel: "self-test",
538
- sessionDir: join(tempDir, "session"),
539
- sessionId: "self-test",
540
- settingSources: DEFAULT_SETTING_SOURCES,
541
- bridge: false,
542
- exposeBuiltinTools: false,
543
- eventDebug: false,
544
- };
545
- const plan = buildLaunchPlan(baseOptions, { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath }, "/bin/sh");
546
- const defaults = envMap(plan.envAssignments);
547
- assertSelfTest(defaults.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "native display must be forced on");
548
- assertSelfTest(defaults.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "native tool registration must be forced on");
549
- assertSelfTest(defaults.get("PI_CURSOR_SETTING_SOURCES") === "none", "setting sources must default to none");
550
- assertSelfTest(defaults.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "bridge must default off");
551
- assertSelfTest(defaults.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "built-in exposure must default off");
552
- for (const name of DEBUG_ENV_NAMES) {
553
- assertSelfTest(plan.clearEnvNames.includes(name), `${name} must be cleared by default`);
554
- }
555
- assertSelfTest(plan.script.includes(shellQuote(fakePi)), "launch script must use resolved pi path");
556
- assertSelfTest(!plan.script.includes(" exec pi "), "launch script must not use bare pi");
557
- const hostileEnv = {
558
- ...process.env,
559
- ...Object.fromEntries(DEBUG_ENV_NAMES.map((name) => [name, join(tempDir, name)])),
560
- PATH: hostilePath,
561
- PI_CURSOR_REGISTER_NATIVE_TOOLS: "0",
562
- PI_CURSOR_SETTING_SOURCES: "all",
563
- PI_CURSOR_PI_TOOL_BRIDGE: "1",
564
- PI_CURSOR_EXPOSE_BUILTIN_TOOLS: "1",
565
- };
566
- const probe = run("/bin/sh", ["-c", plan.script], { env: hostileEnv });
567
- assertSelfTest(probe.status === 0, `fake-pi env capture exited ${probe.status}: ${probe.stderr?.toString() ?? ""}`);
568
- const capturedEnv = parseEnvCapture(envCapture);
569
- assertSelfTest(!existsSync(fakeNodeMarker), "launch PATH should force the resolved node before hostile fake node");
570
- assertSelfTest((capturedEnv.get("PATH") ?? "").split(delimiter)[0] === dirname(process.execPath), "captured PATH should start with resolved node directory");
571
- assertSelfTest(capturedEnv.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "captured env should force native display on");
572
- assertSelfTest(capturedEnv.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "captured env should force native registration on");
573
- assertSelfTest(capturedEnv.get("PI_CURSOR_SETTING_SOURCES") === "none", "captured env should force settings off");
574
- assertSelfTest(capturedEnv.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "captured env should force bridge off");
575
- assertSelfTest(capturedEnv.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "captured env should force built-in exposure off");
576
- for (const name of DEBUG_ENV_NAMES) {
577
- assertSelfTest(!capturedEnv.has(name), `${name} should be absent from captured env by default`);
578
- }
579
-
580
- const optInPlan = buildLaunchPlan(
581
- { ...baseOptions, settingSources: "all", bridge: true, exposeBuiltinTools: true, eventDebug: true },
582
- { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath },
583
- "/bin/sh",
584
- );
585
- const optIns = envMap(optInPlan.envAssignments);
586
- assertSelfTest(optIns.get("PI_CURSOR_SETTING_SOURCES") === "all", "setting source opt-in must be reflected");
587
- assertSelfTest(optIns.get("PI_CURSOR_PI_TOOL_BRIDGE") === "1", "bridge opt-in must be reflected");
588
- assertSelfTest(optIns.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "1", "built-in exposure opt-in must be reflected");
589
- assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug opt-in must be reflected");
590
- assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir must be deterministic under out-dir");
591
- for (const name of DEBUG_ENV_NAMES) {
592
- assertSelfTest(optInPlan.clearEnvNames.includes(name), `${name} must be cleared even when event debug is explicit`);
593
- }
594
- const eventDebugProbe = run("/bin/sh", ["-c", optInPlan.script], { env: hostileEnv });
595
- assertSelfTest(eventDebugProbe.status === 0, `fake-pi event-debug env capture exited ${eventDebugProbe.status}: ${eventDebugProbe.stderr?.toString() ?? ""}`);
596
- const capturedEventDebugEnv = parseEnvCapture(envCapture);
597
- assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug should be explicitly enabled");
598
- assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG_DIR") === join(tempDir, "self-test.cursor-sdk-events"), "event debug dir should be deterministic under out-dir");
599
- assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR"), "stale event debug run dir should be cleared");
600
- assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR"), "stale event debug session dir should be cleared");
601
- assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_STDERR"), "stale event debug stderr flag should be cleared");
602
-
603
- const fakeTmux = join(binDir, "tmux");
604
- const deleteBufferMarker = join(tempDir, "delete-buffer-called");
605
- writeFileSync(
606
- fakeTmux,
607
- `#!/bin/sh\ncase "$1" in\n -V) echo 'tmux fake'; exit 0 ;;\n new-session) exit 0 ;;\n load-buffer) cat >/dev/null; exit 0 ;;\n paste-buffer) exit 77 ;;\n delete-buffer) echo deleted > ${shellQuote(deleteBufferMarker)}; exit 0 ;;\n kill-session) exit 0 ;;\n *) echo "unexpected tmux command: $*" >&2; exit 64 ;;\nesac\n`,
608
- "utf8",
609
- );
610
- chmodSync(fakeTmux, 0o755);
611
- const originalPath = process.env.PATH;
612
- try {
613
- process.env.PATH = hostilePath;
614
- let pasteFailed = false;
615
- try {
616
- runVisualSmoke({
617
- ...baseOptions,
618
- prompt: "buffer cleanup prompt",
619
- startupMs: 1,
620
- waitMs: 1,
621
- width: 80,
622
- height: 24,
623
- historyLines: 100,
624
- });
625
- } catch (error) {
626
- pasteFailed = /paste-buffer failed/.test(error instanceof Error ? error.message : String(error));
627
- }
628
- assertSelfTest(pasteFailed, "fake tmux paste failure should exercise prompt-buffer cleanup path");
629
- assertSelfTest(existsSync(deleteBufferMarker), "prompt tmux buffer should be deleted when paste/send fails");
630
- } finally {
631
- if (originalPath === undefined) delete process.env.PATH;
632
- else process.env.PATH = originalPath;
633
- }
634
- console.log("[visual-smoke] self-test PASS");
635
- } finally {
636
- rmSync(tempDir, { recursive: true, force: true });
637
- }
638
- }
639
475
 
640
476
  const options = parseArgs(process.argv.slice(2));
477
+ let artifacts;
641
478
  try {
642
479
  if (options.selfTest) {
643
- runSelfTest();
480
+ runVisualSmokeSelfTest({
481
+ ROOT,
482
+ DEFAULT_MODE,
483
+ DEFAULT_MODEL,
484
+ DEFAULT_SETTING_SOURCES,
485
+ DEBUG_ENV_NAMES,
486
+ shellQuote,
487
+ parseArgs,
488
+ snapshotJsonlMtimes,
489
+ findLatestJsonl,
490
+ sealedNodePath,
491
+ resolveCommand,
492
+ requireNode,
493
+ requireCommand,
494
+ buildLaunchPlan,
495
+ run,
496
+ runVisualSmoke,
497
+ });
644
498
  process.exit(0);
645
499
  }
646
- const artifacts = runVisualSmoke(options);
500
+ artifacts = runVisualSmoke(options);
501
+ writeVisualManifest(artifacts.manifestPath, options, artifacts);
647
502
  checkLeftovers(options.leftoverPatterns);
648
503
  if (options.screenshot) {
649
504
  await writeTerminalScreenshot(artifacts.htmlPath, artifacts.pngPath, options.width, options.height);
505
+ artifacts.pngWritten = true;
506
+ writeVisualManifest(artifacts.manifestPath, options, artifacts);
650
507
  }
651
508
  console.log("[visual-smoke] artifacts:");
652
509
  console.log(` ansi: ${artifacts.ansiPath}`);
@@ -655,6 +512,15 @@ try {
655
512
  if (options.screenshot) console.log(` png: ${artifacts.pngPath}`);
656
513
  console.log(` jsonl.path: ${artifacts.jsonlPathFile}`);
657
514
  console.log(` jsonl: ${artifacts.jsonlPath}`);
515
+ console.log(` manifest: ${artifacts.manifestPath}`);
658
516
  } catch (error) {
659
- fail(error instanceof Error ? error.message : String(error));
517
+ const message = error instanceof Error ? error.message : String(error);
518
+ if (artifacts?.manifestPath) {
519
+ try {
520
+ writeVisualManifest(artifacts.manifestPath, options, artifacts, { message, writtenAt: new Date().toISOString() });
521
+ } catch {
522
+ // Preserve the original failure.
523
+ }
524
+ }
525
+ fail(message);
660
526
  }
package/src/context.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
3
3
  import { convertToLlm } from "@earendil-works/pi-coding-agent";
4
- import type { SDKImage } from "@cursor/sdk";
4
+ import type { AgentModeOption, SDKImage } from "@cursor/sdk";
5
5
  import { getCursorReplayPromptLabel } from "./cursor-tool-presentation-registry.js";
6
6
 
7
7
  export interface CursorPrompt {
@@ -13,6 +13,7 @@ export interface CursorPromptOptions {
13
13
  maxInputTokens?: number;
14
14
  charsPerToken?: number;
15
15
  imageTokenEstimate?: number;
16
+ agentMode?: AgentModeOption;
16
17
  /** Compact callable-surface summary; included on bootstrap prompts when set. */
17
18
  toolManifest?: string;
18
19
  }
@@ -21,35 +22,49 @@ export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
21
22
  export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
22
23
  const SECTION_SEPARATOR = "\n\n";
23
24
 
24
- export function getCursorToolTailGuardText(): string {
25
+ export function getCursorPlanModeToolGuidanceText(agentMode: AgentModeOption | undefined): string | undefined {
26
+ if (agentMode !== "plan") return undefined;
27
+ return [
28
+ "Cursor SDK mode is plan for this run. In pi-cursor-sdk, plan mode may still use available Cursor SDK/MCP tools for inspection when needed.",
29
+ "Safe/read-only shell commands that inspect or print information are allowed when Cursor chooses to call Shell; do not say Shell is blocked by plan mode and then call it anyway.",
30
+ "Exposed pi__* bridge tools are also callable in plan mode when the user asks for them or they are needed to answer.",
31
+ ].join("\n");
32
+ }
33
+
34
+ export function getCursorToolTailGuardText(
35
+ options: Pick<CursorPromptOptions, "agentMode"> & { includePlanModeGuidance?: boolean } = {},
36
+ ): string {
25
37
  return [
26
38
  "Shell: use an explicit `cd` to the repo path when running project commands; session cwd may not match paths in tool args.",
39
+ options.includePlanModeGuidance === false ? undefined : getCursorPlanModeToolGuidanceText(options.agentMode),
40
+ "Exact-output requests: if the latest user asks to reply exactly, output exactly that text and do not add preambles, diagnostics, or repo checks unless explicitly requested.",
27
41
  "Tool boundary reminder: If a tool is needed, call an available Cursor SDK/MCP tool. Never print a tool card (for example Tool call/Shell/command) as assistant text.",
28
- ].join("\n");
42
+ ].filter((line): line is string => line !== undefined).join("\n");
29
43
  }
30
44
 
31
- function getCursorToolBoundaryText(options: { hasToolManifest?: boolean } = {}): string {
45
+ function getCursorToolBoundaryText(options: Pick<CursorPromptOptions, "agentMode"> & { hasToolManifest?: boolean } = {}): string {
32
46
  const lines = [
33
47
  "Cursor SDK tool boundary:",
34
48
  "Call only tools exposed by Cursor SDK in this run. Pi tool names, replay labels, and transcript names are context only—not callable.",
35
49
  "Bridged pi tools: call pi__* MCP names when exposed, not the pi card name in history. Replay activity is display-only.",
36
50
  "Do not claim pi-side or WebSearch/WebFetch tools unless Cursor executes an equivalent tool.",
37
51
  "Use pi__cursor_ask_question for material choices if exposed.",
52
+ getCursorPlanModeToolGuidanceText(options.agentMode),
38
53
  "Images: only the latest user message's images are sent as bytes; ask to reattach or describe prior images.",
39
- ];
54
+ ].filter((line): line is string => line !== undefined);
40
55
  if (options.hasToolManifest) {
41
56
  lines.push("See callable tool surfaces block below.");
42
57
  }
43
58
  return lines.join("\n");
44
59
  }
45
60
 
46
- function getCursorBootstrapTailSections(): string[] {
61
+ function getCursorBootstrapTailSections(options: Pick<CursorPromptOptions, "agentMode"> = {}): string[] {
47
62
  return [
48
63
  [
49
64
  "Answer the latest user request above using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from the system prompt as if they were available.",
50
65
  "If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
51
66
  ].join("\n"),
52
- getCursorToolTailGuardText(),
67
+ getCursorToolTailGuardText({ ...options, includePlanModeGuidance: false }),
53
68
  ];
54
69
  }
55
70
 
@@ -366,7 +381,7 @@ export function buildCursorIncrementalPrompt(context: Context, options: CursorPr
366
381
  const parts = applyPromptBudget(
367
382
  sectionsBeforeMessages,
368
383
  latestUserMessageSections,
369
- [getCursorToolTailGuardText()],
384
+ [getCursorToolTailGuardText(options)],
370
385
  latestUserMessageIndex,
371
386
  budgetOptions,
372
387
  );
@@ -374,7 +389,7 @@ export function buildCursorIncrementalPrompt(context: Context, options: CursorPr
374
389
  }
375
390
 
376
391
  export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
377
- const sectionsBeforeMessages: string[] = [getCursorToolBoundaryText({ hasToolManifest: Boolean(options.toolManifest) })];
392
+ const sectionsBeforeMessages: string[] = [getCursorToolBoundaryText({ agentMode: options.agentMode, hasToolManifest: Boolean(options.toolManifest) })];
378
393
  if (options.toolManifest) {
379
394
  sectionsBeforeMessages.push(options.toolManifest);
380
395
  }
@@ -390,7 +405,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
390
405
  return text ? { index, text } : undefined;
391
406
  })
392
407
  .filter((section): section is { index: number; text: string } => section !== undefined);
393
- const sectionsAfterMessages = getCursorBootstrapTailSections();
408
+ const sectionsAfterMessages = getCursorBootstrapTailSections(options);
394
409
  const images = extractLatestImages(messages);
395
410
  const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
396
411
  const budgetOptions =
@@ -0,0 +1,7 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type CursorActiveToolApi = Pick<ExtensionAPI, "getActiveTools">;
4
+
5
+ export function arePiToolsDisabled(pi: CursorActiveToolApi): boolean {
6
+ return pi.getActiveTools().length === 0;
7
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { arePiToolsDisabled } from "./cursor-active-tools.js";
2
3
  import {
3
4
  CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES,
4
5
  isNativeCursorToolName,
@@ -13,6 +14,7 @@ import {
13
14
  NATIVE_CURSOR_TOOL_DISPLAY_ENV,
14
15
  readBooleanEnv,
15
16
  registeredNativeToolNames,
17
+ setCursorNativeToolDisplayRuntimeRequested,
16
18
  skippedNativeToolNames,
17
19
  } from "./cursor-native-tool-display-state.js";
18
20
  import { isCursorReplayToolName } from "./cursor-tool-presentation-registry.js";
@@ -70,37 +72,41 @@ function hasAttemptedNativeCursorToolRegistration(): boolean {
70
72
  return registeredNativeToolNames.size > 0 || skippedNativeToolNames.size > 0;
71
73
  }
72
74
 
75
+ function removeRegisteredNonCoreNativeCursorTools(pi: CursorNativeToolActivationApi): void {
76
+ if (registeredNativeToolNames.size === 0) return;
77
+ const activeToolNames = new Set(pi.getActiveTools());
78
+ let changed = false;
79
+ for (const toolName of registeredNativeToolNames) {
80
+ if (isCursorCorePiReplayToolName(toolName)) continue;
81
+ if (!activeToolNames.delete(toolName)) continue;
82
+ changed = true;
83
+ }
84
+ if (changed) pi.setActiveTools([...activeToolNames]);
85
+ }
86
+
73
87
  export function syncRegisteredNativeCursorToolsForModel(
74
88
  pi: CursorNativeToolActivationApi,
75
89
  model: ExtensionContext["model"],
76
90
  ): void {
77
91
  if (registeredNativeToolNames.size === 0) return;
92
+ if (!isCursorModel(model)) {
93
+ removeRegisteredNonCoreNativeCursorTools(pi);
94
+ return;
95
+ }
96
+ if (arePiToolsDisabled(pi)) return;
78
97
  const activeToolNames = new Set(pi.getActiveTools());
79
98
  let changed = false;
80
- if (isCursorModel(model)) {
81
- for (const toolName of registeredNativeToolNames) {
82
- if (isCursorReplayToolName(toolName) && !CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES.some((activeReplayToolName) => activeReplayToolName === toolName)) continue;
83
- if (activeToolNames.has(toolName)) continue;
84
- activeToolNames.add(toolName);
85
- changed = true;
86
- }
87
- } else {
88
- for (const toolName of registeredNativeToolNames) {
89
- if (isCursorCorePiReplayToolName(toolName)) continue;
90
- if (!activeToolNames.delete(toolName)) continue;
91
- changed = true;
92
- }
99
+ for (const toolName of registeredNativeToolNames) {
100
+ if (isCursorReplayToolName(toolName) && !CURSOR_MODEL_ACTIVE_REPLAY_TOOL_NAMES.some((activeReplayToolName) => activeReplayToolName === toolName)) continue;
101
+ if (activeToolNames.has(toolName)) continue;
102
+ activeToolNames.add(toolName);
103
+ changed = true;
93
104
  }
94
105
  if (changed) pi.setActiveTools([...activeToolNames]);
95
106
  }
96
107
 
97
108
  async function ensureNativeCursorToolsRegisteredForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): Promise<void> {
98
- if (!isCursorNativeToolRegistrationRequested()) {
99
- registeredNativeToolNames.clear();
100
- skippedNativeToolNames.clear();
101
- return;
102
- }
103
- if (ctx.mode !== "tui" || !isCursorModel(ctx.model) || hasAttemptedNativeCursorToolRegistration()) return;
109
+ if (!isCursorModel(ctx.model) || hasAttemptedNativeCursorToolRegistration()) return;
104
110
 
105
111
  const nonCoreToolNames = NATIVE_CURSOR_TOOL_NAMES.filter((toolName) => !isCursorCorePiReplayToolName(toolName));
106
112
  const skippedToolNames = [
@@ -111,9 +117,13 @@ async function ensureNativeCursorToolsRegisteredForModel(pi: CursorNativeToolReg
111
117
  }
112
118
 
113
119
  async function ensureThenSyncNativeCursorToolsForModel(pi: CursorNativeToolRegistryApi, ctx: NativeRegistrationContext): Promise<void> {
114
- if (isCursorModel(ctx.model) && !hasAttemptedNativeCursorToolRegistration()) {
115
- await ensureNativeCursorToolsRegisteredForModel(pi, ctx);
120
+ const requested = isCursorNativeToolRegistrationRequested(ctx.mode);
121
+ setCursorNativeToolDisplayRuntimeRequested(requested);
122
+ if (!requested) {
123
+ removeRegisteredNonCoreNativeCursorTools(pi);
124
+ return;
116
125
  }
126
+ await ensureNativeCursorToolsRegisteredForModel(pi, ctx);
117
127
  syncRegisteredNativeCursorToolsForModel(pi, ctx.model);
118
128
  }
119
129
 
@@ -13,18 +13,25 @@ export const registeredNativeToolNames = new Set<string>();
13
13
  export const skippedNativeToolNames = new Set<string>();
14
14
  export const nativeToolResults = new Map<string, CursorNativeToolDisplayItem>();
15
15
 
16
+ let nativeToolDisplayRuntimeRequested = false;
17
+
16
18
  export function readBooleanEnv(name: string, env: Record<string, string | undefined> = process.env): boolean | undefined {
17
19
  return parseOptionalEnvBoolean(env[name]);
18
20
  }
19
21
 
20
- export function isCursorNativeToolDisplayRequested(): boolean {
22
+ export function isCursorNativeToolDisplayRequested(mode?: string): boolean {
21
23
  const override = readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV);
22
24
  if (override !== undefined) return override;
25
+ if (mode) return mode === "tui" || mode === "json" || mode === "rpc";
23
26
  return process.stdout.isTTY === true;
24
27
  }
25
28
 
26
- export function isCursorNativeToolRegistrationRequested(): boolean {
27
- return readBooleanEnv(NATIVE_CURSOR_TOOL_REGISTRATION_ENV) !== false && isCursorNativeToolDisplayRequested();
29
+ export function isCursorNativeToolRegistrationRequested(mode?: string): boolean {
30
+ return mode !== "print" && readBooleanEnv(NATIVE_CURSOR_TOOL_REGISTRATION_ENV) !== false && isCursorNativeToolDisplayRequested(mode);
31
+ }
32
+
33
+ export function setCursorNativeToolDisplayRuntimeRequested(requested: boolean): void {
34
+ nativeToolDisplayRuntimeRequested = requested;
28
35
  }
29
36
 
30
37
  export function isCursorNativeToolDisplayEnabled(): boolean {
@@ -32,7 +39,7 @@ export function isCursorNativeToolDisplayEnabled(): boolean {
32
39
  }
33
40
 
34
41
  export function isCursorNativeToolDisplayRuntimeEnabled(): boolean {
35
- return isCursorNativeToolDisplayRequested() && registeredNativeToolNames.size > 0;
42
+ return nativeToolDisplayRuntimeRequested && readBooleanEnv(NATIVE_CURSOR_TOOL_DISPLAY_ENV) !== false && registeredNativeToolNames.size > 0;
36
43
  }
37
44
 
38
45
  export function canRenderCursorToolNatively(toolName: string): boolean {
@@ -70,9 +77,11 @@ export function isCursorFileMutationToolName(toolName: string): toolName is "edi
70
77
  export const __testUtils = {
71
78
  nativeToolResultCount: () => nativeToolResults.size,
72
79
  registerNativeToolNameForTests(toolName: string): void {
80
+ nativeToolDisplayRuntimeRequested = true;
73
81
  registeredNativeToolNames.add(toolName);
74
82
  },
75
83
  reset(): void {
84
+ nativeToolDisplayRuntimeRequested = false;
76
85
  registeredNativeToolNames.clear();
77
86
  skippedNativeToolNames.clear();
78
87
  nativeToolResults.clear();
@@ -150,7 +150,8 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
150
150
  this.debugRecorder = recorder;
151
151
  }
152
152
 
153
- resolveToolResults(toolResults: readonly ToolResultMessage[]): void {
153
+ async resolveToolResults(toolResults: readonly ToolResultMessage[]): Promise<void> {
154
+ let resolvedCount = 0;
154
155
  for (const toolResult of toolResults) {
155
156
  const pending = this.pendingByPiToolCallId.get(toolResult.toolCallId);
156
157
  if (!pending || pending.settled) continue;
@@ -158,11 +159,13 @@ export class CursorPiToolBridgeRunImpl implements CursorPiToolBridgeRun {
158
159
  content: convertPiContentToMcpContent(toolResult.content),
159
160
  isError: toolResult.isError || undefined,
160
161
  });
162
+ resolvedCount += 1;
161
163
  }
164
+ if (resolvedCount > 0) await waitForProtocolFlush();
162
165
  }
163
166
 
164
- resolveToolResultsFromContext(context: Context): void {
165
- this.resolveToolResults(context.messages.map(asToolResultMessage).filter((message): message is ToolResultMessage => message !== undefined));
167
+ async resolveToolResultsFromContext(context: Context): Promise<void> {
168
+ await this.resolveToolResults(context.messages.map(asToolResultMessage).filter((message): message is ToolResultMessage => message !== undefined));
166
169
  }
167
170
 
168
171
  hasPendingPiToolCallId(piToolCallId: string): boolean {
@@ -61,8 +61,8 @@ export interface CursorPiToolBridgeRun {
61
61
  mcpServers?: Record<string, McpServerConfig>;
62
62
  snapshot: CursorPiToolBridgeSnapshot;
63
63
  takeQueuedToolRequests(): CursorPiBridgeToolRequest[];
64
- resolveToolResults(toolResults: readonly ToolResultMessage[]): void;
65
- resolveToolResultsFromContext(context: Context): void;
64
+ resolveToolResults(toolResults: readonly ToolResultMessage[]): Promise<void>;
65
+ resolveToolResultsFromContext(context: Context): Promise<void>;
66
66
  hasPendingPiToolCallId(piToolCallId: string): boolean;
67
67
  isBridgeMcpToolCall(toolCall: unknown): boolean;
68
68
  setOnToolRequest(handler?: (request: CursorPiBridgeToolRequest) => void): void;
@@ -15,7 +15,7 @@ const NETWORK_CURSOR_SDK_ERROR_MESSAGE =
15
15
  // Keep this phrase aligned with pi's agent-level retry classifier (`provider.?returned.?error`).
16
16
  const RETRYABLE_CURSOR_RUN_FAILURE_PREFIX = "Provider returned error: Cursor SDK run failed";
17
17
 
18
- export type CursorSdkRunFailureSource = Pick<RunResult, "id" | "status" | "durationMs" | "model" | "result">;
18
+ export type CursorSdkRunFailureSource = Pick<RunResult, "id" | "requestId" | "status" | "durationMs" | "model" | "result">;
19
19
 
20
20
  function isGenericErrorMessage(message: string): boolean {
21
21
  const normalized = message.trim().toLowerCase();
@@ -159,6 +159,7 @@ export function formatCursorSdkRunFailureDetail(result: CursorSdkRunFailureSourc
159
159
  const parts = [RETRYABLE_CURSOR_RUN_FAILURE_PREFIX];
160
160
  if (result.model?.id) parts.push(`model ${result.model.id}`);
161
161
  parts.push(`run ${shortRunId(result.id)}`);
162
+ if (result.requestId) parts.push(`request ${shortRunId(result.requestId)}`);
162
163
  if (typeof result.durationMs === "number") parts.push(`${result.durationMs}ms`);
163
164
  return parts.join(" · ");
164
165
  }
@@ -414,7 +414,7 @@ export async function drainExistingCursorLiveRunBeforeSend(
414
414
  const outcome = await cursorLiveRuns.withRunLease(run, signal, async () => {
415
415
  if (run.disposed) return "continue_send" as const;
416
416
  const consumed = cursorLiveRuns.consumeToolResults(run, context, getCursorNativeReplayIdFromToolCallId);
417
- run.bridgeRun?.resolveToolResults(consumed.toolResults);
417
+ await run.bridgeRun?.resolveToolResults(consumed.toolResults);
418
418
  const shouldChainUserInput = run.chainUserInputAfterCompletion || hasTrailingUserMessagesAfterToolResults(context);
419
419
  if (shouldChainUserInput) run.chainUserInputAfterCompletion = true;
420
420
  while (!cursorLiveRuns.isReady(run)) {
@@ -89,7 +89,7 @@ export async function prepareCursorProviderTurn(
89
89
  throwIfAborted();
90
90
 
91
91
  const buildPromptOptions = (plan: ReturnType<typeof planCursorSessionSend>) => {
92
- const promptOptions = getCursorPromptOptions(model);
92
+ const promptOptions = { ...getCursorPromptOptions(model), agentMode };
93
93
  if (plan.mode !== "bootstrap" || !resolveCursorToolManifestEnabled()) {
94
94
  return promptOptions;
95
95
  }
@@ -78,12 +78,14 @@ export async function sendCursorProviderTurn(sendParams: SendCursorProviderTurnP
78
78
  sdkRun = run;
79
79
  sdkEventDebug?.recordRunMeta({
80
80
  runId: run.id,
81
+ requestId: run.requestId,
81
82
  agentId: run.agentId,
82
83
  status: run.status,
83
84
  });
84
85
  sdkEventDebug?.attachRunStream(run);
85
86
  sdkEventDebug?.recordProviderEvent("agent_send_returned", {
86
87
  runId: run.id,
88
+ requestId: run.requestId,
87
89
  agentId: run.agentId,
88
90
  status: run.status,
89
91
  });
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
+ import { arePiToolsDisabled } from "./cursor-active-tools.js";
4
5
  import { isCursorModel } from "./cursor-model.js";
5
6
  import { registerCursorModelLifecycle, type CursorModelLifecycleExtensionApi } from "./cursor-model-lifecycle.js";
6
7
  import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge-env.js";
@@ -175,7 +176,7 @@ async function askOneQuestion(question: CursorQuestion, ctx: { ui: ExtensionCont
175
176
 
176
177
  function syncCursorQuestionToolForModel(pi: Pick<ExtensionAPI, "getActiveTools" | "setActiveTools">, model: ExtensionContext["model"]): void {
177
178
  const activeToolNames = new Set(pi.getActiveTools());
178
- const shouldBeActive = isCursorModel(model) && resolveCursorPiToolBridgeEnabled();
179
+ const shouldBeActive = !arePiToolsDisabled(pi) && isCursorModel(model) && resolveCursorPiToolBridgeEnabled();
179
180
  const alreadyActive = activeToolNames.has(CURSOR_ASK_QUESTION_TOOL_NAME);
180
181
  if (shouldBeActive === alreadyActive) return;
181
182
  if (shouldBeActive) {