pi-cursor-sdk 0.1.25 → 0.1.26
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 +16 -0
- package/README.md +27 -6
- package/package.json +1 -1
- 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 +2 -1
- 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/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,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.26 - 2026-05-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 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`.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- `/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.
|
|
15
|
+
- 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).
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Prevent Cursor SDK `ConnectError: [unauthenticated]` failures from crashing pi as process-level uncaught exceptions; surface them as recoverable Cursor auth errors instead.
|
|
20
|
+
|
|
5
21
|
## 0.1.25 - 2026-05-28
|
|
6
22
|
|
|
7
23
|
### 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,7 +32,7 @@ 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
37
|
No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.15`, 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.15; older pi or Cursor SDK compatibility paths are not maintained.
|
|
38
38
|
|
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -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,9 +9,11 @@ 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
14
|
export async function cacheSdkContextWindow(agentId: string, modelId: string): Promise<void> {
|
|
15
15
|
try {
|
|
16
|
+
const { createAgentPlatform } = await loadCursorSdk();
|
|
16
17
|
const platform = await createAgentPlatform();
|
|
17
18
|
const checkpoint = await platform.checkpointStore.loadLatest(agentId);
|
|
18
19
|
const contextWindow = getCheckpointContextWindow(checkpoint);
|
|
@@ -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({
|
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
|
-
}
|