diff --git a/CHANGELOG.md b/CHANGELOG.md index b0de9a8..9c9a633 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Add default-on `PI_CURSOR_ASK_QUESTION`; set it to `0` to remove `cursor_ask_question` without disabling the rest of the pi tool bridge. - Add strictly opt-in Cursor Cloud pull-request controls: `--cursor-cloud-auto-create-pr` / `PI_CURSOR_CLOUD_AUTO_CREATE_PR` / `cloud.autoCreatePR` and `--cursor-cloud-skip-reviewer-request` / `PI_CURSOR_CLOUD_SKIP_REVIEWER_REQUEST` / `cloud.skipReviewerRequest`. Unset controls remain omitted from SDK options, project config is excluded, and local runtime behavior is unchanged. - Add strictly opt-in local-agent HTTP/1.1/SSE compatibility through `PI_CURSOR_HTTP_1_1`, `/cursor-http [on|off|toggle]`, and user `cursor-sdk.json` `local.useHttp1ForAgent`, resolved as session > environment > user > unset. Explicit values configure the Cursor SDK before local agent creation, session shutdown clears extension-owned SDK transport state before module reload, transport choices split the pooled agent key, and enabled local status shows `http1`; cloud and unset/default behavior remain unchanged. The pool-key shape change makes pre-upgrade local resume handles rebootstrap once; superseded handles remain eligible for explicit `/cursor-local-resume-cleanup`. diff --git a/README.md b/README.md index 59df3a6..345e100 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,9 @@ Cursor-native tool replay is separate from the bridge. Replay cards are display- Bridge controls: ```bash +# Keep the pi bridge but disable Cursor's interactive question tool. +PI_CURSOR_ASK_QUESTION=0 pi --model cursor/composer-2-5 + # Roll back to Cursor SDK tools/settings/MCP only; do not expose active pi tools through the bridge. PI_CURSOR_PI_TOOL_BRIDGE=0 pi --model cursor/composer-2-5 @@ -489,7 +492,7 @@ PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1 pi --model cursor/composer-2-5 On bootstrap sends, a compact **callable tool surfaces** block is injected into the Cursor prompt by default. It reminds the model that Cursor host/configured MCP tools are controlled by Cursor, while pi tool toggles only affect pi tools/bridge exposure; when bridge tools are exposed, it lists the current `pi__*` names. Disable with `PI_CURSOR_TOOL_MANIFEST=0`. -`PI_CURSOR_PI_TOOL_BRIDGE=0` is the supported rollback flag and disables the bridge entirely. The bridge also treats `false`, `off`, `none`, `no`, and `disabled` as off; `1`, `true`, `on`, `yes`, and `enabled` as on. `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` opts in to exposing overlapping pi tool names that Cursor already has native equivalents for. The installed Cursor SDK uses a 60-second MCP protocol default with no public per-server timeout option. pi-cursor-sdk overrides that seam in two directions by default: MCP `callTool` requests are extended to 3600 seconds for long-running local MCP tools (including the pi bridge and configured Cursor MCP servers), and known MCP initialize/listTools requests on first send are shortened to 10 seconds so unavailable configured MCP servers fail fast instead of blocking for a full minute. Unknown Cursor SDK MCP protocol timeout stacks keep the SDK default instead of being shortened. Override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and first-send initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. +`PI_CURSOR_ASK_QUESTION=0` disables only `cursor_ask_question`, leaving the rest of the pi bridge available; it is enabled by default. `PI_CURSOR_PI_TOOL_BRIDGE=0` is the supported rollback flag and disables the bridge entirely. Both flags treat `false`, `off`, `none`, `no`, and `disabled` as off; `1`, `true`, `on`, `yes`, and `enabled` as on. `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1` opts in to exposing overlapping pi tool names that Cursor already has native equivalents for. The installed Cursor SDK uses a 60-second MCP protocol default with no public per-server timeout option. pi-cursor-sdk overrides that seam in two directions by default: MCP `callTool` requests are extended to 3600 seconds for long-running local MCP tools (including the pi bridge and configured Cursor MCP servers), and known MCP initialize/listTools requests on first send are shortened to 10 seconds so unavailable configured MCP servers fail fast instead of blocking for a full minute. Unknown Cursor SDK MCP protocol timeout stacks keep the SDK default instead of being shortened. Override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and first-send initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. `PI_CURSOR_HTTP_1_1=true` maps to the Cursor SDK `Cursor.configure({ local: { useHttp1ForAgent: true } })` compatibility mode for corporate VPN/proxy environments where HTTP/2 streams fail. In interactive sessions, `/cursor-http on`, `/cursor-http off`, and `/cursor-http toggle` set the branch-scoped session preference and save the user default as `local.useHttp1ForAgent` in `~/.pi/agent/cursor-sdk.json`; `/cursor-http` with no argument reports the effective state. Precedence is session command/history, explicit `PI_CURSOR_HTTP_1_1`, user config, then the built-in unset default; project config is ignored for this user-level compatibility choice. Unset performs no SDK configuration, preserving the existing default path. Session shutdown clears extension-owned SDK transport state before module reload. Changing the effective setting splits the local agent pool so an agent created under another transport is not reused. When enabled, the local Cursor footer shows `http1` (for example `cursor:local · fast:on · http1`); cloud status never does. This affects Cursor SDK local-agent backend streams only; it does not configure HTTP proxies, TLS certificates, or HTTP/3. diff --git a/docs/cursor-model-ux-spec.md b/docs/cursor-model-ux-spec.md index 1f78dd3..abab60e 100644 --- a/docs/cursor-model-ux-spec.md +++ b/docs/cursor-model-ux-spec.md @@ -26,7 +26,7 @@ Current implementation notes: - Local Cursor agents get two tool surfaces. First, Cursor keeps the Cursor SDK local-agent tool surface plus configured Cursor settings, plugins, and Cursor MCP servers. Second, pi-cursor-sdk exposes active pi tools through a default-on, tokenized loopback MCP bridge when bridgeable tools exist. - `buildCursorPiToolBridgeSnapshot()` is the runtime capability source for pi bridge tools. It snapshots `pi.getActiveTools()` and `pi.getAllTools()`, carries pi 0.77+ per-tool `promptGuidelines` into bridge MCP descriptions, filters internal replay names, hides overlapping built-in pi tools (`read`, `bash`, `write`, `edit`, `grep`, `find`, `ls`) unless `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1`, and creates collision-safe MCP names such as `pi__sem_reindex`. Cursor discovers the current run's exposed bridge tools through MCP `listTools`. Bootstrap prompts include a compact callable-surface manifest from `buildCursorToolManifestText()` by default (`PI_CURSOR_TOOL_MANIFEST=1`); disable with `PI_CURSOR_TOOL_MANIFEST=0`. There is no per-turn visible tool list, status manifest, or footer manifest. User-facing summary: [Cursor tool surfaces in pi](./cursor-tool-surfaces.md). - Prompt text is the primary provider/bridge contract. Bootstrap prompts carry a short boundary block plus the callable-surface manifest by default (`PI_CURSOR_TOOL_MANIFEST=1`). MCP `listTools` descriptions use a one-line pointer to the bootstrap prompt instead of repeating the full contract (`buildCursorPiBridgeMcpToolDescription()`). Cursor must call the exposed `pi__*` MCP name, not the real pi tool name shown in pi history or transcripts. When exposed, `pi__mcp` takes preference over Cursor-configured MCP for MCP work and `pi__subagent` takes preference over Cursor-native subagents for delegation; the Cursor-native surfaces remain fallbacks when the matching pi bridge tool is absent or unavailable. Pi emits and executes the real pi tool name. Maintainer debug: `/cursor-tools` prints bridge/manifest enablement, effective `PI_CURSOR_SETTING_SOURCES`, and the current callable-surface snapshot. -- The provider also registers `cursor_ask_question` for Cursor models when the bridge is enabled. Cursor sees it as `pi__cursor_ask_question`, and pi executes it through the normal tool path so interactive users can choose options from pi UI. In non-UI modes it reports that UI is unavailable so Cursor can state a default assumption instead. When pi has visible Agent Skills loaded, the provider rewrites the skill catalog for Cursor and registers `cursor_activate_skill` as `pi__cursor_activate_skill`; pi executes it through the normal tool path so Cursor can load the full `SKILL.md` and skill resource list for the current pi-loaded skill source of truth. `PI_CURSOR_PI_TOOL_BRIDGE=0` disables the local bridge, including question and skill activation bridging. Cloud Cursor agents remain out of scope for the bridge. +- The provider also registers `cursor_ask_question` for Cursor models when the bridge and default-on `PI_CURSOR_ASK_QUESTION` control are enabled. Cursor sees it as `pi__cursor_ask_question`, and pi executes it through the normal tool path so interactive users can choose options from pi UI. `PI_CURSOR_ASK_QUESTION=0` removes only this tool while preserving the rest of the bridge. In non-UI modes it reports that UI is unavailable so Cursor can state a default assumption instead. When pi has visible Agent Skills loaded, the provider rewrites the skill catalog for Cursor and registers `cursor_activate_skill` as `pi__cursor_activate_skill`; pi executes it through the normal tool path so Cursor can load the full `SKILL.md` and skill resource list for the current pi-loaded skill source of truth. `PI_CURSOR_PI_TOOL_BRIDGE=0` disables the local bridge, including question and skill activation bridging. Cloud Cursor agents remain out of scope for the bridge. - The bridge queues MCP calls, emits provider `toolcall_*` events, waits for matching pi `toolResult` messages by `toolCallId`, resolves the result back into the same live Cursor SDK run without creating a new `Agent`, and never calls tool `execute()` handlers directly. The same-run resume invariant holds unless the run was disposed, aborted, or cancelled. - Cursor SDK MCP tool calls use a guarded timeout override because installed `@cursor/sdk` 1.0.23 still has a 60-second MCP request default with no public per-server timeout option. The extension extends the verified Cursor SDK MCP `callTool` timeout path to 3600 seconds by default and shortens the verified first-send MCP initialize/listTools timeout paths to 10 seconds by default so unavailable configured MCP servers do not block the first reply for a full minute; unknown MCP protocol timeout stacks keep the SDK default. Users can override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. - Cursor SDK local safety controls are off by default. `--cursor-auto-review` / `PI_CURSOR_AUTO_REVIEW` and `--cursor-sandbox` / `PI_CURSOR_SANDBOX` pass only explicit enabled values into `Agent.create({ local })`; user or trusted project config can set `local.autoReview` and `local.sandboxOptions.enabled`; project config is active only when Pi's project-trust flow reached the extension and approved the project or the run used explicit `--approve`, and project saves require the same immutable trust provenance rather than creating Pi trust resources automatically. Pi 0.80.9 loads `pi install -l` project-local extensions after the trust event, so those installs require `--approve` on every run that reads or writes `.pi/cursor-sdk.json`. Explicit runtime, fast-default, and HTTP transport saves preserve unrecognized config fields, reject malformed or non-object JSON without rewriting it, and use one lock-protected read-modify-write path; fast saves mutate only the selected model key. Because Pi can mutate its in-memory session branch before a journal append throws, a completed global save is authoritative and the command reports the partial journal failure instead of attempting an ambiguous rollback; the new global value stays authoritative over stale branch entries until a later successful save or session restart. diff --git a/docs/cursor-tool-surfaces.md b/docs/cursor-tool-surfaces.md index b5329f3..8ef666d 100644 --- a/docs/cursor-tool-surfaces.md +++ b/docs/cursor-tool-surfaces.md @@ -30,9 +30,12 @@ Default behavior: - The pi bridge exposes **active pi tools** as `pi__*` MCP names when `PI_CURSOR_PI_TOOL_BRIDGE` is enabled (default on). - Overlapping pi builtins (`read`, `bash`, `write`, `edit`, `grep`, `find`, `ls`) are **hidden** from the bridge unless `PI_CURSOR_EXPOSE_BUILTIN_TOOLS=1`. -`pi-cursor-sdk` always registers `cursor_ask_question` for Cursor models when the bridge is on; Cursor sees `pi__cursor_ask_question`. When pi has visible Agent Skills loaded, the extension also rewrites pi's skill catalog for Cursor and activates `cursor_activate_skill`; Cursor sees `pi__cursor_activate_skill` and should call it with a listed skill name before applying that skill. The activation result returns the full `SKILL.md`, the skill directory for relative paths, and a bounded list of bundled `scripts/`, `references/`, and `assets/` files without eagerly reading those resources. +`pi-cursor-sdk` registers `cursor_ask_question` for Cursor models when the bridge is on and `PI_CURSOR_ASK_QUESTION` is enabled (the default); Cursor sees `pi__cursor_ask_question`. Set `PI_CURSOR_ASK_QUESTION=0` to remove only this tool while preserving the rest of the bridge. When pi has visible Agent Skills loaded, the extension also rewrites pi's skill catalog for Cursor and activates `cursor_activate_skill`; Cursor sees `pi__cursor_activate_skill` and should call it with a listed skill name before applying that skill. The activation result returns the full `SKILL.md`, the skill directory for relative paths, and a bounded list of bundled `scripts/`, `references/`, and `assets/` files without eagerly reading those resources. ```bash +# Disable only Cursor's interactive question tool +PI_CURSOR_ASK_QUESTION=0 pi --model cursor/composer-2-5 + # Disable pi bridge entirely PI_CURSOR_PI_TOOL_BRIDGE=0 pi --model cursor/composer-2-5 diff --git a/src/cursor-question-tool.ts b/src/cursor-question-tool.ts index 5c86fc1..4250f89 100644 --- a/src/cursor-question-tool.ts +++ b/src/cursor-question-tool.ts @@ -2,11 +2,17 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { arePiToolsDisabled } from "./cursor-active-tools.js"; +import { parseEnvBoolean } from "./cursor-env-boolean.js"; import { isCursorModel } from "./cursor-model.js"; import { registerCursorModelLifecycle, type CursorModelLifecycleExtensionApi } from "./cursor-model-lifecycle.js"; import { resolveCursorPiToolBridgeEnabled } from "./cursor-pi-tool-bridge-env.js"; export const CURSOR_ASK_QUESTION_TOOL_NAME = "cursor_ask_question"; +export const CURSOR_ASK_QUESTION_ENV = "PI_CURSOR_ASK_QUESTION"; + +export function resolveCursorAskQuestionEnabled(env: Record = process.env): boolean { + return parseEnvBoolean(env[CURSOR_ASK_QUESTION_ENV], true); +} interface CursorQuestionOption { label: string; @@ -188,6 +194,8 @@ function syncCursorQuestionToolForModel(pi: Pick { delete process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY; delete process.env.PI_CURSOR_REGISTER_NATIVE_TOOLS; delete process.env.PI_CURSOR_PI_TOOL_BRIDGE; + delete process.env.PI_CURSOR_ASK_QUESTION; delete process.env.PI_CURSOR_AUTO_REVIEW; delete process.env.PI_CURSOR_SANDBOX; await cursorPiToolBridgeTestUtils.resetRegisteredBridgeForTests(); diff --git a/test/index-registration.test.ts b/test/index-registration.test.ts index 53aa2bb..5a356f3 100644 --- a/test/index-registration.test.ts +++ b/test/index-registration.test.ts @@ -32,7 +32,7 @@ import { __testUtils as cursorSessionScopeTestUtils } from "../src/cursor-sessio import { streamCursor } from "../src/cursor-provider.js"; import { streamCursorLazy } from "../src/cursor-provider-lazy.js"; import { buildCursorPiToolBridgeSnapshot } from "../src/cursor-pi-tool-bridge.js"; -import { CURSOR_ASK_QUESTION_TOOL_NAME } from "../src/cursor-question-tool.js"; +import { CURSOR_ASK_QUESTION_TOOL_NAME, resolveCursorAskQuestionEnabled } from "../src/cursor-question-tool.js"; import { CURSOR_ACTIVATE_SKILL_TOOL_NAME } from "../src/cursor-skill-tool.js"; import { __testUtils as cursorSdkProcessErrorGuardTestUtils } from "../src/cursor-sdk-process-error-guard.js"; @@ -423,6 +423,26 @@ describe("extension registration and discovery", () => { expect(snapshot.tools.find((tool) => tool.piToolName === CURSOR_ASK_QUESTION_TOOL_NAME)?.description).toContain("Ask the user"); }); + it("disables only the Cursor question tool with PI_CURSOR_ASK_QUESTION=0", async () => { + expect(resolveCursorAskQuestionEnabled({})).toBe(true); + for (const value of ["0", "false", "off", "none", "no", "disabled"]) { + expect(resolveCursorAskQuestionEnabled({ PI_CURSOR_ASK_QUESTION: value })).toBe(false); + } + + process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "0"; + process.env.PI_CURSOR_ASK_QUESTION = "0"; + mockedDiscover.mockResolvedValueOnce([]); + const pi = createExtensionPi(); + + await extensionFactory(pi); + await pi.runSessionStart(); + + expect(cursorPiToolBridgeTestUtils.getRegisteredBridgeForTests()?.isEnabled()).toBe(true); + expect(pi._tools.map((tool) => tool.name)).not.toContain(CURSOR_ASK_QUESTION_TOOL_NAME); + expect(pi._activeToolNames()).not.toContain(CURSOR_ASK_QUESTION_TOOL_NAME); + expect(buildCursorPiToolBridgeSnapshot(pi).piToolNameToMcpToolName.has(CURSOR_ASK_QUESTION_TOOL_NAME)).toBe(false); + }); + it("honors PI_CURSOR_PI_TOOL_BRIDGE=0 at the extension registration path", async () => { process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "0"; process.env.PI_CURSOR_PI_TOOL_BRIDGE = "0";