pi-cursor-sdk 0.1.39 → 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 (47) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +13 -12
  3. package/docs/cursor-dogfood-checklist.md +7 -1
  4. package/docs/cursor-live-smoke-checklist.md +13 -13
  5. package/docs/cursor-model-ux-spec.md +8 -8
  6. package/docs/cursor-native-tool-replay.md +4 -4
  7. package/docs/cursor-native-tool-visual-audit.md +5 -5
  8. package/docs/cursor-testing-lessons.md +5 -5
  9. package/docs/cursor-tool-surfaces.md +4 -0
  10. package/docs/platform-smoke.md +22 -7
  11. package/package.json +8 -5
  12. package/platform-smoke.config.mjs +5 -0
  13. package/scripts/debug-provider-events.mjs +1 -0
  14. package/scripts/isolated-cursor-smoke.sh +7 -7
  15. package/scripts/lib/cursor-visual-manifest.d.mts +3 -0
  16. package/scripts/lib/cursor-visual-manifest.mjs +82 -0
  17. package/scripts/platform-smoke/artifacts.mjs +225 -2
  18. package/scripts/platform-smoke/card-detect.mjs +1 -1
  19. package/scripts/platform-smoke/doctor.mjs +53 -8
  20. package/scripts/platform-smoke/live-suite-runner.mjs +7 -6
  21. package/scripts/platform-smoke/platform-build-windows.ps1 +2 -2
  22. package/scripts/platform-smoke/scenarios.mjs +1 -1
  23. package/scripts/platform-smoke/targets.mjs +2 -2
  24. package/scripts/platform-smoke.mjs +75 -6
  25. package/scripts/steering-rpc-smoke.mjs +1 -1
  26. package/scripts/tmux-live-smoke.sh +1 -1
  27. package/scripts/visual-tui-smoke-self-test.mjs +229 -0
  28. package/scripts/visual-tui-smoke.mjs +46 -179
  29. package/shared/cursor-setting-sources.d.mts +1 -0
  30. package/shared/cursor-setting-sources.mjs +2 -1
  31. package/src/context.ts +25 -10
  32. package/src/cursor-active-tools.ts +7 -0
  33. package/src/cursor-native-tool-display-registration.ts +31 -21
  34. package/src/cursor-native-tool-display-state.ts +13 -4
  35. package/src/cursor-pi-tool-bridge-run.ts +6 -3
  36. package/src/cursor-pi-tool-bridge-types.ts +2 -2
  37. package/src/cursor-provider-errors.ts +2 -1
  38. package/src/cursor-provider-live-run-drain.ts +1 -1
  39. package/src/cursor-provider-turn-prepare.ts +1 -1
  40. package/src/cursor-provider-turn-send.ts +2 -0
  41. package/src/cursor-question-tool.ts +2 -1
  42. package/src/cursor-sdk-event-debug.ts +3 -1
  43. package/src/cursor-setting-sources.ts +2 -0
  44. package/src/cursor-skill-tool.ts +2 -1
  45. package/src/cursor-state.ts +2 -1
  46. package/src/cursor-tool-manifest.ts +2 -1
  47. 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.
@@ -363,6 +365,7 @@ function buildLaunchPlan(options, commands, shell) {
363
365
  ...envAssignments.map(([name, value]) => `${name}=${shellQuote(value)}`),
364
366
  "exec",
365
367
  shellQuote(commands.pi),
368
+ "--approve",
366
369
  "-e", shellQuote(options.ext),
367
370
  "--cursor-no-fast",
368
371
  "--cursor-mode", shellQuote(options.mode),
@@ -447,205 +450,60 @@ function runVisualSmoke(options) {
447
450
  const htmlPath = `${base}.html`;
448
451
  const pngPath = `${base}.png`;
449
452
  const jsonlPathFile = `${base}.jsonl.path`;
453
+ const manifestPath = `${base}.manifest.json`;
450
454
 
451
455
  writeUtf8(ansiPath, ansi);
452
456
  writeUtf8(textPath, plain);
453
457
  writeUtf8(htmlPath, buildTerminalHtml({ ansi, plain, options }));
454
458
 
459
+ const partialArtifacts = { ansiPath, textPath, htmlPath, pngPath, jsonlPathFile, manifestPath };
455
460
  const jsonlPath = findLatestJsonl(options.sessionDir, { sinceMs: runStartedAtMs, previousMtimes: jsonlMtimesBeforeRun });
456
- 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
+ }
457
466
  writeUtf8(jsonlPathFile, `${jsonlPath}\n`);
458
467
 
459
- return { ansiPath, textPath, htmlPath, pngPath, jsonlPathFile, jsonlPath };
468
+ return { ...partialArtifacts, jsonlPath };
460
469
  } finally {
461
470
  if (bufferLoaded) run(commands.tmux, ["delete-buffer", "-b", bufferName]);
462
471
  if (sessionStarted) run(commands.tmux, ["kill-session", "-t", sessionName]);
463
472
  }
