pi-cursor-sdk 0.1.40 → 0.1.42

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 (43) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -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 +11 -7
  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-compact-tool-summary.ts +81 -0
  24. package/src/cursor-native-tool-display-registration.ts +31 -21
  25. package/src/cursor-native-tool-display-replay.ts +13 -2
  26. package/src/cursor-native-tool-display-state.ts +13 -4
  27. package/src/cursor-pi-tool-bridge-run.ts +6 -3
  28. package/src/cursor-pi-tool-bridge-types.ts +2 -2
  29. package/src/cursor-provider-errors.ts +2 -1
  30. package/src/cursor-provider-live-run-drain.ts +1 -1
  31. package/src/cursor-provider-turn-prepare.ts +1 -1
  32. package/src/cursor-provider-turn-send.ts +2 -0
  33. package/src/cursor-question-tool.ts +2 -1
  34. package/src/cursor-replay-activity-builders.ts +12 -4
  35. package/src/cursor-replay-summary-args.ts +21 -2
  36. package/src/cursor-sdk-event-debug.ts +3 -1
  37. package/src/cursor-skill-tool.ts +2 -1
  38. package/src/cursor-task-presentation.ts +77 -0
  39. package/src/cursor-tool-manifest.ts +2 -1
  40. package/src/cursor-tool-presentation-registry.ts +16 -2
  41. package/src/cursor-tool-result-display-readers.ts +13 -8
  42. package/src/cursor-transcript-tool-formatters.ts +5 -5
  43. 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
+ }
@@ -0,0 +1,81 @@
1
+ import { isAbsolute, relative, win32 } from "node:path";
2
+ import { asRecord, getRecord, getString } from "./cursor-record-utils.js";
3
+ import { formatDisplayPath } from "./cursor-transcript-utils.js";
4
+
5
+ export interface CursorCompactToolSummaryOptions {
6
+ cwd?: string;
7
+ }
8
+
9
+ function isWindowsAbsolutePath(path: string): boolean {
10
+ return /^[A-Za-z]:[\\/]/.test(path) || path.startsWith("\\\\");
11
+ }
12
+
13
+ export function formatCursorCompactToolPath(path: string | undefined, options: CursorCompactToolSummaryOptions): string | undefined {
14
+ const trimmed = path?.trim();
15
+ if (!trimmed) return undefined;
16
+ const normalized = trimmed.replace(/\\/g, "/");
17
+ if (normalized === "~" || normalized.startsWith("~/") || /^~[^/]+(?:\/|$)/.test(normalized)) return undefined;
18
+ if (normalized.split("/").includes("..")) return undefined;
19
+ if (/^[A-Za-z]:(?!\/)/.test(normalized)) return undefined;
20
+ if (isWindowsAbsolutePath(trimmed)) {
21
+ const cwd = options.cwd;
22
+ if (!cwd || !isWindowsAbsolutePath(cwd)) return undefined;
23
+ const relativePath = win32.relative(cwd, trimmed);
24
+ if (!relativePath || relativePath.startsWith("..") || isWindowsAbsolutePath(relativePath)) return undefined;
25
+ return relativePath.replace(/\\/g, "/");
26
+ }
27
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized)) return undefined;
28
+ if (isAbsolute(trimmed)) {
29
+ const cwd = options.cwd;
30
+ if (!cwd) return undefined;
31
+ const relativePath = relative(cwd, trimmed);
32
+ if (!relativePath || relativePath.startsWith("..") || isAbsolute(relativePath)) return undefined;
33
+ return relativePath.replace(/\\/g, "/");
34
+ }
35
+ return formatDisplayPath(normalized, options.cwd);
36
+ }
37
+
38
+ function getNestedRecord(record: Record<string, unknown> | undefined, ...keys: string[]): Record<string, unknown> | undefined {
39
+ let current = record;
40
+ for (const key of keys) {
41
+ current = getRecord(current, key);
42
+ if (!current) return undefined;
43
+ }
44
+ return current;
45
+ }
46
+
47
+ function summarizeShellTool(args: Record<string, unknown> | undefined, resultValue: Record<string, unknown> | undefined): string {
48
+ const command = getString(args, "command");
49
+ const stdout = getString(resultValue, "stdout");
50
+ const stderr = getString(resultValue, "stderr");
51
+ return [command ? `$ ${command}` : "shell", stdout, stderr].filter((part): part is string => Boolean(part)).join("\n");
52
+ }
53
+
54
+ export function summarizeCursorCompactToolCall(
55
+ toolName: string | undefined,
56
+ args: Record<string, unknown> | undefined,
57
+ result: Record<string, unknown> | undefined,
58
+ options: CursorCompactToolSummaryOptions,
59
+ ): string | undefined {
60
+ if (!toolName) return undefined;
61
+ const compactName = toolName.replace(/\s+/g, " ").trim() || "unknown";
62
+ if (compactName === "shell") return summarizeShellTool(args, getNestedRecord(result, "value"));
63
+
64
+ const path = formatCursorCompactToolPath(getString(args, "path"), options);
65
+ if (path) return `${compactName} ${path}`;
66
+ const query = getString(args, "query") ?? getString(args, "pattern");
67
+ if (query) return `${compactName} ${query}`;
68
+ return compactName;
69
+ }
70
+
71
+ export function summarizeCursorCompactConversationToolCall(step: unknown, options: CursorCompactToolSummaryOptions): string | undefined {
72
+ const record = asRecord(step);
73
+ if (getString(record, "type") !== "toolCall") return undefined;
74
+ const message = getRecord(record, "message");
75
+ return summarizeCursorCompactToolCall(
76
+ getString(message, "type"),
77
+ getRecord(message, "args"),
78
+ getRecord(message, "result"),
79
+ options,
80
+ );
81
+ }
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
2
  import { basename } from "node:path";
