pi-cursor-sdk 0.1.25 → 0.1.27
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.
- package/CHANGELOG.md +24 -0
- package/README.md +28 -7
- package/docs/cursor-live-smoke-checklist.md +5 -5
- package/docs/cursor-model-ux-spec.md +6 -6
- package/docs/cursor-native-tool-replay.md +2 -2
- package/docs/cursor-native-tool-visual-audit.md +4 -4
- package/docs/cursor-testing-lessons.md +1 -1
- package/package.json +2 -2
- package/src/cursor-agent-message-web-tools.ts +4 -1
- package/src/cursor-pi-tool-bridge-constants.ts +2 -0
- package/src/cursor-pi-tool-bridge-run.ts +1 -2
- package/src/cursor-pi-tool-bridge-server.ts +2 -1
- package/src/cursor-pi-tool-bridge.ts +1 -1
- package/src/cursor-provider-errors.ts +77 -5
- package/src/cursor-provider-run-finalizer.ts +5 -5
- package/src/cursor-provider-turn-finalize.ts +7 -4
- package/src/cursor-provider-turn-prepare.ts +2 -1
- package/src/cursor-provider-turn-runner.ts +5 -5
- package/src/cursor-provider-turn-send.ts +5 -5
- package/src/cursor-provider.ts +3 -3
- package/src/cursor-sdk-process-error-guard.ts +99 -0
- package/src/cursor-sdk-runtime.ts +5 -0
- package/src/cursor-session-agent.ts +3 -3
- package/src/cursor-tool-manifest.ts +1 -1
- package/src/index.ts +2 -1
- package/src/model-discovery.ts +36 -3
- package/src/model-list-cache.ts +116 -0
- package/src/cursor-sdk-abort-error-guard.ts +0 -113
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.27 - 2026-05-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Upgrade the pinned Cursor SDK runtime dependency to `@cursor/sdk@1.0.16` and keep the local validation baseline on pi `0.77.0`.
|
|
10
|
+
- Align Cursor context-window checkpoint reads with the SDK 1.0.16 local platform options by scoping direct `createAgentPlatform()` calls to the pi session cwd, matching the workspace used for `Agent.create()`.
|
|
11
|
+
- Review SDK 1.0.16 public-surface changes: new custom `LocalAgentStore` exports (`JsonlLocalAgentStore`, `SqliteLocalAgentStore`, store filters/paginators), per-call `store` options on local agent/list/message APIs, `Cursor.configure()` / `configureCursorSdk()` local defaults, HTTP/1 agent override support, public `CursorAgentPlatformOptions` local-store fields, and the removal of `AgentOptions.platform`. The extension continues to use the SDK default SQLite store and does not install a custom global SDK configuration because pi session cwd remains the source of truth for local persistence.
|
|
12
|
+
|
|
13
|
+
## 0.1.26 - 2026-05-29
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Cache the discovered Cursor model catalog on disk at `~/.pi/agent/cursor-sdk-model-list.json` (`0600`, keyed by an API-key fingerprint) so warm pi startups skip the live `Cursor.models.list` network round-trip that added several seconds to boot (#78). Tune with `PI_CURSOR_SDK_MODEL_CACHE_TTL_MS` (default 24h) or disable with `PI_CURSOR_SDK_DISABLE_MODEL_CACHE=1`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Clarify setup docs and runtime auth messages: `pi-cursor-sdk` requires a Cursor SDK API key and does not reuse Cursor Agent CLI/Desktop login or subscription auth.
|
|
22
|
+
- `/cursor-refresh-models` now forces a live catalog refresh, bypassing the on-disk cache and rewriting it. A previously cached catalog is preferred over the bundled fallback when a live refresh fails.
|
|
23
|
+
- Lazy-load the Cursor SDK runtime so warm cached startup paths avoid importing `@cursor/sdk` until live model discovery or a Cursor turn needs it (#100).
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Prevent Cursor SDK `ConnectError: [unauthenticated]` failures from crashing pi as process-level uncaught exceptions; surface them as recoverable Cursor auth errors instead.
|
|
28
|
+
|
|
5
29
|
## 0.1.25 - 2026-05-28
|
|
6
30
|
|
|
7
31
|
### Fixed
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ pi install https://github.com/fitchmultz/pi-cursor-sdk
|
|
|
24
24
|
pi --model cursor/composer-2.5
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
3. In pi, run `/login`, choose `Use an API key`, choose `Cursor`, and paste your Cursor API key.
|
|
27
|
+
3. In pi, run `/login`, choose `Use an API key`, choose `Cursor`, and paste your Cursor SDK API key.
|
|
28
28
|
|
|
29
29
|
If pi started without a key, run `/cursor-refresh-models` after `/login` to refresh the full live Cursor model catalog without restarting pi. Inside pi, use `/model` to choose another Cursor model.
|
|
30
30
|
|
|
@@ -32,9 +32,9 @@ If pi started without a key, run `/cursor-refresh-models` after `/login` to refr
|
|
|
32
32
|
|
|
33
33
|
- Node.js 22.19+
|
|
34
34
|
- pi 0.76.0 or newer
|
|
35
|
-
- a Cursor API key saved through `/login`, available as `CURSOR_API_KEY`, or passed with pi's `--api-key`
|
|
35
|
+
- a Cursor SDK API key saved through `/login`, available as `CURSOR_API_KEY`, or passed with pi's `--api-key`
|
|
36
36
|
|
|
37
|
-
No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.
|
|
37
|
+
No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.16`, so normal package installation brings in the SDK version this extension was built and tested against. This package declares a pi **minimum** of 0.76.0 with no maximum peer version, so users who update pi before this extension is republished are not blocked from trying the existing extension. The current validation baseline is pi 0.77.0 plus Cursor SDK 1.0.16; older pi or Cursor SDK compatibility paths are not maintained.
|
|
38
38
|
|
|
39
39
|
## Install
|
|
40
40
|
|
|
@@ -67,7 +67,11 @@ npm install
|
|
|
67
67
|
pi -e . --model cursor/composer-2.5
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
## Configure your Cursor API key
|
|
70
|
+
## Configure your Cursor SDK API key
|
|
71
|
+
|
|
72
|
+
`pi-cursor-sdk` passes an explicit API key to the Cursor SDK. It does **not** reuse Cursor Agent CLI login, Cursor Desktop login, or Cursor subscription/OAuth state shown by `agent status`.
|
|
73
|
+
|
|
74
|
+
Use either a user API key from Cursor Dashboard → Integrations or a service account API key from Team settings. Team Admin API keys are not supported by the Cursor SDK. Then configure the key with one of the methods below.
|
|
71
75
|
|
|
72
76
|
Preferred setup:
|
|
73
77
|
|
|
@@ -80,11 +84,13 @@ Then, inside pi:
|
|
|
80
84
|
1. Run `/login`.
|
|
81
85
|
2. Select `Use an API key`.
|
|
82
86
|
3. Select `Cursor`.
|
|
83
|
-
4. Paste your Cursor API key.
|
|
87
|
+
4. Paste your Cursor SDK API key.
|
|
84
88
|
5. The key is saved in pi's native `~/.pi/agent/auth.json`.
|
|
85
89
|
|
|
86
90
|
If pi started without a key, fallback Cursor models still register so `/login` is reachable. After `/login`, fallback model runs can use the stored key, and `/cursor-refresh-models` refreshes the full live Cursor model catalog discovered from the Cursor SDK without restarting pi.
|
|
87
91
|
|
|
92
|
+
Note: if `/login` shows `Cursor ✓ key in models.json` but you have not saved a Cursor key and `CURSOR_API_KEY` is unset, that status is a pi auth-status limitation. A real Cursor SDK API key is still required for Cursor runs.
|
|
93
|
+
|
|
88
94
|
Environment setup:
|
|
89
95
|
|
|
90
96
|
```bash
|
|
@@ -100,6 +106,18 @@ pi --api-key "your-key" --model cursor/composer-2.5 --cursor-no-fast -p "Say ok
|
|
|
100
106
|
|
|
101
107
|
Discovery uses pi's native resolution order for this extension: `--api-key`, the stored `cursor` key in `~/.pi/agent/auth.json`, then `CURSOR_API_KEY`.
|
|
102
108
|
|
|
109
|
+
### Model catalog cache
|
|
110
|
+
|
|
111
|
+
To avoid a live `Cursor.models.list` network round-trip on every pi startup, the discovered catalog is cached on disk at `~/.pi/agent/cursor-sdk-model-list.json` (written `0600`, keyed by an API-key fingerprint — the key itself is never stored). Warm startups within the cache TTL skip the network call and avoid loading `@cursor/sdk` until a Cursor turn needs it; `/cursor-refresh-models` always bypasses the cache and refreshes the live catalog. If a refresh fails, a previously cached catalog is preferred over the generic bundled fallback.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
# Cache lifetime in milliseconds (default 86400000 = 24h).
|
|
115
|
+
PI_CURSOR_SDK_MODEL_CACHE_TTL_MS=3600000 pi --model cursor/composer-2.5
|
|
116
|
+
|
|
117
|
+
# Disable the cache and always discover live.
|
|
118
|
+
PI_CURSOR_SDK_DISABLE_MODEL_CACHE=1 pi --model cursor/composer-2.5
|
|
119
|
+
```
|
|
120
|
+
|
|
103
121
|
Do not store the API key in `~/.pi/agent/cursor-sdk.json`. That file is only for non-secret extension state such as Cursor fast defaults. `PATH` is only for executable lookup and should not contain the API key.
|
|
104
122
|
|
|
105
123
|
## Verify your setup
|
|
@@ -118,9 +136,12 @@ Expected behavior:
|
|
|
118
136
|
Smoke test:
|
|
119
137
|
|
|
120
138
|
```bash
|
|
121
|
-
pi --model cursor/composer-2.5 --cursor-no-fast -
|
|
139
|
+
pi --model cursor/composer-2.5 --cursor-no-fast --no-session --mode json \
|
|
140
|
+
-p "Reply exactly PI_CURSOR_MODEL_OK and nothing else."
|
|
122
141
|
```
|
|
123
142
|
|
|
143
|
+
Expected: the final assistant text is `PI_CURSOR_MODEL_OK`. If auth is missing or invalid, pi should tell you to configure a Cursor SDK API key via `/login`, `CURSOR_API_KEY`, or `--api-key`.
|
|
144
|
+
|
|
124
145
|
## Choosing a model
|
|
125
146
|
|
|
126
147
|
Choose Cursor models interactively with `/model`, or pass a model on the command line:
|
|
@@ -306,7 +327,7 @@ Actual Cursor runs still need a key from `/login`, `CURSOR_API_KEY`, or `--api-k
|
|
|
306
327
|
|
|
307
328
|
### I can see Cursor models, but runs fail
|
|
308
329
|
|
|
309
|
-
You may be seeing fallback startup models or a missing/invalid key. In interactive pi, run `/login`, choose `Use an API key`, choose `Cursor`, paste the key, then run `/cursor-refresh-models`.
|
|
330
|
+
You may be seeing fallback startup models or a missing/invalid Cursor SDK API key. Cursor Agent CLI/Desktop login is not reused by this extension. In interactive pi, run `/login`, choose `Use an API key`, choose `Cursor`, paste the key, then run `/cursor-refresh-models`.
|
|
310
331
|
|
|
311
332
|
When a Cursor run fails after auth is configured, pi now surfaces scrubbed provider detail instead of only `Cursor SDK run failed`. Generic SDK failures include safe run metadata such as model id, a short run id prefix, and duration when available. Check the red toast or assistant error message for that detail before retrying.
|
|
312
333
|
|
|
@@ -66,7 +66,7 @@ The replay scan flags only error `toolResult` / error assistant messages with `T
|
|
|
66
66
|
Pass criteria:
|
|
67
67
|
|
|
68
68
|
- `pi --version` reports pi 0.77.0 for this cutover baseline.
|
|
69
|
-
- `npm ls` shows `@cursor/sdk@1.0.
|
|
69
|
+
- `npm ls` shows `@cursor/sdk@1.0.16` and local `@earendil-works/*@0.77.0` packages.
|
|
70
70
|
- `cursor/composer-2.5` appears in the model list.
|
|
71
71
|
- No Cursor key or auth token is printed.
|
|
72
72
|
- If neither `~/.pi/agent/auth.json` cursor auth nor `CURSOR_API_KEY` is available, stop and report the live smoke as blocked.
|
|
@@ -115,7 +115,7 @@ Run a real interactive session under tmux:
|
|
|
115
115
|
```bash
|
|
116
116
|
SESSION="pi-cursor-sdk-smoke-$(date +%s)"
|
|
117
117
|
tmux new-session -d -s "$SESSION" -x 120 -y 40 -- zsh -lc \
|
|
118
|
-
"cd '$PWD' && PI_CURSOR_SETTING_SOURCES=none pi -e . --cursor-no-fast --model cursor/composer-2.5 --session-dir '$SMOKE_DIR/tui' --session-id cursor-sdk-
|
|
118
|
+
"cd '$PWD' && PI_CURSOR_SETTING_SOURCES=none pi -e . --cursor-no-fast --model cursor/composer-2.5 --session-dir '$SMOKE_DIR/tui' --session-id cursor-sdk-1016-tui --no-tools 'TUI smoke. Compute 19 + 23. Reply only with SUM=<number>.'"
|
|
119
119
|
```
|
|
120
120
|
|
|
121
121
|
Observe with `tmux capture-pane -pt "$SESSION"` or attach manually.
|
|
@@ -131,10 +131,10 @@ Pass criteria:
|
|
|
131
131
|
|
|
132
132
|
## 4. Mandatory visual card/color rendering check
|
|
133
133
|
|
|
134
|
-
This is the canonical visual release path for Cursor provider/runtime changes. It requires offscreen TUI visual inspection, not only JSONL or code review. Use pi 0.77.0, `@cursor/sdk@1.0.
|
|
134
|
+
This is the canonical visual release path for Cursor provider/runtime changes. It requires offscreen TUI visual inspection, not only JSONL or code review. Use pi 0.77.0, `@cursor/sdk@1.0.16`, a fresh temporary session dir, Cursor SDK `plan` mode, native replay enabled, and the checked-in visual runner. The runner resolves `pi` by directly walking the parent `PATH`, uses `process.execPath` for Node, and prepends that Node directory for both prereq checks and tmux launches so `#!/usr/bin/env node` shims use the validated Node. The default matrix is native replay only: native replay registration is forced on, settings sources are `none`, the pi bridge is off, overlapping built-in pi tools are not exposed, and inherited Cursor SDK event-debug artifact env is cleared. With `--event-debug`, debug capture writes to a deterministic directory under `VISUAL_DIR`.
|
|
135
135
|
|
|
136
136
|
```bash
|
|
137
|
-
VISUAL_DIR="$(mktemp -d /tmp/pi-cursor-sdk-
|
|
137
|
+
VISUAL_DIR="$(mktemp -d /tmp/pi-cursor-sdk-1016-visual.XXXXXX)"
|
|
138
138
|
VISUAL_ARGS=(
|
|
139
139
|
--ext "$PWD"
|
|
140
140
|
--cwd "$PWD"
|
|
@@ -204,7 +204,7 @@ Pass criteria:
|
|
|
204
204
|
PI_CURSOR_SETTING_SOURCES=none \
|
|
205
205
|
pi -e . --cursor-no-fast --cursor-mode plan --model cursor/composer-2.5 \
|
|
206
206
|
--session-dir "$SMOKE_DIR/cursor-mode-plan" \
|
|
207
|
-
--session-id cursor-sdk-
|
|
207
|
+
--session-id cursor-sdk-1016-plan \
|
|
208
208
|
--no-tools \
|
|
209
209
|
-p 'Cursor mode smoke. Reply with one short implementation plan for printing hello.' \
|
|
210
210
|
> "$SMOKE_DIR/cursor-mode-plan.stdout.txt" \
|
|
@@ -15,7 +15,7 @@ Current implementation notes:
|
|
|
15
15
|
- Cursor status uses one coordinated `ctx.ui.setStatus("cursor", ...)` value for fast and non-default plan mode; the default pi footer remains intact.
|
|
16
16
|
- Installed `@cursor/sdk` user messages accept images, and Cursor models are treated as image-capable; registered input metadata is `text` plus `image`.
|
|
17
17
|
- Image payload forwarding sends images only from the latest user message. If the latest user turn is plain text after an earlier image turn, the transcript keeps an `[image omitted from transcript]` placeholder but no image bytes are sent to Cursor. The prompt explicitly tells Cursor that prior image bytes are unavailable and to ask the user to reattach or describe a prior image when needed. Carrying images forward across turns remains a future product decision because it affects token cost, privacy, stale visual context, and expected multimodal follow-up behavior.
|
|
18
|
-
- Exact `@cursor/sdk@1.0.
|
|
18
|
+
- Exact `@cursor/sdk@1.0.16` is a package dependency of this extension; users should not need a global SDK install. pi 0.77.0 is the current validation baseline, while published pi peer dependencies are minimum-only `>=0.76.0` ranges with no upper bound. Newer pi versions are allowed to attempt loading this extension before a matching extension release exists; compatibility is best-effort until validated.
|
|
19
19
|
- Cursor auth uses pi-native API-key resolution for provider `cursor`: CLI `--api-key`, stored `~/.pi/agent/auth.json` API key from `/login`, then `CURSOR_API_KEY`. The extension config file stores only non-secret Cursor-only state such as fast defaults.
|
|
20
20
|
- Local agents pass `settingSources: ["all"]` by default so Cursor MCP servers, plugin tools, project/user settings, and related Cursor-native capabilities are available. Users can narrow loading with a comma-separated list such as `PI_CURSOR_SETTING_SOURCES=project,user,plugins`, or disable ambient setting sources with `PI_CURSOR_SETTING_SOURCES=none`. The provider suppresses direct Cursor SDK bootstrap stdout/stderr/console noise (including late first-send workspace loading such as hook compatibility warnings) so it does not pollute pi's TUI.
|
|
21
21
|
- On `cursor/*` models, pi-cursor-sdk removes only pi-generated `<project_instructions>` blocks that overlap the effective Cursor `settingSources`: `user` for `~/.pi/agent/AGENTS.md`; `project` for discovered repo/parent `AGENTS.md` and `CLAUDE.md` (verified Cursor behavior: local agents load project `AGENTS.md` and `CLAUDE.md`). `~/.pi/agent/CLAUDE.md` is not removed (Cursor user layer uses `~/.claude/CLAUDE.md`). Blocks are removed by exact pi serialization match from structured `contextFiles` via the `before_agent_start` hook, not in `buildCursorPrompt` sanitization. Suppression is skipped with `-nc`, `PI_CURSOR_SETTING_SOURCES=none`, narrowed sources such as `plugins` that omit the matching layer, or `PI_CURSOR_PRESERVE_PI_AGENTS_MD=1`. Switching away from a Cursor model restores pi's full context block on the next user message.
|
|
@@ -26,18 +26,18 @@ Current implementation notes:
|
|
|
26
26
|
- 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. 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.
|
|
27
27
|
- 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. `PI_CURSOR_PI_TOOL_BRIDGE=0` disables the local bridge, including question bridging. Cloud Cursor agents remain out of scope for the bridge.
|
|
28
28
|
- 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.
|
|
29
|
-
- Cursor SDK MCP tool calls use a guarded timeout override because installed `@cursor/sdk` 1.0.
|
|
29
|
+
- Cursor SDK MCP tool calls use a guarded timeout override because installed `@cursor/sdk` 1.0.16 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`.
|
|
30
30
|
- Bridge diagnostics are opt-in only: `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` writes typed, allowlisted, scrubbed single-line JSONL records to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`. Diagnostics are scrubbed operational logs, not anonymous telemetry. They intentionally include tool names, safe correlation IDs, run lifecycle, exposed pi↔MCP name pairs, queued requests, result resolution, rejection, cancellation, and pending counts. Correlation IDs are generated independently from the tokenized endpoint path, and Cursor MCP call IDs are hashed before serialization. Diagnostics must not include endpoint paths/URLs/path components/tokens, API keys, bearer tokens, cookies, session credentials, raw args/results, stdout/stderr payloads, file contents, Cursor settings output, or local private session paths in tracked docs, and they must not call pi UI status, notification, or footer APIs. If tool names themselves are unacceptable for a release target, bridge debug diagnostics are not safe for shared logs under the current contract.
|
|
31
31
|
- This repo does not provide a generic desktop-automation, browser-driver, or CDP recipe. Provider docs should describe pi-cursor-sdk's Cursor provider/bridge contract only.
|
|
32
|
-
- Cursor internal tool activity is recorded from SDK events and scrubbed. Maintainer reference for all 16 `@cursor/sdk@1.0.
|
|
32
|
+
- Cursor internal tool activity is recorded from SDK events and scrubbed. Maintainer reference for all 16 `@cursor/sdk@1.0.16` `ToolType` values, runtime alias normalization, and intentional mapping/fallback rules: [Cursor native tool replay — SDK ToolType replay matrix](./cursor-native-tool-replay.md#sdk-tooltype-replay-matrix) (official SDK docs: https://cursor.com/docs/sdk/typescript). In interactive TTY sessions, supported completed `read`, `bash`, `grep`, `find`, `ls`, `edit`, `write`, diagnostics, delete, todo/plan, task, image generation, MCP, semantic search, and screen recording activity is replayed through pi's native tool-call rendering path with recorded Cursor results, so the TUI can show native-looking cards without rerunning Cursor's reads/shell commands/file edits. Cursor `glob` activity is replayed through native `find` cards. Cursor write activity is replayed through native-looking `write` cards, and Cursor StrReplace/edit activity uses native-looking `edit` only when recorded arguments truthfully satisfy pi's `edit` schema; path-only Cursor edit and notebook edit replay falls back to neutral Cursor activity before pi validation. Diagnostics, delete, todos/plans, task, image, and MCP activity use neutral Cursor activity cards with pi's default success/error shell. Neutral Cursor activity calls include `activityTitle` and, when available, `activitySummary` so partial/collapsed cards preserve identity such as `Cursor plan`, `Cursor todos`, `Cursor MCP`, or `Cursor edit`. For long-running or externally meaningful Cursor tools (`task`, `shell`, `mcp`, `generateImage`, `recordScreen`, `semSearch`, web search/fetch, plan/todo), the provider may surface one low-noise deferred in-progress thinking line such as `Cursor MCP: external_search` from bounded, scrubbed SDK args; fast local tools (`read`, `grep`, `glob`, and similar) skip lifecycle lines when completion follows immediately, and pi bridge MCP calls are excluded because pi already shows real pi tool execution ([lifecycle visibility](./cursor-native-tool-replay.md#low-noise-tool-lifecycle-visibility)). Replay-only tools display recorded Cursor results, normalize workspace-local paths/diff headers for display, use pi diff colors for edit previews and path-inferred syntax highlighting for write previews, and fail closed if called without a recorded result. Native replay wrappers are registered only for tool names not already owned by another extension; conflicting tools use the bounded scrubbed transcript fallback. Cursor workflow tools such as mode/task/todo/plan activity are not pi workflow controls; reported todo/plan events are displayed as Cursor activity only. Plan/todo replay cards can be followed by Cursor's final plan text, selected from `run.wait().result` when Cursor provides one and trimmed against already-emitted text. Started Cursor SDK tool calls that never receive a completion event are surfaced with bounded user-visible labels/traces (neutral activity cards when native replay routing allows, otherwise the same inactive or transcript trace fallbacks used for completed replay) instead of being silently discarded when the run failed/aborted, produced no assistant text, or involved external/side-effectful tools; incomplete fast local discovery starts (`read`, `grep`, `glob`, `ls`) remain maintainer-debug-only after successful text-producing runs so stale SDK start events do not create red post-answer cards. Explicit failures remain visible when Cursor reports them through completed tool calls or step results. Pi bridge MCP starts remain excluded from duplicate incomplete Cursor cards because pi already shows real pi tool execution. `PI_CURSOR_NATIVE_TOOL_DISPLAY=0` disables native replay, and `PI_CURSOR_REGISTER_NATIVE_TOOLS=0` is a registration-only opt-out that keeps the transcript fallback without shadowing pi tool names. When bridge or native replay cards are emitted, the provider mirrors Codex's turn shape as Cursor SDK activity arrives: assistant `toolUse`, pi `toolResult`s, live post-tool Cursor thinking/text, any later tool batches as further `toolUse` turns, then Cursor's final assistant answer. For shell replay, completed `stdout` / `stderr` are primary; unambiguous `shell-output-delta` data is used only as display-only fallback for empty successful shell completions, and overlapping shell calls drop ambiguous deltas instead of guessing. Non-interactive runs keep bounded scrubbed transcript output instead, preserving `pi -p` assistant text output. Cursor text deltas stream live when no live-run turn split is active.
|
|
33
33
|
- Synthetic replay names are internal compatibility details. New model-facing prompt text and user-visible cards use native tool names when renderer-compatible, or neutral Cursor activity labels when not. Legacy sessions containing old internal replay names are sanitized before prompt/display. Bridge MCP names such as `pi__sem_reindex` are MCP-only; pi session output uses real pi tool names.
|
|
34
34
|
- Cursor SDK usage events report cumulative internal agent/tool/cache work, not the replayable pi prompt context. The extension does not copy raw Cursor SDK usage into pi usage or compaction. For Cursor assistant messages, `usage.input`/`usage.output` are approximate pi session activity components: initial Cursor prompt input is counted once, consumed split-run tool results are counted as deduped input on the following assistant turn, and assistant output includes visible text/thinking/tool-call content. `usage.totalTokens` is the replayable Cursor prompt/context estimate derived from the same `buildCursorPrompt()` path used for `Agent.send`; it may differ from `input + output` and is the context-safe value for display/compaction. `src/cursor-usage-accounting.ts` owns this usage policy, and `src/cursor-live-run-accounting.ts` owns prompt-once and consumed-tool-result accounting so provider usage and bridge result resolution share the same matched tool-result boundary.
|
|
35
35
|
- Audit observation, 2026-05-19, superseded by the 2026-05-21 replay pass and #68 incomplete visibility, then narrowed by the 2026-05-26 fast-local suppression: a missing-file read with Composer 2.5 emitted `tool-call-started` for Cursor `read`, then streamed final text `Error: File not found`, but did not emit `tool-call-completed` or an `onStep` `toolCall` error result. Leftover external/side-effectful started calls are surfaced at run completion through the same native replay routing as completed tools (activity cards when allowed, otherwise inactive/transcript traces), while fast local discovery starts are debug-only after a successful text-producing run. Cursor-reported completed/step errors remain visible.
|
|
36
36
|
- Maintainer visual verification for replay-card changes should follow [Cursor Native Tool Visual Audit Workflow](./cursor-native-tool-visual-audit.md): offscreen PTY-driven pi run, xterm.js/Playwright screenshot rendering, and JSONL inspection before accepting commits or PRs.
|
|
37
37
|
- Cursor provider/runtime releases should follow [Cursor Live Smoke Checklist](./cursor-live-smoke-checklist.md) with real `pi -e . --cursor-no-fast --model cursor/composer-2.5` invocations, manual observation, temporary session dirs, diagnostics scans, and persisted JSONL inspection. See [Cursor testing lessons](./cursor-testing-lessons.md) for auth.json seeding, isolated smoke harnesses, and replay JSONL scans. Assume every runtime surface is in scope. A release is not ready when any live check is optional, deferred, mostly passing, or unobserved.
|
|
38
38
|
- For models without a catalog `context` parameter, context windows are not hardcoded. The extension ships a bundled SDK-derived default/non-Max cache generated from `createAgentPlatform().checkpointStore.loadLatest(agentId).tokenDetails.maxTokens`. Successful runs can update a local override cache, but model discovery does not probe models at startup.
|
|
39
|
-
- Max Mode context windows are distinct from default/non-Max context windows. `@cursor/sdk` 1.0.
|
|
40
|
-
- `@cursor/sdk`
|
|
39
|
+
- Max Mode context windows are distinct from default/non-Max context windows. `@cursor/sdk` 1.0.16 documentation says the SDK may enable Max Mode automatically when a selected model requires it, but the public local-agent `ModelSelection` path still does not expose a manual Max Mode selector. Do not advertise Max Mode context windows unless the SDK catalog exposes an exact parameter/variant or the SDK public API adds a Max Mode selector that the extension actually sends.
|
|
40
|
+
- The installed `@cursor/sdk` exposes latest-style `ModelListItem.aliases`. The extension registers only unambiguous aliases as pi model IDs (with the same context suffixes when applicable) and sends the alias back in `ModelSelection.id`, while sharing Cursor-only state such as fast defaults with the underlying catalog `id`. Aliases shared by multiple base models, such as generic family aliases, are skipped because the pi row metadata would otherwise imply one base model while Cursor may resolve the alias to another.
|
|
41
41
|
- Session-scoped Cursor SDK agent pooling reuses one live `@cursor/sdk` agent across compatible follow-up turns within the same pi session scope. `planCursorSessionSend()` in `src/cursor-session-send-policy.ts` decides whether the next turn sends a full bootstrap prompt or an incremental follow-up, whether the SDK agent must be recreated, and why. `computeCursorContextFingerprint()` and `shouldBootstrapCursorContext()` remain the context-only bootstrap signal. The pool recreates the agent when context diverges, when branch or compaction summaries appear after `/tree` navigation or compaction, after 20 completed incremental sends, when the API key identity changes, after send errors, on `session_shutdown`, and when `session_before_tree` / `session_tree` invalidate the active branch. Incremental sends omit the full Cursor SDK tool boundary block because the session agent retains prior bootstrap context, but every send ends with a short tool tail guard placed after the latest user request (including an explicit shell `cd` hint).
|
|
42
42
|
- Pi steering/follow-up delivery can arrive while a split live Cursor SDK run is still active. The provider resolves pending live runs by scanning trailing `toolResult` messages while skipping trailing `user` messages, tracks the active live run per session scope, and resumes the in-flight run instead of calling `Agent.send()` again. When the context ends with steering user text after tool results, the provider releases the prior live run and chains an incremental `Agent.send()` for the latest user message in the same provider turn; if the prior run emits more text or tool requests after steering arrives, that stale activity is cancelled instead of surfacing another old-run tool turn and losing the new user input. A pre-send guard waits for or resumes any still-active scoped live run before starting a fresh send so `@cursor/sdk` `AgentBusyError` (`already has active run`) does not surface to pi users. Pooled session agents mark busy as soon as live/direct `run.wait()` tracking starts (`trackRunCompletion` on the session lease), and `acquireSessionCursorAgent()` awaits that busy state before returning a lease so send planning, transcript offsets, and later `Agent.send()` do not race the prior turn's SDK run completion (for example pi auto-compaction summarization). `session_before_compact` calls `prepareCursorSessionForCompaction()` to release scoped live-run drain state and reset the pooled agent before summarization streams. Tracked completions and send commits are scoped to the pooled agent `instanceId` so disposal/replacement drops stale tracking and ignores late commits from disposed agents.
|
|
43
43
|
|
|
@@ -382,7 +382,7 @@ cursor fast
|
|
|
382
382
|
|
|
383
383
|
## Cursor SDK Mode Behavior
|
|
384
384
|
|
|
385
|
-
Cursor SDK 1.0.
|
|
385
|
+
Cursor SDK 1.0.16 exposes SDK-native conversation mode:
|
|
386
386
|
|
|
387
387
|
```ts
|
|
388
388
|
type AgentModeOption = "agent" | "plan";
|
|
@@ -62,13 +62,13 @@ When Cursor reports completed tool activity, the extension can display recorded
|
|
|
62
62
|
|
|
63
63
|
Cursor `glob` activity is displayed through native `find` cards.
|
|
64
64
|
|
|
65
|
-
For the full `@cursor/sdk@1.0.
|
|
65
|
+
For the full `@cursor/sdk@1.0.16` `ToolType` set, disposition matrix, and runtime alias normalization, see [SDK ToolType replay matrix](#sdk-tooltype-replay-matrix) below. Official SDK reference: https://cursor.com/docs/sdk/typescript
|
|
66
66
|
|
|
67
67
|
Edit and write activity replays through pi-facing `edit` and `write` cards only when replay arguments truthfully satisfy the matching pi schema, but still uses recorded Cursor results only. The adapter passes through truthful Cursor paths, content when Cursor reported it, and recorded diff/details; it does not pretend Cursor's editing schema is pi's schema and it fails closed if a recorded replay result is missing. Cursor `StrReplace` with recorded replacement text displays as native-looking `edit`; path-only Cursor `edit` and notebook edit activity fall back to neutral Cursor activity so pi does not reject the replay before recorded-result handling. Cursor `write` displays as native-looking `write`. Diagnostics, delete, todos/plans, task, image, MCP, semantic search, screen recording, and web search/fetch activity use neutral Cursor activity cards with pi's default success/error tool shell. MCP completions whose `toolName` is `WebSearch` / `web_search` / `WebFetch` / similar are labeled **Cursor web search** or **Cursor web fetch** instead of generic **Cursor MCP**. Neutral Cursor activity cards carry display metadata such as `activityTitle` and `activitySummary`, so partial/collapsed cards can say `Cursor plan`, `Cursor todos`, `Cursor MCP`, `Cursor semantic search`, `Cursor screen recording`, `Cursor web search`, `Cursor web fetch`, or `Cursor edit` instead of only `Cursor activity`. These replay tools only display recorded Cursor results; they never mutate files or execute tool work directly. Replay paths are normalized to workspace-relative paths when possible. Most collapsed replay cards include bounded previews for diffs and text details so small edits, todos, task output, and MCP results are visible without expanding; web search/fetch activity stays summary-only while collapsed because those cards often arrive after final text and can otherwise bury the answer. Ctrl+O expansion shows the recorded details. Edit previews omit raw unified diff headers and show compact numbered changed/context lines using pi's native diff added/removed/context colors, and write previews use syntax highlighting when pi can infer a language from the path. Image generation replay cards show the saved image path in the collapsed summary and render the image inline when pi terminal image display is enabled and the generated file is still readable.
|
|
68
68
|
|
|
69
69
|
## SDK ToolType replay matrix
|
|
70
70
|
|
|
71
|
-
Source of truth for SDK tool names: `@cursor/sdk@1.0.
|
|
71
|
+
Source of truth for SDK tool names: `@cursor/sdk@1.0.16` conversation `ToolType` values and https://cursor.com/docs/sdk/typescript
|
|
72
72
|
|
|
73
73
|
Implementation owners: `src/cursor-tool-presentation-registry.ts` (canonical names, labels, visibility, replay policy, bridge exclusions for internal replay wrappers, and display-spec key completeness), `src/cursor-transcript-tool-specs.ts` (registry-keyed `TOOL_DISPLAY_SPECS` formatters/builders), `src/cursor-native-tool-display-replay.ts` (replay card rendering derived from registry replay metadata), and `src/cursor-transcript-utils.ts` (`normalizeToolName()` delegating to the registry).
|
|
74
74
|
|
|
@@ -4,19 +4,19 @@ This workflow is the canonical repo path for verifying Cursor SDK tool replay th
|
|
|
4
4
|
|
|
5
5
|
Use it before accepting replay-card commits or PRs, and for every Cursor provider/runtime release where TUI card/color behavior could regress. Text logs and JSONL are necessary, but they are not enough when the claim is visual parity: always keep PNGs for the exact prompt, and keep before/after PNGs when reviewing a rendering change.
|
|
6
6
|
|
|
7
|
-
Current validation baseline: pi 0.77.0, exact `@cursor/sdk@1.0.
|
|
7
|
+
Current validation baseline: pi 0.77.0, exact `@cursor/sdk@1.0.16`, local validation packages `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` at 0.77.0. Published peer dependencies remain minimum-only at pi 0.76.0+ with no upper bound, so newer pi installs can try the extension before a matching validation release exists.
|
|
8
8
|
|
|
9
|
-
## Cursor SDK 1.0.
|
|
9
|
+
## Cursor SDK 1.0.16 / pi 0.77.0 cutover visual record
|
|
10
10
|
|
|
11
11
|
Record the required cutover validation here or in the final release handoff. The default matrix is native replay only: the runner forces native replay registration on, forces Cursor setting sources off, disables the pi bridge, disables overlapping built-in pi tool exposure, and clears inherited Cursor SDK event-debug artifact env. With `--event-debug`, debug capture writes to a deterministic directory under the visual output directory. Do not commit raw ANSI logs, screenshots, terminal recordings, debug artifacts, or `.debug/visual-smoke` scratch files.
|
|
12
12
|
|
|
13
13
|
| Field | Required value / evidence |
|
|
14
14
|
| --- | --- |
|
|
15
15
|
| Command/session used | `npm run smoke:visual -- --ext "$PWD" --cwd "$PWD" --mode plan --out-dir <fresh /tmp dir> --label <matrix label> --prompt <matrix prompt>` with default native-replay isolation |
|
|
16
|
-
| Baseline versions | `pi --version` = 0.77.0; `npm ls` = `@cursor/sdk@1.0.
|
|
16
|
+
| Baseline versions | `pi --version` = 0.77.0; `npm ls` = `@cursor/sdk@1.0.16` and local `@earendil-works/*@0.77.0` |
|
|
17
17
|
| Card categories checked | Claim only categories proven by both PNG and JSONL. Required cutover categories are read, grep/search, find/glob, list, shell success, write, edit/diff, and true read failure. Neutral Cursor plan/todo/task/mode activity is optional/opportunistic and only counts when JSONL contains a completed Cursor workflow event. |
|
|
18
18
|
| Observed status/card colors | Confirm native-looking cards use native pi styling; neutral Cursor activity is not red; true errors are distinct; diff previews show red/green; plan status is readable |
|
|
19
|
-
| Screenshot/ANSI evidence location | External path only, for example `/tmp/pi-cursor-sdk-
|
|
19
|
+
| Screenshot/ANSI evidence location | External path only, for example `/tmp/pi-cursor-sdk-1016-visual.*/read-package.{ansi,txt,html,png,jsonl.path}` |
|
|
20
20
|
| Debug artifact location | External `.debug/cursor-sdk-events/...` or temp artifact directory path only; do not commit raw artifacts |
|
|
21
21
|
| Pass/fail notes | Summarize any mismatch, blocker, or auth/environment limitation |
|
|
22
22
|
|
|
@@ -238,7 +238,7 @@ The script writes timestamped artifacts under `--out` (default `/tmp/pi-cursor-s
|
|
|
238
238
|
|
|
239
239
|
Stdout prints artifact paths and summary counts only. Raw payloads stay on disk and may contain local paths, project text, tool args/results, or secrets — do not commit or share them.
|
|
240
240
|
|
|
241
|
-
Hard repo rule: Cursor SDK behavior claims must come from the installed `@cursor/sdk` package and/or https://cursor.com/docs/sdk/typescript, not from memory or ad-hoc probes alone. Current cutover validation targets exact `@cursor/sdk@1.0.
|
|
241
|
+
Hard repo rule: Cursor SDK behavior claims must come from the installed `@cursor/sdk` package and/or https://cursor.com/docs/sdk/typescript, not from memory or ad-hoc probes alone. Current cutover validation targets exact `@cursor/sdk@1.0.16` and pi 0.77.0 local packages.
|
|
242
242
|
|
|
243
243
|
## Pi provider SDK event capture
|
|
244
244
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cursor-sdk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "pi provider extension backed by @cursor/sdk local agents",
|
|
5
5
|
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"debug:mcp-coldstart": "node scripts/probe-mcp-coldstart.mjs"
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@cursor/sdk": "1.0.
|
|
85
|
+
"@cursor/sdk": "1.0.16",
|
|
86
86
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
87
87
|
},
|
|
88
88
|
"peerDependencies": {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AgentMessage } from "@cursor/sdk";
|
|
2
2
|
import { asRecord, getArray, getString, stringifyUnknown } from "./cursor-transcript-utils.js";
|
|
3
|
+
import { loadCursorSdk } from "./cursor-sdk-runtime.js";
|
|
3
4
|
|
|
4
5
|
const CURSOR_AGENT_MESSAGE_PAGE_LIMIT = 8;
|
|
5
6
|
|
|
@@ -21,6 +22,7 @@ function getOneofCaseValue(value: unknown, caseName: string): unknown {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
async function hasCursorAgentMessageAt(agentId: string, cwd: string, offset: number): Promise<boolean> {
|
|
25
|
+
const { Agent } = await loadCursorSdk();
|
|
24
26
|
const messages = await Agent.messages.list(agentId, { runtime: "local", cwd, limit: 1, offset });
|
|
25
27
|
return messages.length > 0;
|
|
26
28
|
}
|
|
@@ -46,6 +48,7 @@ export async function loadCursorTranscriptWebToolCallsAfterOffset(options: {
|
|
|
46
48
|
offset: number | undefined;
|
|
47
49
|
}): Promise<CursorTranscriptCompletedToolCall[]> {
|
|
48
50
|
if (options.offset === undefined) return [];
|
|
51
|
+
const { Agent } = await loadCursorSdk();
|
|
49
52
|
const messages = await Agent.messages.list(options.agentId, {
|
|
50
53
|
runtime: "local",
|
|
51
54
|
cwd: options.cwd,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ListToolsRequestSchema,
|
|
10
10
|
type CallToolResult,
|
|
11
11
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
import { MCP_ENDPOINT_ROOT, MCP_SERVER_NAME } from "./cursor-pi-tool-bridge-constants.js";
|
|
12
13
|
import {
|
|
13
14
|
type CursorPiToolBridgeDiagnosticEvent,
|
|
14
15
|
type CursorPiToolBridgeLifecycleDiagnosticFields,
|
|
@@ -38,8 +39,6 @@ export interface CursorPiToolBridgeRunHost {
|
|
|
38
39
|
unregisterRun(pathname: string, run: CursorPiToolBridgeRunImpl): Promise<void>;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export const MCP_SERVER_NAME = "pi_tools";
|
|
42
|
-
export const MCP_ENDPOINT_ROOT = "/cursor-pi-tool-bridge";
|
|
43
42
|
const MCP_SERVER_VERSION = "0.1.0";
|
|
44
43
|
|
|
45
44
|
interface PendingBridgeCall {
|
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
CursorPiToolBridgeSnapshotApi,
|
|
8
8
|
} from "./cursor-pi-tool-bridge-types.js";
|
|
9
9
|
import { isRecord } from "./cursor-pi-tool-bridge-mcp.js";
|
|
10
|
-
import { CursorPiToolBridgeRunImpl } from "./cursor-pi-tool-bridge-run.js";
|
|
10
|
+
import type { CursorPiToolBridgeRunImpl } from "./cursor-pi-tool-bridge-run.js";
|
|
11
11
|
import {
|
|
12
12
|
buildCursorPiToolBridgeSnapshot,
|
|
13
13
|
buildCursorPiToolBridgeSurfaceSignature,
|
|
@@ -54,6 +54,7 @@ export class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
|
|
|
54
54
|
exposeOverlappingBuiltins: resolveCursorPiToolBridgeBuiltinsEnabled(this.env),
|
|
55
55
|
})
|
|
56
56
|
: createEmptySnapshot();
|
|
57
|
+
const { CursorPiToolBridgeRunImpl } = await import("./cursor-pi-tool-bridge-run.js");
|
|
57
58
|
const run = new CursorPiToolBridgeRunImpl(this, this.env, snapshot, bridgeEnabled && snapshot.tools.length > 0, options);
|
|
58
59
|
this.runs.add(run);
|
|
59
60
|
await run.start();
|
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
resolveCursorPiToolBridgeEnabled,
|
|
14
14
|
} from "./cursor-pi-tool-bridge-snapshot.js";
|
|
15
15
|
import { bridgeToolExecutionAbortTracker } from "./cursor-pi-tool-bridge-abort.js";
|
|
16
|
+
import { MCP_SERVER_NAME } from "./cursor-pi-tool-bridge-constants.js";
|
|
16
17
|
import { LOOPBACK_HOST, CursorPiToolBridgeRegistry } from "./cursor-pi-tool-bridge-server.js";
|
|
17
|
-
import { MCP_SERVER_NAME } from "./cursor-pi-tool-bridge-run.js";
|
|
18
18
|
import type {
|
|
19
19
|
CursorPiToolBridge,
|
|
20
20
|
CursorPiToolBridgeExtensionApi,
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { RunResult } from "@cursor/sdk";
|
|
2
|
+
import { asRecord } from "./cursor-record-utils.js";
|
|
2
3
|
import { scrubSensitiveText } from "./cursor-sensitive-text.js";
|
|
3
4
|
|
|
4
5
|
export const MISSING_CURSOR_API_KEY_MESSAGE =
|
|
5
|
-
"Cursor SDK runs require a Cursor API key. Run /login -> Use an API key -> Cursor, set CURSOR_API_KEY before starting pi, or restart pi with --api-key.";
|
|
6
|
+
"Cursor SDK runs require a Cursor SDK API key. Cursor Agent CLI/Desktop login is not reused. Run /login -> Use an API key -> Cursor, set CURSOR_API_KEY before starting pi, or restart pi with --api-key.";
|
|
6
7
|
const GENERIC_CURSOR_SDK_ERROR_MESSAGE =
|
|
7
|
-
"Cursor SDK request failed. The API key may be missing, invalid, or unauthorized. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
|
|
8
|
+
"Cursor SDK request failed. The Cursor SDK API key may be missing, invalid, or unauthorized. Cursor Agent CLI/Desktop login is not reused. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
|
|
8
9
|
const AUTH_CURSOR_SDK_ERROR_MESSAGE =
|
|
9
|
-
"Cursor SDK request failed because the API key may be invalid or unauthorized. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
|
|
10
|
+
"Cursor SDK request failed because the Cursor SDK API key may be invalid or unauthorized. Cursor Agent CLI/Desktop login is not reused. Run /login -> Use an API key -> Cursor, verify CURSOR_API_KEY, or pass --api-key, then retry.";
|
|
10
11
|
const NETWORK_CURSOR_SDK_ERROR_MESSAGE =
|
|
11
12
|
"Cursor SDK request timed out during network I/O. Check your connection and retry; if this keeps happening, try again later or verify Cursor service availability.";
|
|
12
13
|
|
|
@@ -25,7 +26,78 @@ function isKnownGenericRunFailureText(message: string): boolean {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
function isLikelyAuthError(message: string): boolean {
|
|
28
|
-
return /\b(unauthorized|unauthorised|forbidden|invalid api key|invalid key|authentication|auth|401|403)\b/i.test(message);
|
|
29
|
+
return /\b(unauthenticated|unauthorized|unauthorised|forbidden|invalid api key|invalid key|authentication|auth|401|403)\b/i.test(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getErrorStringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
33
|
+
const value = record?.[key];
|
|
34
|
+
return typeof value === "string" ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getErrorStack(error: unknown, record: Record<string, unknown> | undefined): string {
|
|
38
|
+
return error instanceof Error ? error.stack ?? "" : getErrorStringField(record, "stack") ?? "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isConnectError(error: unknown, record: Record<string, unknown> | undefined): boolean {
|
|
42
|
+
const name = error instanceof Error ? error.name : getErrorStringField(record, "name");
|
|
43
|
+
return name === "ConnectError";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isUnauthenticatedConnectCode(code: unknown): boolean {
|
|
47
|
+
return code === 16 || (typeof code === "string" && /^(?:16|unauthenticated)$/i.test(code));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getCursorConnectSource(error: unknown, record: Record<string, unknown> | undefined): CursorConnectErrorSource {
|
|
51
|
+
const stack = getErrorStack(error, record);
|
|
52
|
+
if (stack.includes("@cursor/sdk")) return "cursor-sdk-stack";
|
|
53
|
+
const details = Array.isArray(record?.details) ? record.details : [];
|
|
54
|
+
const hasCursorBackendDetails = details.some((detail) => {
|
|
55
|
+
const type = getErrorStringField(asRecord(detail), "type");
|
|
56
|
+
return typeof type === "string" && type.startsWith("aiserver.");
|
|
57
|
+
});
|
|
58
|
+
return hasCursorBackendDetails ? "cursor-backend-details" : "generic-connect";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type CursorConnectErrorSource = "cursor-sdk-stack" | "cursor-backend-details" | "generic-connect";
|
|
62
|
+
|
|
63
|
+
export type CursorConnectErrorClassification =
|
|
64
|
+
| { kind: "abort"; source: "cursor-sdk-stack" }
|
|
65
|
+
| { kind: "unauthenticated"; source: CursorConnectErrorSource };
|
|
66
|
+
|
|
67
|
+
export function classifyCursorConnectError(error: unknown): CursorConnectErrorClassification | undefined {
|
|
68
|
+
const record = asRecord(error);
|
|
69
|
+
if (!isConnectError(error, record)) return undefined;
|
|
70
|
+
|
|
71
|
+
const message = error instanceof Error ? error.message : getErrorStringField(record, "message") ?? "";
|
|
72
|
+
const rawMessage = getErrorStringField(record, "rawMessage") ?? message;
|
|
73
|
+
const code = record?.code;
|
|
74
|
+
const cause = asRecord(record?.cause);
|
|
75
|
+
const causeName = getErrorStringField(cause, "name");
|
|
76
|
+
const stack = getErrorStack(error, record);
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
(code === 1 || code === "canceled") &&
|
|
80
|
+
Boolean(rawMessage && /(?:operation was aborted|canceled)/i.test(rawMessage)) &&
|
|
81
|
+
(causeName === "AbortError" || /AbortError/.test(stack)) &&
|
|
82
|
+
stack.includes("@cursor/sdk") &&
|
|
83
|
+
stack.includes("@connectrpc/connect-node")
|
|
84
|
+
) {
|
|
85
|
+
return { kind: "abort", source: "cursor-sdk-stack" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (isUnauthenticatedConnectCode(code) || isLikelyAuthError(`${message}\n${rawMessage}`)) {
|
|
89
|
+
return { kind: "unauthenticated", source: getCursorConnectSource(error, record) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isCursorSdkAbortConnectError(error: unknown): boolean {
|
|
96
|
+
return classifyCursorConnectError(error)?.kind === "abort";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isUnauthenticatedConnectError(error: unknown): boolean {
|
|
100
|
+
return classifyCursorConnectError(error)?.kind === "unauthenticated";
|
|
29
101
|
}
|
|
30
102
|
|
|
31
103
|
function isLikelyNetworkTimeout(message: string): boolean {
|
|
@@ -89,8 +161,8 @@ export function sanitizeCursorProviderError(error: unknown, apiKey?: string): st
|
|
|
89
161
|
const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
90
162
|
if (message === MISSING_CURSOR_API_KEY_MESSAGE) return MISSING_CURSOR_API_KEY_MESSAGE;
|
|
91
163
|
const scrubbed = scrubSensitiveText(message, apiKey).trim();
|
|
164
|
+
if (isUnauthenticatedConnectError(error) || isLikelyAuthError(scrubbed)) return AUTH_CURSOR_SDK_ERROR_MESSAGE;
|
|
92
165
|
if (isGenericErrorMessage(scrubbed)) return GENERIC_CURSOR_SDK_ERROR_MESSAGE;
|
|
93
|
-
if (isLikelyAuthError(scrubbed)) return AUTH_CURSOR_SDK_ERROR_MESSAGE;
|
|
94
166
|
if (isLikelyNetworkTimeout(scrubbed)) return NETWORK_CURSOR_SDK_ERROR_MESSAGE;
|
|
95
167
|
return scrubbed || GENERIC_CURSOR_SDK_ERROR_MESSAGE;
|
|
96
168
|
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./cursor-provider-errors.js";
|
|
14
14
|
import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
|
|
15
15
|
import type { IncompleteCursorToolRunOutcomeInput } from "./cursor-incomplete-tool-visibility.js";
|
|
16
|
-
import type {
|
|
16
|
+
import type { installCursorSdkProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
|
|
17
17
|
import type { CursorSdkEventDebugSink } from "./cursor-sdk-event-debug.js";
|
|
18
18
|
import { awaitFinalizeCursorRunOutcome } from "./cursor-provider-turn-finalize.js";
|
|
19
19
|
import type {
|
|
@@ -63,7 +63,7 @@ export interface CursorLiveRunCompletion {
|
|
|
63
63
|
export interface CursorRunFinalizerParams {
|
|
64
64
|
runnerParams: CursorProviderTurnRunnerParams;
|
|
65
65
|
sdkEventDebug: () => CursorSdkEventDebugSink | undefined;
|
|
66
|
-
|
|
66
|
+
sdkProcessErrorGuard: ReturnType<typeof installCursorSdkProcessErrorGuard>;
|
|
67
67
|
resolvedApiKey: () => string | undefined;
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -145,13 +145,13 @@ export class CursorRunFinalizer {
|
|
|
145
145
|
void liveCompletion.waitCompletion
|
|
146
146
|
.finally(async () => {
|
|
147
147
|
await this.finalizeSdkEventDebugBestEffort();
|
|
148
|
-
this.safeCleanup(() => this.params.
|
|
148
|
+
this.safeCleanup(() => this.params.sdkProcessErrorGuard.dispose());
|
|
149
149
|
})
|
|
150
150
|
.catch(() => {});
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
153
|
await this.finalizeSdkEventDebugBestEffort();
|
|
154
|
-
this.safeCleanup(() => this.params.
|
|
154
|
+
this.safeCleanup(() => this.params.sdkProcessErrorGuard.dispose());
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
private async applyDirectOutcome(
|
|
@@ -195,7 +195,7 @@ export class CursorRunFinalizer {
|
|
|
195
195
|
await abandonSessionCursorAgent(prepared?.sessionAgentScopeKey);
|
|
196
196
|
}
|
|
197
197
|
if (error instanceof CursorLiveRunAbortError) {
|
|
198
|
-
this.params.
|
|
198
|
+
this.params.sdkProcessErrorGuard.suppressAbortErrors();
|
|
199
199
|
this.pushTerminalError(this.params.runnerParams.partial, "aborted", this.abortMessage());
|
|
200
200
|
} else {
|
|
201
201
|
this.pushTerminalError(
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createAgentPlatform } from "@cursor/sdk";
|
|
2
1
|
import type { SDKAgent } from "@cursor/sdk";
|
|
3
2
|
import { loadCursorTranscriptWebToolCallsAfterOffset } from "./cursor-agent-message-web-tools.js";
|
|
4
3
|
import { getCheckpointContextWindow, saveCachedContextWindow } from "./context-window-cache.js";
|
|
@@ -10,10 +9,14 @@ import {
|
|
|
10
9
|
type CursorRunOutcome,
|
|
11
10
|
} from "./cursor-provider-run-outcome.js";
|
|
12
11
|
import type { CursorProviderTurnPrepareResult } from "./cursor-provider-turn-types.js";
|
|
12
|
+
import { loadCursorSdk } from "./cursor-sdk-runtime.js";
|
|
13
13
|
|
|
14
|
-
export async function cacheSdkContextWindow(agentId: string, modelId: string): Promise<void> {
|
|
14
|
+
export async function cacheSdkContextWindow(agentId: string, modelId: string, cwd?: string): Promise<void> {
|
|
15
15
|
try {
|
|
16
|
-
const
|
|
16
|
+
const { createAgentPlatform } = await loadCursorSdk();
|
|
17
|
+
const platform = await createAgentPlatform(
|
|
18
|
+
cwd ? { workspaceRef: cwd, scopedWorkspaceRef: cwd } : undefined,
|
|
19
|
+
);
|
|
17
20
|
const checkpoint = await platform.checkpointStore.loadLatest(agentId);
|
|
18
21
|
const contextWindow = getCheckpointContextWindow(checkpoint);
|
|
19
22
|
if (contextWindow) saveCachedContextWindow(modelId, contextWindow);
|
|
@@ -113,7 +116,7 @@ export async function awaitFinalizeCursorRunOutcome(params: AwaitFinalizeCursorR
|
|
|
113
116
|
params.prepared.runtime.turnCoordinator.discardIncompleteStartedToolCalls(outcome.incompleteTools);
|
|
114
117
|
await params.sdkEventDebug?.captureRunArtifacts(params.run);
|
|
115
118
|
if (params.cacheContextWindow !== false) {
|
|
116
|
-
await cacheSdkContextWindow(params.contextWindowAgentId ?? params.run.agentId, params.modelId);
|
|
119
|
+
await cacheSdkContextWindow(params.contextWindowAgentId ?? params.run.agentId, params.modelId, params.prepared.cwd);
|
|
117
120
|
}
|
|
118
121
|
return outcome;
|
|
119
122
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
2
|
-
import { Agent } from "@cursor/sdk";
|
|
3
2
|
import { installCursorMcpToolTimeoutOverride } from "./cursor-mcp-timeout-override.js";
|
|
4
3
|
import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./cursor-sdk-output-filter.js";
|
|
5
4
|
import {
|
|
@@ -26,6 +25,7 @@ import { isCursorNativeToolDisplayRuntimeEnabled } from "./cursor-native-tool-di
|
|
|
26
25
|
import { MISSING_CURSOR_API_KEY_MESSAGE } from "./cursor-provider-errors.js";
|
|
27
26
|
import { CursorSdkTurnCoordinator } from "./cursor-provider-turn-coordinator.js";
|
|
28
27
|
import { resolveCursorApiKey } from "./cursor-provider-turn-api-key.js";
|
|
28
|
+
import { loadCursorSdk } from "./cursor-sdk-runtime.js";
|
|
29
29
|
import type {
|
|
30
30
|
CursorProviderTurnPrepareResult,
|
|
31
31
|
CursorProviderTurnRunnerParams,
|
|
@@ -56,6 +56,7 @@ export async function prepareCursorProviderTurn(
|
|
|
56
56
|
const agentMode = getEffectiveCursorAgentMode();
|
|
57
57
|
const selection = buildCursorModelSelection(model.id, options?.reasoning ?? "off", fastEnabled);
|
|
58
58
|
const settingSources = getEffectiveCursorSettingSources();
|
|
59
|
+
const { Agent } = await loadCursorSdk();
|
|
59
60
|
|
|
60
61
|
installCursorMcpToolTimeoutOverride();
|
|
61
62
|
restoreCursorSdkOutputFilter = installCursorSdkOutputFilter();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
|
|
2
2
|
import { drainExistingCursorLiveRunBeforeSend } from "./cursor-provider-live-run-drain.js";
|
|
3
3
|
import { getCursorSessionCwd } from "./cursor-session-cwd.js";
|
|
4
|
-
import {
|
|
4
|
+
import { installCursorSdkProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
|
|
5
5
|
import { CursorSdkEventDebugSink } from "./cursor-sdk-event-debug.js";
|
|
6
6
|
import { awaitFinalizeCursorRunOutcome } from "./cursor-provider-turn-finalize.js";
|
|
7
7
|
import {
|
|
@@ -41,7 +41,7 @@ export class CursorProviderTurnRunner {
|
|
|
41
41
|
discardIncompleteToolsFromPrepared(prepared, outcome);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
async run(
|
|
44
|
+
async run(sdkProcessErrorGuard: ReturnType<typeof installCursorSdkProcessErrorGuard>): Promise<void> {
|
|
45
45
|
const { stream, partial, model, context, options, sdkEventDebugRef } = this.params;
|
|
46
46
|
let prepared: CursorProviderTurnPrepareResult | undefined;
|
|
47
47
|
let sendResult: CursorProviderTurnSendResult | undefined;
|
|
@@ -49,7 +49,7 @@ export class CursorProviderTurnRunner {
|
|
|
49
49
|
const runFinalizer = new CursorRunFinalizer({
|
|
50
50
|
runnerParams: this.params,
|
|
51
51
|
sdkEventDebug: () => this.sdkEventDebug,
|
|
52
|
-
|
|
52
|
+
sdkProcessErrorGuard,
|
|
53
53
|
resolvedApiKey: () => this.resolvedApiKey,
|
|
54
54
|
});
|
|
55
55
|
|
|
@@ -84,7 +84,7 @@ export class CursorProviderTurnRunner {
|
|
|
84
84
|
params: this.params,
|
|
85
85
|
prepared,
|
|
86
86
|
sdkEventDebug: this.sdkEventDebug,
|
|
87
|
-
|
|
87
|
+
sdkProcessErrorGuard,
|
|
88
88
|
throwIfAborted: () => this.throwIfAborted(),
|
|
89
89
|
});
|
|
90
90
|
const { send } = sendResult;
|
|
@@ -131,7 +131,7 @@ export class CursorProviderTurnRunner {
|
|
|
131
131
|
const runFinalizer = new CursorRunFinalizer({
|
|
132
132
|
runnerParams: this.params,
|
|
133
133
|
sdkEventDebug: () => this.sdkEventDebug,
|
|
134
|
-
|
|
134
|
+
sdkProcessErrorGuard: installCursorSdkProcessErrorGuard(),
|
|
135
135
|
resolvedApiKey: () => this.resolvedApiKey,
|
|
136
136
|
});
|
|
137
137
|
await runFinalizer.applyTerminalEvent({ kind: "error", prepared: undefined, error });
|
|
@@ -2,7 +2,7 @@ import type { SendOptions } from "@cursor/sdk";
|
|
|
2
2
|
import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
|
|
3
3
|
import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
|
|
4
4
|
import { getCursorAgentMessageOffset } from "./cursor-provider-turn-message-offset.js";
|
|
5
|
-
import type {
|
|
5
|
+
import type { installCursorSdkProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
|
|
6
6
|
import type {
|
|
7
7
|
CursorProviderTurnRunnerParams,
|
|
8
8
|
CursorProviderTurnPrepareResult,
|
|
@@ -14,12 +14,12 @@ export interface SendCursorProviderTurnParams {
|
|
|
14
14
|
params: CursorProviderTurnRunnerParams;
|
|
15
15
|
prepared: CursorProviderTurnPrepareResult;
|
|
16
16
|
sdkEventDebug: CursorSdkEventDebugSink | undefined;
|
|
17
|
-
|
|
17
|
+
sdkProcessErrorGuard: ReturnType<typeof installCursorSdkProcessErrorGuard>;
|
|
18
18
|
throwIfAborted: () => void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export async function sendCursorProviderTurn(sendParams: SendCursorProviderTurnParams): Promise<CursorProviderTurnSendResult> {
|
|
22
|
-
const { params, prepared, sdkEventDebug,
|
|
22
|
+
const { params, prepared, sdkEventDebug, sdkProcessErrorGuard, throwIfAborted } = sendParams;
|
|
23
23
|
const { options } = params;
|
|
24
24
|
const { agent, cwd, payload, meta, runtime } = prepared;
|
|
25
25
|
const { turnCoordinator, liveRun } = runtime;
|
|
@@ -27,7 +27,7 @@ export async function sendCursorProviderTurn(sendParams: SendCursorProviderTurnP
|
|
|
27
27
|
let completed = false;
|
|
28
28
|
let sdkRun: Awaited<ReturnType<typeof agent.send>> | null = null;
|
|
29
29
|
const abortListener = () => {
|
|
30
|
-
|
|
30
|
+
sdkProcessErrorGuard.suppressAbortErrors();
|
|
31
31
|
liveRun?.bridgeRun?.cancel("Cursor SDK run aborted");
|
|
32
32
|
if (sdkRun) {
|
|
33
33
|
sdkRun.cancel().catch(() => {});
|
|
@@ -84,7 +84,7 @@ export async function sendCursorProviderTurn(sendParams: SendCursorProviderTurnP
|
|
|
84
84
|
});
|
|
85
85
|
if (liveRun) cursorLiveRuns.attachSdkRun(liveRun, run);
|
|
86
86
|
if (options?.signal?.aborted) {
|
|
87
|
-
|
|
87
|
+
sdkProcessErrorGuard.suppressAbortErrors();
|
|
88
88
|
liveRun?.bridgeRun?.cancel("Cursor SDK run aborted");
|
|
89
89
|
await run.cancel().catch(() => {});
|
|
90
90
|
throw new CursorLiveRunAbortError();
|
package/src/cursor-provider.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { cursorLiveRuns } from "./cursor-provider-live-run-drain.js";
|
|
19
19
|
import { disposeAllSessionCursorAgents } from "./cursor-session-agent.js";
|
|
20
20
|
import { attachCursorSdkEventDebugPiStreamTap, type CursorSdkEventDebugSink } from "./cursor-sdk-event-debug.js";
|
|
21
|
-
import {
|
|
21
|
+
import { installCursorSdkProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
|
|
22
22
|
import { sanitizeCursorProviderError } from "./cursor-provider-errors.js";
|
|
23
23
|
import { CursorProviderTurnRunner, resolveCursorApiKey } from "./cursor-provider-turn-runner.js";
|
|
24
24
|
|
|
@@ -53,7 +53,7 @@ export function streamCursor(
|
|
|
53
53
|
|
|
54
54
|
(async () => {
|
|
55
55
|
const partial = makeInitialMessage(model);
|
|
56
|
-
const
|
|
56
|
+
const sdkProcessErrorGuard = installCursorSdkProcessErrorGuard();
|
|
57
57
|
|
|
58
58
|
const runner = new CursorProviderTurnRunner({
|
|
59
59
|
model,
|
|
@@ -65,7 +65,7 @@ export function streamCursor(
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
try {
|
|
68
|
-
await runner.run(
|
|
68
|
+
await runner.run(sdkProcessErrorGuard);
|
|
69
69
|
} catch (error) {
|
|
70
70
|
await runner.handleOuterCatch(error);
|
|
71
71
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { classifyCursorConnectError, isCursorSdkAbortConnectError } from "./cursor-provider-errors.js";
|
|
2
|
+
|
|
3
|
+
interface CursorSdkProcessErrorGuardToken {
|
|
4
|
+
suppressAbortErrors: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CursorSdkProcessErrorGuard {
|
|
8
|
+
suppressAbortErrors(): void;
|
|
9
|
+
dispose(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type GenericProcessEmit = (event: string | symbol, ...args: unknown[]) => boolean;
|
|
13
|
+
|
|
14
|
+
// The local Cursor SDK can surface some ConnectRPC failures as process-level
|
|
15
|
+
// uncaught exceptions/unhandled rejections even when run.wait()/run.cancel() is awaited.
|
|
16
|
+
// Keep suppression scoped to active Cursor provider turns and tightly matched SDK shapes.
|
|
17
|
+
const activeProviderTurns = new Set<CursorSdkProcessErrorGuardToken>();
|
|
18
|
+
let originalProcessEmit: GenericProcessEmit | undefined;
|
|
19
|
+
let captureCallbackInstalled = false;
|
|
20
|
+
|
|
21
|
+
function hasActiveAbortSuppression(): boolean {
|
|
22
|
+
for (const turn of activeProviderTurns) {
|
|
23
|
+
if (turn.suppressAbortErrors) return true;
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isCursorProvenance(source: string): boolean {
|
|
29
|
+
return source === "cursor-sdk-stack" || source === "cursor-backend-details";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shouldSuppressProcessError(event: string | symbol, args: readonly unknown[]): boolean {
|
|
33
|
+
if (event !== "uncaughtException" && event !== "unhandledRejection") return false;
|
|
34
|
+
const error = args[0];
|
|
35
|
+
const classification = classifyCursorConnectError(error);
|
|
36
|
+
if (!classification) return false;
|
|
37
|
+
if (classification.kind === "abort") return hasActiveAbortSuppression();
|
|
38
|
+
return activeProviderTurns.size > 0 && isCursorProvenance(classification.source);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function installProcessEmitPatch(): void {
|
|
42
|
+
if (originalProcessEmit) return;
|
|
43
|
+
originalProcessEmit = process.emit.bind(process) as GenericProcessEmit;
|
|
44
|
+
process.emit = function patchedCursorSdkProcessErrorEmit(this: NodeJS.Process, event: string | symbol, ...args: unknown[]): boolean {
|
|
45
|
+
if (shouldSuppressProcessError(event, args)) return true;
|
|
46
|
+
return originalProcessEmit!(event, ...args);
|
|
47
|
+
} as typeof process.emit;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function installCaptureCallbackIfAvailable(): void {
|
|
51
|
+
if (captureCallbackInstalled || process.hasUncaughtExceptionCaptureCallback()) return;
|
|
52
|
+
process.setUncaughtExceptionCaptureCallback((error: Error) => {
|
|
53
|
+
if (shouldSuppressProcessError("uncaughtException", [error])) return;
|
|
54
|
+
uninstallCaptureCallbackIfIdle(true);
|
|
55
|
+
if (originalProcessEmit?.("uncaughtException", error)) return;
|
|
56
|
+
throw error;
|
|
57
|
+
});
|
|
58
|
+
captureCallbackInstalled = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function uninstallCaptureCallbackIfIdle(force = false): void {
|
|
62
|
+
if (!captureCallbackInstalled) return;
|
|
63
|
+
if (!force && activeProviderTurns.size > 0) return;
|
|
64
|
+
process.setUncaughtExceptionCaptureCallback(null);
|
|
65
|
+
captureCallbackInstalled = false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function uninstallProcessEmitPatchIfIdle(): void {
|
|
69
|
+
if (activeProviderTurns.size > 0 || !originalProcessEmit) return;
|
|
70
|
+
uninstallCaptureCallbackIfIdle();
|
|
71
|
+
process.emit = originalProcessEmit as typeof process.emit;
|
|
72
|
+
originalProcessEmit = undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const __testUtils = {
|
|
76
|
+
activeProviderTurnCount: (): number => activeProviderTurns.size,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export { isCursorSdkAbortConnectError };
|
|
80
|
+
|
|
81
|
+
export function installCursorSdkProcessErrorGuard(): CursorSdkProcessErrorGuard {
|
|
82
|
+
installProcessEmitPatch();
|
|
83
|
+
installCaptureCallbackIfAvailable();
|
|
84
|
+
const token: CursorSdkProcessErrorGuardToken = { suppressAbortErrors: false };
|
|
85
|
+
activeProviderTurns.add(token);
|
|
86
|
+
let disposed = false;
|
|
87
|
+
return {
|
|
88
|
+
suppressAbortErrors(): void {
|
|
89
|
+
if (disposed) return;
|
|
90
|
+
token.suppressAbortErrors = true;
|
|
91
|
+
},
|
|
92
|
+
dispose(): void {
|
|
93
|
+
if (disposed) return;
|
|
94
|
+
disposed = true;
|
|
95
|
+
activeProviderTurns.delete(token);
|
|
96
|
+
uninstallProcessEmitPatchIfIdle();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
SessionTreeEvent,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
-
import { Agent } from "@cursor/sdk";
|
|
10
9
|
import type { AgentModeOption, ModelSelection, SDKAgent, SettingSource } from "@cursor/sdk";
|
|
11
10
|
import type { Context } from "@earendil-works/pi-ai";
|
|
12
11
|
import {
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
import { computeCursorContextFingerprint } from "./context.js";
|
|
18
17
|
import { getCursorSessionScopeKey, onCursorSessionScopeKeyChange } from "./cursor-session-scope.js";
|
|
19
18
|
import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
|
|
19
|
+
import { loadCursorSdk, type CursorSdkModule } from "./cursor-sdk-runtime.js";
|
|
20
20
|
|
|
21
21
|
export interface SessionCursorAgentSendState {
|
|
22
22
|
bootstrapped: boolean;
|
|
@@ -109,7 +109,7 @@ interface SessionCursorAgentCreateParams {
|
|
|
109
109
|
settingSources?: SettingSource[];
|
|
110
110
|
onBridgeToolRequest?: (request: CursorPiBridgeToolRequest) => void;
|
|
111
111
|
debugRecorder?: CursorSdkEventDebugRecorder;
|
|
112
|
-
createAgent?:
|
|
112
|
+
createAgent?: CursorSdkModule["Agent"]["create"];
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
interface CursorSessionAgentExtensionApi {
|
|
@@ -377,7 +377,7 @@ async function createSessionAgentEntry(
|
|
|
377
377
|
}
|
|
378
378
|
|
|
379
379
|
const resolvedPoolKey = buildSessionAgentPoolKey(scopeKey, params);
|
|
380
|
-
const createAgent = params.createAgent ?? Agent.create;
|
|
380
|
+
const createAgent = params.createAgent ?? (await loadCursorSdk()).Agent.create;
|
|
381
381
|
let agent: SDKAgent;
|
|
382
382
|
try {
|
|
383
383
|
agent = await createAgent({
|
|
@@ -4,7 +4,7 @@ import type { CursorPiToolBridgeSnapshot } from "./cursor-pi-tool-bridge-types.j
|
|
|
4
4
|
export const CURSOR_TOOL_MANIFEST_ENV = "PI_CURSOR_TOOL_MANIFEST";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Representative @cursor/sdk@1.0.
|
|
7
|
+
* Representative @cursor/sdk@1.0.16 local-agent ToolType values; actual exposure can vary by run.
|
|
8
8
|
* See docs/cursor-native-tool-replay.md#sdk-tooltype-replay-matrix.
|
|
9
9
|
*/
|
|
10
10
|
export const CURSOR_HOST_TOOL_MANIFEST_SUMMARY =
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,7 @@ export default async function (pi: CursorExtensionApi) {
|
|
|
67
67
|
handler: async (_args, ctx) => {
|
|
68
68
|
let refreshFallbackIssue: CursorModelFallbackIssue | undefined;
|
|
69
69
|
const refreshedModels = await discoverModels({
|
|
70
|
+
forceRefresh: true,
|
|
70
71
|
onFallback: (issue) => {
|
|
71
72
|
refreshFallbackIssue = issue;
|
|
72
73
|
},
|
|
@@ -74,7 +75,7 @@ export default async function (pi: CursorExtensionApi) {
|
|
|
74
75
|
registerCursorProvider(pi, refreshedModels);
|
|
75
76
|
if (!ctx.hasUI) return;
|
|
76
77
|
if (refreshFallbackIssue) {
|
|
77
|
-
ctx.ui.notify(`Cursor model catalog refresh
|
|
78
|
+
ctx.ui.notify(`Cursor model catalog refresh did not use a live catalog: ${refreshFallbackIssue.message}`, "warning");
|
|
78
79
|
} else {
|
|
79
80
|
ctx.ui.notify(`Cursor model catalog refreshed with ${refreshedModels.length} model${refreshedModels.length === 1 ? "" : "s"}.`, "info");
|
|
80
81
|
}
|
package/src/model-discovery.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Cursor } from "@cursor/sdk";
|
|
2
1
|
import type {
|
|
3
2
|
ModelListItem,
|
|
4
3
|
ModelParameterDefinition,
|
|
@@ -8,19 +7,26 @@ import type {
|
|
|
8
7
|
import { AuthStorage, type ProviderModelConfig } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
import type { ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
10
9
|
import { loadContextWindowCache } from "./context-window-cache.js";
|
|
10
|
+
import { loadCursorSdk } from "./cursor-sdk-runtime.js";
|
|
11
11
|
import { CURSOR_API_KEY_ENV_VAR, resolveCursorApiKey } from "./cursor-api-key.js";
|
|
12
12
|
import { FALLBACK_MODEL_ITEMS } from "./cursor-fallback-models.generated.js";
|
|
13
|
+
import {
|
|
14
|
+
fingerprintApiKey,
|
|
15
|
+
loadAnyCachedModelCatalog,
|
|
16
|
+
loadFreshCachedModels,
|
|
17
|
+
saveModelListCache,
|
|
18
|
+
} from "./model-list-cache.js";
|
|
13
19
|
|
|
14
20
|
const CURSOR_PROVIDER_ID = "cursor";
|
|
15
21
|
const FALLBACK_CONTEXT_WINDOW = 128000;
|
|
16
22
|
const FALLBACK_MAX_TOKENS = 16384;
|
|
17
23
|
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
18
24
|
const TEXT_AND_IMAGE_INPUT: ProviderModelConfig["input"] = ["text", "image"];
|
|
19
|
-
const AUTH_SETUP_HINT = "/login (Use an API key -> Cursor), CURSOR_API_KEY, or --api-key";
|
|
25
|
+
const AUTH_SETUP_HINT = "/login (Use an API key -> Cursor), CURSOR_API_KEY, or --api-key with a Cursor SDK API key; Cursor Agent CLI/Desktop login is not reused";
|
|
20
26
|
const CATALOG_REFRESH_HINT =
|
|
21
27
|
"After adding auth to an already-started pi session, run /cursor-refresh-models to refresh the full live Cursor model catalog without restarting pi.";
|
|
22
28
|
|
|
23
|
-
export type CursorModelFallbackReason = "missing-api-key" | "discovery-failed" | "empty-model-list";
|
|
29
|
+
export type CursorModelFallbackReason = "missing-api-key" | "discovery-failed" | "empty-model-list" | "cached-after-error";
|
|
24
30
|
|
|
25
31
|
export interface CursorModelFallbackIssue {
|
|
26
32
|
reason: CursorModelFallbackReason;
|
|
@@ -30,6 +36,10 @@ export interface CursorModelFallbackIssue {
|
|
|
30
36
|
|
|
31
37
|
export interface DiscoverModelsOptions {
|
|
32
38
|
onFallback?: (issue: CursorModelFallbackIssue) => void;
|
|
39
|
+
// Bypass the on-disk model cache and always hit the live catalog. Used by the
|
|
40
|
+
// /cursor-refresh-models command; the startup path leaves this false so warm
|
|
41
|
+
// boots skip the slow network round-trip.
|
|
42
|
+
forceRefresh?: boolean;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
function getCliApiKeyFromArgv(argv: string[] = process.argv): string | undefined {
|
|
@@ -442,9 +452,20 @@ export async function discoverModels(options: DiscoverModelsOptions = {}): Promi
|
|
|
442
452
|
});
|
|
443
453
|
}
|
|
444
454
|
|
|
455
|
+
const keyFingerprint = fingerprintApiKey(apiKey);
|
|
456
|
+
|
|
457
|
+
if (!options.forceRefresh) {
|
|
458
|
+
const cachedModels = loadFreshCachedModels(keyFingerprint);
|
|
459
|
+
if (cachedModels && cachedModels.length > 0) {
|
|
460
|
+
return registerModelItems(cachedModels);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
445
464
|
try {
|
|
465
|
+
const { Cursor } = await loadCursorSdk();
|
|
446
466
|
const models = await Cursor.models.list({ apiKey });
|
|
447
467
|
if (models.length > 0) {
|
|
468
|
+
saveModelListCache(keyFingerprint, models);
|
|
448
469
|
return registerModelItems(models);
|
|
449
470
|
}
|
|
450
471
|
return useFallbackModels(options, {
|
|
@@ -453,6 +474,18 @@ export async function discoverModels(options: DiscoverModelsOptions = {}): Promi
|
|
|
453
474
|
});
|
|
454
475
|
} catch (error) {
|
|
455
476
|
const errorMessage = sanitizeDiscoveryError(error, apiKey);
|
|
477
|
+
// Prefer a previously cached catalog over the generic bundled fallback when
|
|
478
|
+
// a live refresh fails (e.g. transient network/auth errors), but keep the
|
|
479
|
+
// provenance visible so refresh commands do not claim a live refresh worked.
|
|
480
|
+
const cachedCatalog = loadAnyCachedModelCatalog(keyFingerprint);
|
|
481
|
+
if (cachedCatalog && cachedCatalog.models.length > 0) {
|
|
482
|
+
options.onFallback?.({
|
|
483
|
+
reason: "cached-after-error",
|
|
484
|
+
message: `Cursor model discovery failed; using cached Cursor model catalog from ${new Date(cachedCatalog.fetchedAt).toISOString()}. ${errorMessage}`,
|
|
485
|
+
errorMessage,
|
|
486
|
+
});
|
|
487
|
+
return registerModelItems(cachedCatalog.models);
|
|
488
|
+
}
|
|
456
489
|
return useFallbackModels(options, {
|
|
457
490
|
reason: "discovery-failed",
|
|
458
491
|
message: `Cursor model discovery failed${errorMessage ? `: ${errorMessage}` : ""}. Using fallback Cursor models; verify ${AUTH_SETUP_HINT}. ${CATALOG_REFRESH_HINT}`,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ModelListItem } from "@cursor/sdk";
|
|
6
|
+
import { parseEnvBoolean } from "./cursor-env-boolean.js";
|
|
7
|
+
|
|
8
|
+
const MODEL_LIST_CACHE_FILE = "cursor-sdk-model-list.json";
|
|
9
|
+
const MODEL_LIST_CACHE_VERSION = 1;
|
|
10
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
const DISABLE_ENV_VAR = "PI_CURSOR_SDK_DISABLE_MODEL_CACHE";
|
|
12
|
+
const TTL_ENV_VAR = "PI_CURSOR_SDK_MODEL_CACHE_TTL_MS";
|
|
13
|
+
|
|
14
|
+
interface ModelListCacheFile {
|
|
15
|
+
version: number;
|
|
16
|
+
fetchedAt: number;
|
|
17
|
+
keyFingerprint: string;
|
|
18
|
+
models: ModelListItem[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CachedModelList {
|
|
22
|
+
fetchedAt: number;
|
|
23
|
+
models: ModelListItem[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getCachePath(): string {
|
|
27
|
+
return join(getAgentDir(), MODEL_LIST_CACHE_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isModelCacheDisabled(): boolean {
|
|
31
|
+
return parseEnvBoolean(process.env[DISABLE_ENV_VAR], false);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getModelCacheTtlMs(): number {
|
|
35
|
+
const raw = process.env[TTL_ENV_VAR];
|
|
36
|
+
if (raw === undefined) return DEFAULT_TTL_MS;
|
|
37
|
+
const parsed = Number.parseInt(raw, 10);
|
|
38
|
+
if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_TTL_MS;
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fingerprint the API key so a key change invalidates the cache, without ever
|
|
43
|
+
// persisting the key itself.
|
|
44
|
+
export function fingerprintApiKey(apiKey: string): string {
|
|
45
|
+
return createHash("sha256").update(apiKey).digest("hex").slice(0, 16);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readCacheFile(): ModelListCacheFile | undefined {
|
|
49
|
+
const path = getCachePath();
|
|
50
|
+
if (!existsSync(path)) return undefined;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as ModelListCacheFile;
|
|
53
|
+
if (
|
|
54
|
+
parsed.version !== MODEL_LIST_CACHE_VERSION ||
|
|
55
|
+
typeof parsed.fetchedAt !== "number" ||
|
|
56
|
+
typeof parsed.keyFingerprint !== "string" ||
|
|
57
|
+
!Array.isArray(parsed.models)
|
|
58
|
+
) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Return cached models only when caching is enabled, the key matches, and the
|
|
68
|
+
// entry is within the TTL. Used on the hot startup path to skip the network.
|
|
69
|
+
export function loadFreshCachedModels(keyFingerprint: string, now: number = Date.now()): ModelListItem[] | undefined {
|
|
70
|
+
if (isModelCacheDisabled()) return undefined;
|
|
71
|
+
const ttlMs = getModelCacheTtlMs();
|
|
72
|
+
if (ttlMs <= 0) return undefined;
|
|
73
|
+
const cache = readCacheFile();
|
|
74
|
+
if (!cache || cache.keyFingerprint !== keyFingerprint) return undefined;
|
|
75
|
+
if (now - cache.fetchedAt > ttlMs) return undefined;
|
|
76
|
+
return cache.models;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Return cached models regardless of age, as long as the key matches. Used as a
|
|
80
|
+
// resilience fallback when a live discovery request fails.
|
|
81
|
+
export function loadAnyCachedModelCatalog(keyFingerprint: string): CachedModelList | undefined {
|
|
82
|
+
if (isModelCacheDisabled()) return undefined;
|
|
83
|
+
const cache = readCacheFile();
|
|
84
|
+
if (!cache || cache.keyFingerprint !== keyFingerprint) return undefined;
|
|
85
|
+
return { fetchedAt: cache.fetchedAt, models: cache.models };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function loadAnyCachedModels(keyFingerprint: string): ModelListItem[] | undefined {
|
|
89
|
+
return loadAnyCachedModelCatalog(keyFingerprint)?.models;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function saveModelListCache(keyFingerprint: string, models: ModelListItem[]): boolean {
|
|
93
|
+
if (isModelCacheDisabled()) return false;
|
|
94
|
+
try {
|
|
95
|
+
const path = getCachePath();
|
|
96
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
97
|
+
const data: ModelListCacheFile = {
|
|
98
|
+
version: MODEL_LIST_CACHE_VERSION,
|
|
99
|
+
fetchedAt: Date.now(),
|
|
100
|
+
keyFingerprint,
|
|
101
|
+
models,
|
|
102
|
+
};
|
|
103
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
104
|
+
chmodSync(path, 0o600);
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const __testUtils = {
|
|
112
|
+
getCachePath,
|
|
113
|
+
DEFAULT_TTL_MS,
|
|
114
|
+
DISABLE_ENV_VAR,
|
|
115
|
+
TTL_ENV_VAR,
|
|
116
|
+
};
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import { asRecord } from "./cursor-record-utils.js";
|
|
2
|
-
|
|
3
|
-
interface CursorSdkAbortErrorSuppressionToken {
|
|
4
|
-
suppress: boolean;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export interface CursorSdkAbortErrorSuppression {
|
|
8
|
-
suppressAbortErrors(): void;
|
|
9
|
-
dispose(): void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function getString(record: Record<string, unknown> | undefined, key: string): string | undefined {
|
|
13
|
-
const value = record?.[key];
|
|
14
|
-
return typeof value === "string" ? value : undefined;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
type GenericProcessEmit = (event: string | symbol, ...args: unknown[]) => boolean;
|
|
18
|
-
|
|
19
|
-
// The local Cursor SDK can surface abort-time ConnectRPC cancellation as a process-level
|
|
20
|
-
// uncaught exception/unhandled rejection even when run.cancel() is awaited/caught.
|
|
21
|
-
const activeSuppressions = new Set<CursorSdkAbortErrorSuppressionToken>();
|
|
22
|
-
let originalProcessEmit: GenericProcessEmit | undefined;
|
|
23
|
-
let captureCallbackInstalled = false;
|
|
24
|
-
|
|
25
|
-
export function isCursorSdkAbortConnectError(error: unknown): boolean {
|
|
26
|
-
const record = asRecord(error);
|
|
27
|
-
const name = error instanceof Error ? error.name : getString(record, "name");
|
|
28
|
-
const message = error instanceof Error ? error.message : getString(record, "message");
|
|
29
|
-
const rawMessage = getString(record, "rawMessage") ?? message;
|
|
30
|
-
const code = record?.code;
|
|
31
|
-
const cause = asRecord(record?.cause);
|
|
32
|
-
const causeName = getString(cause, "name");
|
|
33
|
-
const stack = error instanceof Error ? error.stack ?? "" : getString(record, "stack") ?? "";
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
name === "ConnectError" &&
|
|
37
|
-
(code === 1 || code === "canceled") &&
|
|
38
|
-
Boolean(rawMessage && /(?:operation was aborted|canceled)/i.test(rawMessage)) &&
|
|
39
|
-
(causeName === "AbortError" || /AbortError/.test(stack)) &&
|
|
40
|
-
stack.includes("@cursor/sdk") &&
|
|
41
|
-
stack.includes("@connectrpc/connect-node")
|
|
42
|
-
);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function hasActiveSuppression(): boolean {
|
|
46
|
-
for (const suppression of activeSuppressions) {
|
|
47
|
-
if (suppression.suppress) return true;
|
|
48
|
-
}
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function shouldSuppressProcessError(event: string | symbol, args: readonly unknown[]): boolean {
|
|
53
|
-
if (event !== "uncaughtException" && event !== "unhandledRejection") return false;
|
|
54
|
-
return hasActiveSuppression() && isCursorSdkAbortConnectError(args[0]);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function installProcessEmitPatch(): void {
|
|
58
|
-
if (originalProcessEmit) return;
|
|
59
|
-
originalProcessEmit = process.emit.bind(process) as GenericProcessEmit;
|
|
60
|
-
process.emit = function patchedCursorSdkAbortEmit(this: NodeJS.Process, event: string | symbol, ...args: unknown[]): boolean {
|
|
61
|
-
if (shouldSuppressProcessError(event, args)) return false;
|
|
62
|
-
return originalProcessEmit!(event, ...args);
|
|
63
|
-
} as typeof process.emit;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function installCaptureCallbackIfAvailable(): void {
|
|
67
|
-
if (captureCallbackInstalled || process.hasUncaughtExceptionCaptureCallback()) return;
|
|
68
|
-
process.setUncaughtExceptionCaptureCallback((error: Error) => {
|
|
69
|
-
if (shouldSuppressProcessError("uncaughtException", [error])) return;
|
|
70
|
-
uninstallCaptureCallbackIfIdle(true);
|
|
71
|
-
if (originalProcessEmit?.("uncaughtException", error)) return;
|
|
72
|
-
throw error;
|
|
73
|
-
});
|
|
74
|
-
captureCallbackInstalled = true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function uninstallCaptureCallbackIfIdle(force = false): void {
|
|
78
|
-
if (!captureCallbackInstalled) return;
|
|
79
|
-
if (!force && activeSuppressions.size > 0) return;
|
|
80
|
-
process.setUncaughtExceptionCaptureCallback(null);
|
|
81
|
-
captureCallbackInstalled = false;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function uninstallProcessEmitPatchIfIdle(): void {
|
|
85
|
-
if (activeSuppressions.size > 0 || !originalProcessEmit) return;
|
|
86
|
-
uninstallCaptureCallbackIfIdle();
|
|
87
|
-
process.emit = originalProcessEmit as typeof process.emit;
|
|
88
|
-
originalProcessEmit = undefined;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export const __testUtils = {
|
|
92
|
-
activeSuppressionCount: (): number => activeSuppressions.size,
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export function installCursorSdkAbortErrorSuppression(): CursorSdkAbortErrorSuppression {
|
|
96
|
-
installProcessEmitPatch();
|
|
97
|
-
const token: CursorSdkAbortErrorSuppressionToken = { suppress: false };
|
|
98
|
-
activeSuppressions.add(token);
|
|
99
|
-
let disposed = false;
|
|
100
|
-
return {
|
|
101
|
-
suppressAbortErrors(): void {
|
|
102
|
-
if (disposed) return;
|
|
103
|
-
token.suppress = true;
|
|
104
|
-
installCaptureCallbackIfAvailable();
|
|
105
|
-
},
|
|
106
|
-
dispose(): void {
|
|
107
|
-
if (disposed) return;
|
|
108
|
-
disposed = true;
|
|
109
|
-
activeSuppressions.delete(token);
|
|
110
|
-
uninstallProcessEmitPatchIfIdle();
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|