464
473
  }
465
474
 
466
- function assertSelfTest(condition, message) {
467
- if (!condition) throw new Error(`self-test failed: ${message}`);
468
- }
469
-
470
- function envMap(assignments) {
471
- return new Map(assignments.map(([name, value]) => [name, value]));
472
- }
473
-
474
- function parseEnvCapture(path) {
475
- return new Map(
476
- readFileSync(path, "utf8")
477
- .split("\n")
478
- .filter(Boolean)
479
- .map((line) => {
480
- const index = line.indexOf("=");
481
- return index === -1 ? [line, ""] : [line.slice(0, index), line.slice(index + 1)];
482
- }),
483
- );
484
- }
485
-
486
- function runSelfTest() {
487
- const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-visual-self-test-"));
488
- try {
489
- const binDir = join(tempDir, "bin");
490
- mkdirSync(binDir, { recursive: true });
491
- const fakePi = join(binDir, "pi");
492
- const fakeNode = join(binDir, "node");
493
- const fakeNodeMarker = join(tempDir, "fake-node-used");
494
- const envCapture = join(tempDir, "fake-pi.env");
495
- writeFileSync(
496
- fakePi,
497
- `#!/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`,
498
- "utf8",
499
- );
500
- writeFileSync(fakeNode, `#!/bin/sh\necho fake-node-used > ${shellQuote(fakeNodeMarker)}\nexit 99\n`, "utf8");
501
- chmodSync(fakePi, 0o755);
502
- chmodSync(fakeNode, 0o755);
503
-
504
- const promptFile = join(tempDir, "prompt.txt");
505
- writeFileSync(promptFile, "file prompt", "utf8");
506
- assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt-file", promptFile, "--prompt", "inline prompt"]).prompt === "inline prompt", "--prompt should override an earlier --prompt-file");
507
- assertSelfTest(parseArgs(["--label", "prompt-dash", "--prompt", "--starts-with-dash"]).prompt === "--starts-with-dash", "--prompt should accept dash-prefixed free-form text");
508
- assertSelfTest(parseArgs(["--label", "prompt-order", "--prompt", "inline prompt", "--prompt-file", promptFile]).prompt === "file prompt", "--prompt-file should override an earlier --prompt");
509
-
510
- const jsonlDir = join(tempDir, "jsonl-filter");
511
- mkdirSync(jsonlDir, { recursive: true });
512
- const staleJsonl = join(jsonlDir, "stale.jsonl");
513
- const freshJsonl = join(jsonlDir, "fresh.jsonl");
514
- writeFileSync(staleJsonl, "{}\n", "utf8");
515
- utimesSync(staleJsonl, new Date(1_000), new Date(1_000));
516
- const previousJsonlMtimes = snapshotJsonlMtimes(jsonlDir);
517
- writeFileSync(freshJsonl, "{}\n", "utf8");
518
- utimesSync(freshJsonl, new Date(3_000), new Date(3_000));
519
- assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 2_000, previousMtimes: previousJsonlMtimes }) === freshJsonl, "JSONL discovery should ignore unchanged stale files before run start");
520
- assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 4_000, previousMtimes: snapshotJsonlMtimes(jsonlDir) }) === undefined, "JSONL discovery should not return stale evidence when current run has no changed JSONL");
521
-
522
- assertSelfTest(!sealedNodePath(process.execPath, "").includes(delimiter), "empty inherited PATH must not leave an empty PATH segment");
523
- const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
524
- const sealedHostilePath = sealedNodePath(process.execPath, hostilePath);
525
- assertSelfTest(resolveCommand("pi", hostilePath) === fakePi, "direct PATH resolver did not prefer fake PATH head");
526
- assertSelfTest(requireNode() === process.execPath, "node resolver must use process.execPath");
527
- assertSelfTest(requireCommand("pi", { envPath: hostilePath, env: { ...process.env, PATH: sealedHostilePath } }) === fakePi, "pi prereq should use sealed PATH when executing the shim");
528
- assertSelfTest(!existsSync(fakeNodeMarker), "pi prereq should not use hostile fake node");
529
-
530
- const baseOptions = {
531
- ext: ROOT,
532
- cwd: ROOT,
533
- mode: DEFAULT_MODE,
534
- model: DEFAULT_MODEL,
535
- outDir: tempDir,
536
- safeLabel: "self-test",
537
- sessionDir: join(tempDir, "session"),
538
- sessionId: "self-test",
539
- settingSources: DEFAULT_SETTING_SOURCES,
540
- bridge: false,
541
- exposeBuiltinTools: false,
542
- eventDebug: false,
543
- };
544
- const plan = buildLaunchPlan(baseOptions, { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath }, "/bin/sh");
545
- const defaults = envMap(plan.envAssignments);
546
- assertSelfTest(defaults.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "native display must be forced on");
547
- assertSelfTest(defaults.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "native tool registration must be forced on");
548
- assertSelfTest(defaults.get("PI_CURSOR_SETTING_SOURCES") === "none", "setting sources must default to none");
549
- assertSelfTest(defaults.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "bridge must default off");
550
- assertSelfTest(defaults.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "built-in exposure must default off");
551
- for (const name of DEBUG_ENV_NAMES) {
552
- assertSelfTest(plan.clearEnvNames.includes(name), `${name} must be cleared by default`);
553
- }
554
- assertSelfTest(plan.script.includes(shellQuote(fakePi)), "launch script must use resolved pi path");
555
- assertSelfTest(!plan.script.includes(" exec pi "), "launch script must not use bare pi");
556
- const hostileEnv = {
557
- ...process.env,
558
- ...Object.fromEntries(DEBUG_ENV_NAMES.map((name) => [name, join(tempDir, name)])),
559
- PATH: hostilePath,
560
- PI_CURSOR_REGISTER_NATIVE_TOOLS: "0",
561
- PI_CURSOR_SETTING_SOURCES: "all",
562
- PI_CURSOR_PI_TOOL_BRIDGE: "1",
563
- PI_CURSOR_EXPOSE_BUILTIN_TOOLS: "1",
564
- };
565
- const probe = run("/bin/sh", ["-c", plan.script], { env: hostileEnv });
566
- assertSelfTest(probe.status === 0, `fake-pi env capture exited ${probe.status}: ${probe.stderr?.toString() ?? ""}`);
567
- const capturedEnv = parseEnvCapture(envCapture);
568
- assertSelfTest(!existsSync(fakeNodeMarker), "launch PATH should force the resolved node before hostile fake node");
569
- assertSelfTest((capturedEnv.get("PATH") ?? "").split(delimiter)[0] === dirname(process.execPath), "captured PATH should start with resolved node directory");
570
- assertSelfTest(capturedEnv.get("PI_CURSOR_NATIVE_TOOL_DISPLAY") === "1", "captured env should force native display on");
571
- assertSelfTest(capturedEnv.get("PI_CURSOR_REGISTER_NATIVE_TOOLS") === "1", "captured env should force native registration on");
572
- assertSelfTest(capturedEnv.get("PI_CURSOR_SETTING_SOURCES") === "none", "captured env should force settings off");
573
- assertSelfTest(capturedEnv.get("PI_CURSOR_PI_TOOL_BRIDGE") === "0", "captured env should force bridge off");
574
- assertSelfTest(capturedEnv.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "0", "captured env should force built-in exposure off");
575
- for (const name of DEBUG_ENV_NAMES) {
576
- assertSelfTest(!capturedEnv.has(name), `${name} should be absent from captured env by default`);
577
- }
578
-
579
- const optInPlan = buildLaunchPlan(
580
- { ...baseOptions, settingSources: "all", bridge: true, exposeBuiltinTools: true, eventDebug: true },
581
- { pi: fakePi, node: process.execPath, sealedPath: sealedHostilePath },
582
- "/bin/sh",
583
- );
584
- const optIns = envMap(optInPlan.envAssignments);
585
- assertSelfTest(optIns.get("PI_CURSOR_SETTING_SOURCES") === "all", "setting source opt-in must be reflected");
586
- assertSelfTest(optIns.get("PI_CURSOR_PI_TOOL_BRIDGE") === "1", "bridge opt-in must be reflected");
587
- assertSelfTest(optIns.get("PI_CURSOR_EXPOSE_BUILTIN_TOOLS") === "1", "built-in exposure opt-in must be reflected");
588
- assertSelfTest(optIns.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug opt-in must be reflected");
589
- 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");
590
- for (const name of DEBUG_ENV_NAMES) {
591
- assertSelfTest(optInPlan.clearEnvNames.includes(name), `${name} must be cleared even when event debug is explicit`);
592
- }
593
- const eventDebugProbe = run("/bin/sh", ["-c", optInPlan.script], { env: hostileEnv });
594
- assertSelfTest(eventDebugProbe.status === 0, `fake-pi event-debug env capture exited ${eventDebugProbe.status}: ${eventDebugProbe.stderr?.toString() ?? ""}`);
595
- const capturedEventDebugEnv = parseEnvCapture(envCapture);
596
- assertSelfTest(capturedEventDebugEnv.get("PI_CURSOR_SDK_EVENT_DEBUG") === "1", "event debug should be explicitly enabled");
597
- 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");
598
- assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_RUN_DIR"), "stale event debug run dir should be cleared");
599
- assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_SESSION_DIR"), "stale event debug session dir should be cleared");
600
- assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_STDERR"), "stale event debug stderr flag should be cleared");
601
-
602
- const fakeTmux = join(binDir, "tmux");
603
- const deleteBufferMarker = join(tempDir, "delete-buffer-called");
604
- writeFileSync(
605
- fakeTmux,
606
- `#!/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`,
607
- "utf8",
608
- );
609
- chmodSync(fakeTmux, 0o755);
610
- const originalPath = process.env.PATH;
611
- try {
612
- process.env.PATH = hostilePath;
613
- let pasteFailed = false;
614
- try {
615
- runVisualSmoke({
616
- ...baseOptions,
617
- prompt: "buffer cleanup prompt",
618
- startupMs: 1,
619
- waitMs: 1,
620
- width: 80,
621
- height: 24,
622
- historyLines: 100,
623
- });
624
- } catch (error) {
625
- pasteFailed = /paste-buffer failed/.test(error instanceof Error ? error.message : String(error));
626
- }
627
- assertSelfTest(pasteFailed, "fake tmux paste failure should exercise prompt-buffer cleanup path");
628
- assertSelfTest(existsSync(deleteBufferMarker), "prompt tmux buffer should be deleted when paste/send fails");
629
- } finally {
630
- if (originalPath === undefined) delete process.env.PATH;
631
- else process.env.PATH = originalPath;
632
- }
633
- console.log("[visual-smoke] self-test PASS");
634
- } finally {
635
- rmSync(tempDir, { recursive: true, force: true });
636
- }
637
- }
638
475
 