3
- import { getLanguageFromPath, highlightCode, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { getLanguageFromPath, highlightCode, keyHint, type ToolDefinition } from "@earendil-works/pi-coding-agent";
4
4
  import { Image, Text, type Component } from "@earendil-works/pi-tui";
5
5
  import { Type } from "typebox";
6
6
  import { resolveCursorEditDiff } from "./cursor-edit-diff.js";
@@ -9,6 +9,7 @@ import { LOCAL_READ_PREVIEW_NOTICE, isLocalReadPreviewContent } from "./cursor-t
9
9
  import {
10
10
  CURSOR_REPLAY_ACTIVITY_TOOL_NAME,
11
11
  getCursorReplayCallSummary,
12
+ shouldShowCursorReplayCollapsedExpandHint,
12
13
  type CursorReplayToolName,
13
14
  } from "./cursor-tool-presentation-registry.js";
14
15
  import {
@@ -423,6 +424,14 @@ function hasCursorReplayDisplayTitle(details: CursorReplayToolDetails | undefine
423
424
  return isCursorReplayActivityDetails(details) || isCursorReplayGenerateImageDetails(details);
424
425
  }
425
426
 
427
+ function formatCursorReplayExpandHint(): string {
428
+ try {
429
+ return keyHint("app.tools.expand", "to expand");
430
+ } catch {
431
+ return "Ctrl+O to expand";
432
+ }
433
+ }
434
+
426
435
  function renderExpandableCursorReplayResult(
427
436
  title: string,
428
437
  details: CursorReplayExpandableResultDetails,
@@ -434,8 +443,10 @@ function renderExpandableCursorReplayResult(
434
443
  ): Component {
435
444
  const text = firstContentText(result);
436
445
  const summary = details.summary ?? text.split("\n").find((line) => line.trim()) ?? "completed";
437
- let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg(isError ? "error" : "success", summary)}`;
438
446
  const expandedText = details.expandedText ?? (text.includes("\n") ? text : undefined);
447
+ const showExpandHint = expandedText && !options.expanded && shouldShowCursorReplayCollapsedExpandHint(details.sourceToolName);
448
+ const expandHint = showExpandHint ? theme.fg("dim", ` (${formatCursorReplayExpandHint()})`) : "";
449
+ let rendered = `${theme.fg("toolTitle", theme.bold(title))} ${theme.fg(isError ? "error" : "success", summary)}${expandHint}`;
439
450
  if (expandedText && (options.expanded || !details.collapseDetailsByDefault)) {
440
451
  const preview = formatCursorReplayActivityPreview(
441
452
  details,
@@ -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();