@rahularya01/pi-cursor 1.0.0 → 1.1.0
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 +18 -0
- package/README.md +28 -4
- package/package.json +8 -5
- package/src/auth/cli-credentials.ts +30 -5
- package/src/auth/consent.ts +27 -0
- package/src/auth/index.ts +12 -0
- package/src/client/index.ts +1 -1
- package/src/diagnostics/diagnostics.ts +17 -0
- package/src/index.ts +54 -11
- package/src/stream/config.ts +69 -0
- package/src/stream/context-normalize.ts +104 -0
- package/src/stream/index.ts +34 -3
- package/src/stream/model-routing.ts +100 -0
- package/src/stream/native-core.ts +97 -456
- package/src/stream/protocol.ts +41 -0
- package/src/stream/recovery.ts +454 -0
- package/tsconfig.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.0] - 2026-07-23
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Modular stream surface: `config`, `model-routing`, `context-normalize`, `recovery`, `protocol` extracted from the native runtime.
|
|
8
|
+
- Vitest unit suite covering recovery, model routing, context-mode normalize, consent, protocol framing, and usage formatting.
|
|
9
|
+
- Mid-session token re-resolution when access tokens near expiry (all credential sources).
|
|
10
|
+
- System credential consent opt-out via `PI_CURSOR_SYSTEM_CREDENTIALS=0`.
|
|
11
|
+
- `/cursor.doctor` fields: `clientVersion`, `systemCredentials`, `lastRecoverySkipReason`, protocol/auth hints.
|
|
12
|
+
- Protocol mismatch / auth error message enhancement with actionable hints.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Tool-continuation recovery prefers full-history rebuild when checkpoints are stale or tool-id mismatched (hard skip only when rebuild is unsafe).
|
|
17
|
+
- OpenAI-compatible local proxy path quarantined (not part of the public `src/stream` export surface).
|
|
18
|
+
- Agent URL resolution validates hosts via the existing allowlist helper.
|
|
19
|
+
- `SECURITY.md` updated for 1.x support and system-credential policy.
|
|
20
|
+
|
|
3
21
|
## [1.0.0] - 2026-07-23
|
|
4
22
|
|
|
5
23
|
### Added
|
package/README.md
CHANGED
|
@@ -57,6 +57,12 @@ If you are logged into the Cursor desktop app or Cursor CLI (`cursor` / `agent`)
|
|
|
57
57
|
|
|
58
58
|
On **WSL (Windows Subsystem for Linux)**, `pi-cursor` automatically scans Windows host user profiles (`/mnt/c/Users/*/AppData/...`) to detect and reuse your Windows Cursor app login.
|
|
59
59
|
|
|
60
|
+
To **opt out** of Keychain / IDE / WSL credential reuse (OAuth or `CURSOR_ACCESS_TOKEN` only):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
export PI_CURSOR_SYSTEM_CREDENTIALS=0
|
|
64
|
+
```
|
|
65
|
+
|
|
60
66
|
### Deep-link PKCE browser login
|
|
61
67
|
|
|
62
68
|
When no local credentials exist, running `/login cursor` initiates browser sign-in:
|
|
@@ -139,17 +145,35 @@ Pi Coding Agent → streamSimple (cursor-native)
|
|
|
139
145
|
| `PI_CURSOR_AGENT_URL` / `CURSOR_AGENT_URL` | Override agent base URL (default: `https://agentn.us.api5.cursor.sh`). |
|
|
140
146
|
| `CURSOR_ACCESS_TOKEN` | Static access token override. |
|
|
141
147
|
| `PI_CURSOR_CLIENT_VERSION` | Pin `x-cursor-client-version` header sent by the HTTP/2 bridge. |
|
|
148
|
+
| `PI_CURSOR_SYSTEM_CREDENTIALS` | `0`/`false` to disable Keychain/IDE credential reuse (default: allow). |
|
|
142
149
|
| `PI_CURSOR_RAW_MODELS` | Disable effort-suffix model collapse. |
|
|
143
150
|
| `PI_CURSOR_PROVIDER_DEBUG` | Enable JSONL debug logging (`/tmp/pi-cursor-debug.jsonl`). |
|
|
144
151
|
| `CURSOR_USAGE_SESSION_TOKEN` | Optional `WorkosCursorSessionToken` fallback cookie for `/cursor.usage`. |
|
|
145
152
|
| `PI_OFFLINE` | Skip live model discovery on startup. |
|
|
146
153
|
|
|
154
|
+
## Architecture notes
|
|
155
|
+
|
|
156
|
+
Stream modules are split under `src/stream/`:
|
|
157
|
+
|
|
158
|
+
| Module | Responsibility |
|
|
159
|
+
| ---------------------- | --------------------------------------------------- |
|
|
160
|
+
| `config.ts` | Agent URL + client version resolution |
|
|
161
|
+
| `model-routing.ts` | Effort suffix / requested model resolution |
|
|
162
|
+
| `context-normalize.ts` | Context-mode side-channel folding |
|
|
163
|
+
| `recovery.ts` | Tool-continuation recovery planner |
|
|
164
|
+
| `protocol.ts` | Auth/protocol error enhancement |
|
|
165
|
+
| `native-core.ts` | Native streamSimple runtime + (internal) proxy path |
|
|
166
|
+
|
|
167
|
+
The OpenAI-compatible local proxy remains **internal/quarantined** (not exported from `src/stream/index.ts`). Day-to-day chat uses native `streamSimple` only.
|
|
168
|
+
|
|
169
|
+
`src/proto/agent_pb.ts` is a large generated Connect/protobuf surface used by the wire layer. Prefer regenerating it from upstream protos when Cursor changes the agent schema rather than hand-editing.
|
|
170
|
+
|
|
147
171
|
## Troubleshooting
|
|
148
172
|
|
|
149
|
-
- **Not logged in / 401:** Ensure Cursor CLI or app is logged in, or run `/login cursor` again. Check `/cursor.doctor` to verify your `tokenSource`.
|
|
150
|
-
- **Empty / hung stream:** Cursor may have updated wire headers; verify network connectivity or bump `PI_CURSOR_CLIENT_VERSION`.
|
|
151
|
-
- **Tool continuation lost:** Retry the turn or start a new chat
|
|
152
|
-
- **WSL credential detection:** Ensure your Windows user profile folder exists under `/mnt/c/Users/` and is readable from WSL.
|
|
173
|
+
- **Not logged in / 401:** Ensure Cursor CLI or app is logged in, or run `/login cursor` again. Check `/cursor.doctor` to verify your `tokenSource`. Tokens from CLI/IDE are re-resolved when near expiry.
|
|
174
|
+
- **Empty / hung stream:** Cursor may have updated wire headers; verify network connectivity or bump `PI_CURSOR_CLIENT_VERSION`. `/cursor.doctor` prints the active `clientVersion`.
|
|
175
|
+
- **Tool continuation lost:** The provider now prefers full-history rebuild when checkpoints are stale/mismatched. If recovery still skips, `/cursor.doctor` shows `lastRecoverySkipReason`. Retry the turn or start a new chat.
|
|
176
|
+
- **WSL credential detection:** Ensure your Windows user profile folder exists under `/mnt/c/Users/` and is readable from WSL. Disable with `PI_CURSOR_SYSTEM_CREDENTIALS=0` if undesired.
|
|
153
177
|
|
|
154
178
|
## Development
|
|
155
179
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rahularya01/pi-cursor",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Native Cursor provider for Pi Coding Agent (OAuth + Connect/protobuf streamSimple)",
|
|
5
5
|
"author": "Rahul Arya",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,12 +30,14 @@
|
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
32
|
"typecheck": "tsc --noEmit",
|
|
33
|
-
"lint": "eslint src",
|
|
34
|
-
"lint:fix": "eslint src --fix",
|
|
33
|
+
"lint": "eslint src tests",
|
|
34
|
+
"lint:fix": "eslint src tests --fix",
|
|
35
35
|
"format": "prettier --write .",
|
|
36
36
|
"format:check": "prettier --check .",
|
|
37
37
|
"security-check": "tsx scripts/security-check.ts",
|
|
38
|
-
"test": "
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest",
|
|
40
|
+
"test:legacy": "tsx scripts/test-model-routing.ts && tsx scripts/test-thinking-levels.ts && tsx scripts/test-usage.ts && tsx scripts/test-context-mode-normalize.ts && tsx scripts/test-cli-auth.ts",
|
|
39
41
|
"smoke:auth": "node --import tsx scripts/smoke-auth.mjs",
|
|
40
42
|
"smoke:models": "node --import tsx scripts/smoke-models.mjs",
|
|
41
43
|
"smoke:stream": "node --import tsx scripts/smoke-stream.mjs",
|
|
@@ -62,6 +64,7 @@
|
|
|
62
64
|
"prettier": "^3.9.5",
|
|
63
65
|
"tsx": "^4.23.1",
|
|
64
66
|
"typescript": "^6.0.3",
|
|
65
|
-
"typescript-eslint": "^8.64.0"
|
|
67
|
+
"typescript-eslint": "^8.64.0",
|
|
68
|
+
"vitest": "^3.2.4"
|
|
66
69
|
}
|
|
67
70
|
}
|
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync } from "node:fs";
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { systemCredentialsAllowed } from "./consent.js";
|
|
5
6
|
import { getCursorAccessTokenFromEnv, getTokenExpiry, refreshCursorToken } from "./oauth.js";
|
|
6
7
|
|
|
7
8
|
export type CredentialSource =
|
|
@@ -151,18 +152,42 @@ export async function getCursorVscdbToken(): Promise<CursorTokenResult | undefin
|
|
|
151
152
|
/**
|
|
152
153
|
* Full credential resolution cascade:
|
|
153
154
|
* 1. CURSOR_ACCESS_TOKEN env var
|
|
154
|
-
* 2. macOS Keychain (Cursor CLI)
|
|
155
|
-
* 3. Cursor IDE state.vscdb
|
|
155
|
+
* 2. macOS Keychain (Cursor CLI) — gated by system-credential consent
|
|
156
|
+
* 3. Cursor IDE state.vscdb — gated by system-credential consent
|
|
157
|
+
*
|
|
158
|
+
* Opt out of Keychain/vscdb scraping with PI_CURSOR_SYSTEM_CREDENTIALS=0.
|
|
156
159
|
*/
|
|
157
|
-
export async function resolveSystemCursorAccessToken(
|
|
160
|
+
export async function resolveSystemCursorAccessToken(options?: {
|
|
161
|
+
forceRefresh?: boolean;
|
|
162
|
+
}): Promise<CursorTokenResult | undefined> {
|
|
158
163
|
const envToken = getCursorAccessTokenFromEnv();
|
|
159
|
-
if (envToken)
|
|
164
|
+
if (envToken) {
|
|
165
|
+
// Env tokens cannot be refreshed here; still prefer them when present.
|
|
166
|
+
if (!options?.forceRefresh || Date.now() < getTokenExpiry(envToken)) {
|
|
167
|
+
return { accessToken: envToken, source: "env" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!systemCredentialsAllowed()) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
160
174
|
|
|
175
|
+
// forceRefresh: skip unexpired access-token short-circuit by re-reading sources
|
|
176
|
+
// (keychain/vscdb helpers already refresh when access is expired).
|
|
161
177
|
const keychainToken = await getCursorKeychainToken();
|
|
162
|
-
if (keychainToken)
|
|
178
|
+
if (keychainToken) {
|
|
179
|
+
if (!options?.forceRefresh || keychainToken.source.endsWith("_refresh")) {
|
|
180
|
+
return keychainToken;
|
|
181
|
+
}
|
|
182
|
+
// Access still valid but forceRefresh requested — try refresh path via re-read.
|
|
183
|
+
// Keychain helper returns unexpired access first; force path falls through to vscdb/oauth.
|
|
184
|
+
}
|
|
163
185
|
|
|
164
186
|
const vscdbToken = await getCursorVscdbToken();
|
|
165
187
|
if (vscdbToken) return vscdbToken;
|
|
166
188
|
|
|
189
|
+
// If forceRefresh and we only had unexpired keychain access, return it as last resort.
|
|
190
|
+
if (keychainToken) return keychainToken;
|
|
191
|
+
|
|
167
192
|
return undefined;
|
|
168
193
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consent / opt-out for reusing Cursor CLI / IDE system credentials.
|
|
3
|
+
*
|
|
4
|
+
* Default: allow system credential reuse (Keychain + state.vscdb + WSL host).
|
|
5
|
+
* Opt out with PI_CURSOR_SYSTEM_CREDENTIALS=0|false|off|deny.
|
|
6
|
+
* Force-enable with PI_CURSOR_SYSTEM_CREDENTIALS=1|true|on|allow.
|
|
7
|
+
*/
|
|
8
|
+
export type SystemCredentialPolicy = "allow" | "deny";
|
|
9
|
+
|
|
10
|
+
export function resolveSystemCredentialPolicy(
|
|
11
|
+
envValue: string | undefined = process.env.PI_CURSOR_SYSTEM_CREDENTIALS,
|
|
12
|
+
): SystemCredentialPolicy {
|
|
13
|
+
const raw = envValue?.trim().toLowerCase();
|
|
14
|
+
if (!raw) return "allow";
|
|
15
|
+
if (raw === "0" || raw === "false" || raw === "off" || raw === "deny" || raw === "no") {
|
|
16
|
+
return "deny";
|
|
17
|
+
}
|
|
18
|
+
if (raw === "1" || raw === "true" || raw === "on" || raw === "allow" || raw === "yes") {
|
|
19
|
+
return "allow";
|
|
20
|
+
}
|
|
21
|
+
// Unknown values fail closed so misconfiguration never silently scrapes credentials.
|
|
22
|
+
return "deny";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function systemCredentialsAllowed(envValue?: string): boolean {
|
|
26
|
+
return resolveSystemCredentialPolicy(envValue) === "allow";
|
|
27
|
+
}
|
package/src/auth/index.ts
CHANGED
|
@@ -8,3 +8,15 @@ export {
|
|
|
8
8
|
type CursorAuthParams,
|
|
9
9
|
type CursorCredentials,
|
|
10
10
|
} from "./oauth.js";
|
|
11
|
+
export {
|
|
12
|
+
resolveSystemCursorAccessToken,
|
|
13
|
+
getCursorKeychainToken,
|
|
14
|
+
getCursorVscdbToken,
|
|
15
|
+
type CredentialSource,
|
|
16
|
+
type CursorTokenResult,
|
|
17
|
+
} from "./cli-credentials.js";
|
|
18
|
+
export {
|
|
19
|
+
systemCredentialsAllowed,
|
|
20
|
+
resolveSystemCredentialPolicy,
|
|
21
|
+
type SystemCredentialPolicy,
|
|
22
|
+
} from "./consent.js";
|
package/src/client/index.ts
CHANGED
|
@@ -10,6 +10,10 @@ export type DiagnosticsSnapshot = {
|
|
|
10
10
|
availableModels?: string;
|
|
11
11
|
matchedModelDebug?: string;
|
|
12
12
|
lastRpc?: string;
|
|
13
|
+
tokenSource?: string;
|
|
14
|
+
clientVersion?: string;
|
|
15
|
+
lastRecoverySkipReason?: string;
|
|
16
|
+
systemCredentials?: string;
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
const storage = new AsyncLocalStorage<DiagnosticsSnapshot>();
|
|
@@ -56,6 +60,19 @@ export function setLastMatchedModelDebug(debug: string | undefined): void {
|
|
|
56
60
|
currentBag().matchedModelDebug =
|
|
57
61
|
debug === undefined ? undefined : redactSecrets(debug).slice(0, 1200);
|
|
58
62
|
}
|
|
63
|
+
export function setLastTokenSource(source: string | undefined): void {
|
|
64
|
+
currentBag().tokenSource = source;
|
|
65
|
+
}
|
|
66
|
+
export function setLastClientVersion(version: string | undefined): void {
|
|
67
|
+
currentBag().clientVersion = version;
|
|
68
|
+
}
|
|
69
|
+
export function setLastRecoverySkipReason(reason: string | undefined): void {
|
|
70
|
+
currentBag().lastRecoverySkipReason =
|
|
71
|
+
reason === undefined ? undefined : redactSecrets(reason).slice(0, 200);
|
|
72
|
+
}
|
|
73
|
+
export function setSystemCredentialsPolicy(policy: string | undefined): void {
|
|
74
|
+
currentBag().systemCredentials = policy;
|
|
75
|
+
}
|
|
59
76
|
|
|
60
77
|
export function resetDiagnosticsForTests(): void {
|
|
61
78
|
lastSnapshot = {};
|
package/src/index.ts
CHANGED
|
@@ -31,9 +31,17 @@ import {
|
|
|
31
31
|
refreshCursorToken,
|
|
32
32
|
} from "./auth/oauth.js";
|
|
33
33
|
import { resolveSystemCursorAccessToken, type CredentialSource } from "./auth/cli-credentials.js";
|
|
34
|
-
import {
|
|
34
|
+
import { resolveSystemCredentialPolicy, systemCredentialsAllowed } from "./auth/consent.js";
|
|
35
|
+
import {
|
|
36
|
+
getLastDiagnostics,
|
|
37
|
+
setLastAvailableModels,
|
|
38
|
+
setLastClientVersion,
|
|
39
|
+
setLastTokenSource,
|
|
40
|
+
setSystemCredentialsPolicy,
|
|
41
|
+
} from "./diagnostics/index.js";
|
|
35
42
|
import { redactSecrets } from "./utils/security.js";
|
|
36
43
|
import { formatCursorUsage, getCursorUsageSummary } from "./usage.js";
|
|
44
|
+
import { getCursorClientVersion } from "./stream/config.js";
|
|
37
45
|
import {
|
|
38
46
|
cleanupSessionState,
|
|
39
47
|
createCursorNativeStream,
|
|
@@ -1042,14 +1050,22 @@ async function getStoredCursorOAuthAccessToken(): Promise<
|
|
|
1042
1050
|
return undefined;
|
|
1043
1051
|
}
|
|
1044
1052
|
|
|
1045
|
-
async function getStartupCursorAccessToken(
|
|
1046
|
-
|
|
1047
|
-
> {
|
|
1048
|
-
const systemToken = await resolveSystemCursorAccessToken();
|
|
1053
|
+
async function getStartupCursorAccessToken(options?: {
|
|
1054
|
+
forceRefresh?: boolean;
|
|
1055
|
+
}): Promise<{ accessToken: string; source: CredentialSource } | undefined> {
|
|
1056
|
+
const systemToken = await resolveSystemCursorAccessToken(options);
|
|
1049
1057
|
if (systemToken) return systemToken;
|
|
1050
1058
|
return getStoredCursorOAuthAccessToken();
|
|
1051
1059
|
}
|
|
1052
1060
|
|
|
1061
|
+
function isTokenNearExpiry(token: string, skewMs = 60_000): boolean {
|
|
1062
|
+
try {
|
|
1063
|
+
return Date.now() >= getTokenExpiry(token) - skewMs;
|
|
1064
|
+
} catch {
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1053
1069
|
export function registerSessionLifecycleCleanup(pi: ExtensionAPI) {
|
|
1054
1070
|
const cleanupCurrentSession = (_event: unknown, ctx: ExtensionContext) => {
|
|
1055
1071
|
debugExtensionLog("session.cleanup_hook", {
|
|
@@ -1159,16 +1175,38 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1159
1175
|
let rawModelByEffortByModelId = new Map<string, Record<string, CursorModelRouting>>();
|
|
1160
1176
|
let lastRegisteredModels: ProcessedModel[] = [];
|
|
1161
1177
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1178
|
+
setSystemCredentialsPolicy(resolveSystemCredentialPolicy());
|
|
1179
|
+
setLastClientVersion(getCursorClientVersion());
|
|
1180
|
+
|
|
1181
|
+
const getAccessToken = async (options?: { forceRefresh?: boolean }) => {
|
|
1182
|
+
const needsRefresh =
|
|
1183
|
+
options?.forceRefresh ||
|
|
1184
|
+
!currentToken ||
|
|
1185
|
+
(currentTokenSource !== "env" && isTokenNearExpiry(currentToken));
|
|
1186
|
+
|
|
1187
|
+
if (needsRefresh) {
|
|
1188
|
+
const resolved = await getStartupCursorAccessToken({
|
|
1189
|
+
forceRefresh: options?.forceRefresh || isTokenNearExpiry(currentToken || "x"),
|
|
1190
|
+
});
|
|
1165
1191
|
if (resolved) {
|
|
1166
1192
|
currentToken = resolved.accessToken;
|
|
1167
1193
|
currentTokenSource = resolved.source;
|
|
1194
|
+
setLastTokenSource(resolved.source);
|
|
1195
|
+
} else if (options?.forceRefresh) {
|
|
1196
|
+
// Keep previous token only if force refresh failed and we still have one.
|
|
1197
|
+
setLastTokenSource(currentTokenSource || "none");
|
|
1168
1198
|
}
|
|
1169
1199
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1200
|
+
|
|
1201
|
+
if (!currentToken) {
|
|
1202
|
+
const consentHint = systemCredentialsAllowed()
|
|
1203
|
+
? ""
|
|
1204
|
+
: " System credential reuse is disabled (PI_CURSOR_SYSTEM_CREDENTIALS=0).";
|
|
1205
|
+
throw new Error(
|
|
1206
|
+
`Not logged in to Cursor. Run /login cursor or log in via Cursor CLI.${consentHint}`,
|
|
1207
|
+
);
|
|
1208
|
+
}
|
|
1209
|
+
setLastTokenSource(currentTokenSource);
|
|
1172
1210
|
return currentToken;
|
|
1173
1211
|
};
|
|
1174
1212
|
|
|
@@ -1245,17 +1283,22 @@ export default async function (pi: ExtensionAPI) {
|
|
|
1245
1283
|
const lines = [
|
|
1246
1284
|
`provider=${CURSOR_PROVIDER_ID}`,
|
|
1247
1285
|
`agentUrl=${getCursorAgentUrl()}`,
|
|
1248
|
-
`
|
|
1286
|
+
`clientVersion=${d.clientVersion || getCursorClientVersion()}`,
|
|
1287
|
+
`tokenSource=${d.tokenSource || currentTokenSource || "none"}`,
|
|
1288
|
+
`systemCredentials=${d.systemCredentials || resolveSystemCredentialPolicy()}`,
|
|
1249
1289
|
`lastResolvedRuntimeModel=${d.resolvedRuntimeModel || "none"}`,
|
|
1250
1290
|
`availableModels=${d.availableModels || lastRegisteredModels.length || "none"}`,
|
|
1251
1291
|
`matchedModel=${d.matchedModelDebug || "none"}`,
|
|
1252
1292
|
`lastEndpoint=${d.endpoint || "none"}`,
|
|
1253
1293
|
`lastStatus=${d.status ?? "none"}`,
|
|
1254
1294
|
`lastRpc=${d.lastRpc || "none"}`,
|
|
1295
|
+
`lastRecoverySkipReason=${d.lastRecoverySkipReason || "none"}`,
|
|
1255
1296
|
`lastError=${d.error ? redactSecrets(d.error) : "none"}`,
|
|
1256
1297
|
"transport=native-streamSimple",
|
|
1257
1298
|
"runtimeCli=not-used",
|
|
1299
|
+
"proxyPath=quarantined-internal",
|
|
1258
1300
|
"commands=/cursor.models /cursor.usage /cursor.doctor",
|
|
1301
|
+
"hint=On wire errors set PI_CURSOR_CLIENT_VERSION or re-login; opt out of Keychain/IDE scrape with PI_CURSOR_SYSTEM_CREDENTIALS=0",
|
|
1259
1302
|
];
|
|
1260
1303
|
const text = lines.join("\n");
|
|
1261
1304
|
if (ctx.hasUI) ctx.ui.notify(`Cursor doctor\n${text}`, "info");
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor agent URL resolution (env → CLI config → default).
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join as pathJoin } from "node:path";
|
|
7
|
+
import { assertSafeCursorBaseUrl } from "../utils/security.js";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_CURSOR_AGENT_URL = "https://agentn.us.api5.cursor.sh";
|
|
10
|
+
export const DEFAULT_CURSOR_CLIENT_VERSION = "cli-2026.05.01-eea359f";
|
|
11
|
+
|
|
12
|
+
let cachedCursorAgentUrl: string | undefined;
|
|
13
|
+
|
|
14
|
+
export function normalizeCursorUrl(value: unknown): string | undefined {
|
|
15
|
+
if (typeof value !== "string") return undefined;
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
if (!trimmed) return undefined;
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(trimmed);
|
|
20
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
|
|
21
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
22
|
+
url.search = "";
|
|
23
|
+
url.hash = "";
|
|
24
|
+
return url.toString().replace(/\/$/, "");
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readCursorCliAgentUrl(): string | undefined {
|
|
31
|
+
const configDir = process.env.CURSOR_CONFIG_DIR?.trim() || pathJoin(homedir(), ".cursor");
|
|
32
|
+
try {
|
|
33
|
+
const config = JSON.parse(readFileSync(pathJoin(configDir, "cli-config.json"), "utf8")) as {
|
|
34
|
+
serverConfigCache?: {
|
|
35
|
+
agentUrlConfig?: { agentnUrl?: unknown; agentUrl?: unknown };
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
return (
|
|
39
|
+
normalizeCursorUrl(config.serverConfigCache?.agentUrlConfig?.agentnUrl) ??
|
|
40
|
+
normalizeCursorUrl(config.serverConfigCache?.agentUrlConfig?.agentUrl)
|
|
41
|
+
);
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getCursorClientVersion(): string {
|
|
48
|
+
return process.env.PI_CURSOR_CLIENT_VERSION?.trim() || DEFAULT_CURSOR_CLIENT_VERSION;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolve the agent base URL, validating host against the allowlist. */
|
|
52
|
+
export function getCursorAgentUrl(): string {
|
|
53
|
+
const envUrl =
|
|
54
|
+
normalizeCursorUrl(process.env.PI_CURSOR_AGENT_URL) ??
|
|
55
|
+
normalizeCursorUrl(process.env.CURSOR_AGENT_URL);
|
|
56
|
+
if (envUrl) {
|
|
57
|
+
cachedCursorAgentUrl = assertSafeCursorBaseUrl(envUrl);
|
|
58
|
+
return cachedCursorAgentUrl;
|
|
59
|
+
}
|
|
60
|
+
if (cachedCursorAgentUrl) return cachedCursorAgentUrl;
|
|
61
|
+
const resolved = readCursorCliAgentUrl() ?? DEFAULT_CURSOR_AGENT_URL;
|
|
62
|
+
cachedCursorAgentUrl = assertSafeCursorBaseUrl(resolved);
|
|
63
|
+
return cachedCursorAgentUrl;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Test helper: clear cached URL between cases. */
|
|
67
|
+
export function resetCursorAgentUrlCacheForTests(): void {
|
|
68
|
+
cachedCursorAgentUrl = undefined;
|
|
69
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize context-mode / session side-channel user messages into the system
|
|
3
|
+
* prompt so Cursor treats the real user turn as the task.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type OpenAIRole = "system" | "user" | "assistant" | "tool";
|
|
7
|
+
|
|
8
|
+
export interface OpenAIContentPart {
|
|
9
|
+
type: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
data?: string;
|
|
12
|
+
mimeType?: string;
|
|
13
|
+
image_url?: { url?: string };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OpenAIMessage {
|
|
17
|
+
role: OpenAIRole;
|
|
18
|
+
content?: string | OpenAIContentPart[] | null;
|
|
19
|
+
tool_call_id?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
tool_calls?: unknown[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const CONTEXT_MODE_SIDE_CHANNEL_PRIORITY =
|
|
25
|
+
"Provider infrastructure context only. Prioritize the user's actual request above. " +
|
|
26
|
+
"Do not run compaction recovery, session investigation, or ctx_doctor/ctx_stats rituals " +
|
|
27
|
+
"unless the user explicitly asked for that.";
|
|
28
|
+
|
|
29
|
+
export function textContent(content: OpenAIMessage["content"]): string {
|
|
30
|
+
if (content == null) return "";
|
|
31
|
+
if (typeof content === "string") return content;
|
|
32
|
+
return content
|
|
33
|
+
.filter((p) => p.type === "text" && p.text)
|
|
34
|
+
.map((p) => p.text as string)
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function contentHasImageParts(content: OpenAIMessage["content"]): boolean {
|
|
39
|
+
if (!Array.isArray(content)) return false;
|
|
40
|
+
return content.some(
|
|
41
|
+
(part) =>
|
|
42
|
+
part.type === "image_url" ||
|
|
43
|
+
part.type === "image" ||
|
|
44
|
+
(typeof part.mimeType === "string" && part.mimeType.startsWith("image/")),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isContextModeSideChannelText(text: string): boolean {
|
|
49
|
+
const t = text.trim();
|
|
50
|
+
if (!t) return false;
|
|
51
|
+
return (
|
|
52
|
+
/^context-mode active\b/i.test(t) ||
|
|
53
|
+
t.includes("<session_state") ||
|
|
54
|
+
t.includes("<session_resume") ||
|
|
55
|
+
t.includes("<active_memory>") ||
|
|
56
|
+
t.includes("Hierarchy: ctx_batch_execute") ||
|
|
57
|
+
/<\/?session_mode\b/i.test(t)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function frameContextModeSideChannel(text: string): string {
|
|
62
|
+
return (
|
|
63
|
+
`<provider_context source="context-mode">\n${text.trim()}\n</provider_context>\n\n` +
|
|
64
|
+
CONTEXT_MODE_SIDE_CHANNEL_PRIORITY
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fold pure side-channel user messages into the system prompt and keep the
|
|
70
|
+
* real user turns as the task.
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeMessagesForCursor(messages: OpenAIMessage[]): OpenAIMessage[] {
|
|
73
|
+
const systemParts: string[] = [];
|
|
74
|
+
const sideParts: string[] = [];
|
|
75
|
+
const rest: OpenAIMessage[] = [];
|
|
76
|
+
|
|
77
|
+
for (const msg of messages) {
|
|
78
|
+
if (msg.role === "system") {
|
|
79
|
+
const text = textContent(msg.content);
|
|
80
|
+
if (text) systemParts.push(text);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (msg.role === "user") {
|
|
85
|
+
const text = textContent(msg.content);
|
|
86
|
+
// Keep multimodal user turns intact — only pure text side-channels move.
|
|
87
|
+
if (isContextModeSideChannelText(text) && !contentHasImageParts(msg.content)) {
|
|
88
|
+
sideParts.push(text);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
rest.push(msg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (sideParts.length === 0) {
|
|
97
|
+
if (systemParts.length === 0) return messages;
|
|
98
|
+
return [{ role: "system", content: systemParts.join("\n") }, ...rest];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const framed = frameContextModeSideChannel(sideParts.join("\n\n"));
|
|
102
|
+
const system = systemParts.length > 0 ? `${systemParts.join("\n")}\n\n${framed}` : framed;
|
|
103
|
+
return [{ role: "system", content: system }, ...rest];
|
|
104
|
+
}
|
package/src/stream/index.ts
CHANGED
|
@@ -1,11 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public stream surface for the Cursor provider.
|
|
3
|
+
*
|
|
4
|
+
* The legacy OpenAI-compatible local proxy (`startProxy`) remains inside
|
|
5
|
+
* native-core for internal/debug use but is intentionally not re-exported here.
|
|
6
|
+
*/
|
|
1
7
|
export {
|
|
2
8
|
createCursorNativeStream,
|
|
3
|
-
getCursorAgentUrl,
|
|
4
9
|
getCursorModels,
|
|
5
10
|
getCursorParameterizedModels,
|
|
6
11
|
cleanupSessionState,
|
|
7
|
-
|
|
8
|
-
resolveRequestedModelId,
|
|
12
|
+
cleanupAllSessionState,
|
|
9
13
|
type CursorModel,
|
|
10
14
|
type CursorNativeStreamConfig,
|
|
11
15
|
} from "./native-core.js";
|
|
16
|
+
|
|
17
|
+
export { getCursorAgentUrl, getCursorClientVersion } from "./config.js";
|
|
18
|
+
export {
|
|
19
|
+
resolveModelId,
|
|
20
|
+
resolveRequestedModelId,
|
|
21
|
+
type CursorNativeModelRouting,
|
|
22
|
+
} from "./model-routing.js";
|
|
23
|
+
export {
|
|
24
|
+
isContextModeSideChannelText,
|
|
25
|
+
normalizeMessagesForCursor,
|
|
26
|
+
frameContextModeSideChannel,
|
|
27
|
+
} from "./context-normalize.js";
|
|
28
|
+
export {
|
|
29
|
+
planRecovery,
|
|
30
|
+
fingerprintCompletedTurns,
|
|
31
|
+
wrapRecoveredToolResults,
|
|
32
|
+
lostToolContinuationErrorBody,
|
|
33
|
+
formatLostToolContinuationDiagnostic,
|
|
34
|
+
type RecoveryDecision,
|
|
35
|
+
type PlanRecoveryInput,
|
|
36
|
+
type StoredConversation,
|
|
37
|
+
} from "./recovery.js";
|
|
38
|
+
export {
|
|
39
|
+
enhanceCursorStreamError,
|
|
40
|
+
isAuthErrorMessage,
|
|
41
|
+
isProtocolMismatchMessage,
|
|
42
|
+
} from "./protocol.js";
|