639
476
  const options = parseArgs(process.argv.slice(2));
477
+ let artifacts;
640
478
  try {
641
479
  if (options.selfTest) {
642
- 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
+ });
643
498
  process.exit(0);
644
499
  }
645
- const artifacts = runVisualSmoke(options);
500
+ artifacts = runVisualSmoke(options);
501
+ writeVisualManifest(artifacts.manifestPath, options, artifacts);
646
502
  checkLeftovers(options.leftoverPatterns);
647
503
  if (options.screenshot) {
648
504
  await writeTerminalScreenshot(artifacts.htmlPath, artifacts.pngPath, options.width, options.height);
505
+ artifacts.pngWritten = true;
506
+ writeVisualManifest(artifacts.manifestPath, options, artifacts);
649
507
  }
650
508
  console.log("[visual-smoke] artifacts:");
651
509
  console.log(` ansi: ${artifacts.ansiPath}`);
@@ -654,6 +512,15 @@ try {
654
512
  if (options.screenshot) console.log(` png: ${artifacts.pngPath}`);
655
513
  console.log(` jsonl.path: ${artifacts.jsonlPathFile}`);
656
514
  console.log(` jsonl: ${artifacts.jsonlPath}`);
515
+ console.log(` manifest: ${artifacts.manifestPath}`);
657
516
  } catch (error) {
658
- 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);
659
526
  }
@@ -1,4 +1,5 @@
1
1
  export declare const CURSOR_SETTING_SOURCES_ENV: "PI_CURSOR_SETTING_SOURCES";
2
+ export declare const DEFAULT_CURSOR_SETTING_SOURCES: readonly string[];
2
3
 
3
4
  export declare function resolveCursorSettingSources(raw?: string): string[] | undefined;
4
5
 
@@ -1,9 +1,10 @@
1
1
  /** Canonical Cursor settingSources parsing (parity-tested by provider runtime and maintainer scripts). */
2
2
  export const CURSOR_SETTING_SOURCES_ENV = "PI_CURSOR_SETTING_SOURCES";
3
+ export const DEFAULT_CURSOR_SETTING_SOURCES = Object.freeze(["all"]);
3
4
 
4
5
  export function resolveCursorSettingSources(raw) {
5
6
  const trimmed = raw?.trim();
6
- if (!trimmed) return ["all"];
7
+ if (!trimmed) return [...DEFAULT_CURSOR_SETTING_SOURCES];
7
8
  const normalized = trimmed.toLowerCase();
8
9
  if (["0", "false", "off", "none", "omit", "disabled"].includes(normalized)) return undefined;
9
10
  if (["1", "true", "on", "all"].includes(normalized)) return ["all"];
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
  });