pi-cursor-sdk 0.1.19 → 0.1.21
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 +52 -0
- package/README.md +72 -11
- package/docs/cursor-dogfood-checklist.md +57 -0
- package/docs/cursor-live-smoke-checklist.md +116 -10
- package/docs/cursor-model-ux-spec.md +60 -19
- package/docs/cursor-native-tool-replay.md +21 -11
- package/docs/cursor-native-tool-visual-audit.md +104 -59
- package/docs/cursor-testing-lessons.md +10 -5
- package/docs/cursor-tool-surfaces.md +69 -0
- package/package.json +37 -11
- package/scripts/debug-provider-events.d.mts +59 -0
- package/scripts/debug-provider-events.mjs +70 -175
- package/scripts/debug-sdk-events.d.mts +90 -0
- package/scripts/debug-sdk-events.mjs +36 -98
- package/scripts/fixtures/plan-strip-shim/index.ts +12 -0
- package/scripts/isolated-cursor-smoke.sh +264 -102
- package/scripts/lib/cursor-child-process.d.mts +10 -0
- package/scripts/lib/cursor-child-process.mjs +50 -0
- package/scripts/lib/cursor-cli-args.d.mts +63 -0
- package/scripts/lib/cursor-cli-args.mjs +129 -0
- package/scripts/lib/cursor-script-fail.d.mts +1 -0
- package/scripts/lib/cursor-script-fail.mjs +13 -0
- package/scripts/lib/cursor-sdk-output-filter.d.mts +5 -0
- package/scripts/lib/cursor-smoke-env.d.mts +38 -0
- package/scripts/lib/cursor-smoke-env.mjs +81 -0
- package/scripts/lib/cursor-smoke-shell.sh +174 -0
- package/scripts/lib/cursor-visual-render.d.mts +15 -0
- package/scripts/lib/cursor-visual-render.mjs +131 -0
- package/scripts/probe-mcp-coldstart.mjs +226 -0
- package/scripts/refresh-cursor-model-snapshots.mjs +29 -65
- package/scripts/steering-rpc-smoke.mjs +170 -65
- package/scripts/tmux-live-smoke.sh +152 -98
- package/scripts/visual-tui-smoke.mjs +659 -0
- package/shared/cursor-sdk-event-debug-env.d.mts +12 -0
- package/shared/cursor-sdk-event-debug-env.mjs +13 -0
- package/shared/cursor-sensitive-text.d.mts +1 -0
- package/{scripts/lib/cursor-probe-utils.mjs → shared/cursor-sensitive-text.mjs} +1 -13
- package/shared/cursor-setting-sources.d.mts +5 -0
- package/shared/cursor-setting-sources.mjs +22 -0
- package/src/context.ts +21 -12
- package/src/cursor-bridge-contract.ts +1 -3
- package/src/cursor-incomplete-tool-visibility.ts +72 -49
- package/src/cursor-mcp-timeout-override.ts +66 -11
- package/src/cursor-native-tool-display-registration.ts +63 -27
- package/src/cursor-native-tool-display-replay.ts +246 -143
- package/src/cursor-native-tool-display-state.ts +2 -0
- package/src/cursor-native-tool-display-tools.ts +149 -41
- package/src/cursor-provider-live-run-drain.ts +1 -52
- package/src/cursor-provider-run-finalizer.ts +235 -0
- package/src/cursor-provider-run-outcome.ts +149 -0
- package/src/cursor-provider-turn-api-key.ts +8 -0
- package/src/cursor-provider-turn-coordinator.ts +113 -440
- package/src/cursor-provider-turn-display-router.ts +216 -0
- package/src/cursor-provider-turn-emit.ts +59 -0
- package/src/cursor-provider-turn-finalize.ts +119 -0
- package/src/cursor-provider-turn-lifecycle-emitter.ts +97 -0
- package/src/cursor-provider-turn-message-offset.ts +15 -0
- package/src/cursor-provider-turn-prepare.ts +216 -0
- package/src/cursor-provider-turn-runner.ts +138 -0
- package/src/cursor-provider-turn-sdk-normalizer.ts +88 -0
- package/src/cursor-provider-turn-send.ts +103 -0
- package/src/cursor-provider-turn-shell-output.ts +107 -0
- package/src/cursor-provider-turn-tool-ledger.ts +126 -0
- package/src/cursor-provider-turn-types.ts +87 -0
- package/src/cursor-provider.ts +16 -482
- package/src/cursor-replay-activity-builders.ts +276 -0
- package/src/cursor-replay-source-names.ts +33 -0
- package/src/cursor-replay-summary-args.ts +191 -0
- package/src/cursor-replay-tool-details.ts +464 -0
- package/src/cursor-run-final-text.ts +56 -0
- package/src/cursor-sdk-abort-error-guard.ts +4 -0
- package/src/cursor-sdk-event-debug-constants.ts +14 -5
- package/src/cursor-sdk-event-debug.ts +8 -2
- package/src/cursor-sensitive-text.ts +3 -36
- package/src/cursor-session-agent.ts +265 -88
- package/src/cursor-setting-sources.ts +7 -10
- package/src/cursor-state.ts +232 -28
- package/src/cursor-tool-lifecycle.ts +17 -42
- package/src/cursor-tool-manifest.ts +41 -0
- package/src/cursor-tool-names.ts +18 -79
- package/src/cursor-tool-presentation-registry.ts +556 -0
- package/src/cursor-tool-transcript.ts +1 -1
- package/src/cursor-tool-visibility.ts +39 -0
- package/src/cursor-transcript-tool-formatters.ts +0 -59
- package/src/cursor-transcript-tool-specs.ts +169 -232
- package/src/cursor-transcript-utils.ts +0 -44
- package/src/cursor-web-tool-activity.ts +10 -60
- package/src/cursor-web-tool-args.ts +39 -0
- package/src/index.ts +4 -10
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Maintainer probe: measure Cursor SDK cold-start timing with/without ambient MCP settings
|
|
4
|
+
* and with the pi-cursor-sdk MCP connect timeout override installed.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {
|
|
10
|
+
installCursorMcpToolTimeoutOverride,
|
|
11
|
+
restoreCursorMcpToolTimeoutOverride,
|
|
12
|
+
} from "../src/cursor-mcp-timeout-override.ts";
|
|
13
|
+
import { apiKeySecretsFromProcess, defaultApiKeyFromEnv, parseArgv } from "./lib/cursor-cli-args.mjs";
|
|
14
|
+
import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs";
|
|
15
|
+
import { createScriptFail } from "./lib/cursor-script-fail.mjs";
|
|
16
|
+
import { installCursorSdkOutputFilter, suppressCursorSdkOutput } from "./lib/cursor-sdk-output-filter.mjs";
|
|
17
|
+
|
|
18
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
19
|
+
const SCENARIOS = [
|
|
20
|
+
{ label: "with-all-settings", settingSources: ["all"] },
|
|
21
|
+
{ label: "with-all-settings+connect-override", settingSources: ["all"], installConnectOverride: true },
|
|
22
|
+
{ label: "no-setting-sources", settingSources: undefined },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function printHelp() {
|
|
26
|
+
console.log(`Measure Cursor SDK first-send MCP cold-start timing.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
CURSOR_API_KEY=... npm run debug:mcp-coldstart
|
|
30
|
+
node scripts/probe-mcp-coldstart.mjs [options]
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--api-key <key> Cursor API key. Prefer CURSOR_API_KEY to avoid shell history.
|
|
34
|
+
--scenario <label> Run one scenario in this process. Used by the orchestrator.
|
|
35
|
+
-h, --help Show this help without importing or calling the Cursor SDK.
|
|
36
|
+
|
|
37
|
+
Stdout:
|
|
38
|
+
Emits one JSON object per scenario. Human status lines go to stderr.
|
|
39
|
+
|
|
40
|
+
Scenarios:
|
|
41
|
+
with-all-settings Cursor settingSources=["all"]
|
|
42
|
+
with-all-settings+connect-override Same, with pi-cursor-sdk timeout override installed
|
|
43
|
+
no-setting-sources No explicit settingSources
|
|
44
|
+
|
|
45
|
+
Safety:
|
|
46
|
+
- --help never performs live Cursor calls.
|
|
47
|
+
- Each default scenario runs in a fresh child process before its first Cursor SDK import.
|
|
48
|
+
- SDK startup noise is suppressed.
|
|
49
|
+
- Error messages are scrubbed for API keys, bearer tokens, cookies, and bridge endpoints.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const exitWithFailure = createScriptFail("probe-mcp-coldstart");
|
|
53
|
+
|
|
54
|
+
function fail(message, secrets) {
|
|
55
|
+
const secretList = secrets === undefined ? [] : Array.isArray(secrets) ? secrets : [secrets];
|
|
56
|
+
exitWithFailure(message, secretList.filter(Boolean));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findScenario(label) {
|
|
60
|
+
return SCENARIOS.find((scenario) => scenario.label === label);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv, env = process.env) {
|
|
64
|
+
const args = parseArgv(argv, {
|
|
65
|
+
defaults: {
|
|
66
|
+
apiKey: defaultApiKeyFromEnv(env),
|
|
67
|
+
scenario: undefined,
|
|
68
|
+
},
|
|
69
|
+
flags: {
|
|
70
|
+
apiKey: { names: ["--api-key"], assign: (value) => value.trim() },
|
|
71
|
+
scenario: { names: ["--scenario"], assign: (value) => value.trim() },
|
|
72
|
+
},
|
|
73
|
+
fail: (message) => fail(message, defaultApiKeyFromEnv(env)),
|
|
74
|
+
});
|
|
75
|
+
if (args.scenario && !findScenario(args.scenario)) {
|
|
76
|
+
fail(`unknown scenario: ${args.scenario}`, args.apiKey);
|
|
77
|
+
}
|
|
78
|
+
return args;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function probe(Agent, apiKey, label, { settingSources, installConnectOverride = false } = {}) {
|
|
82
|
+
let agent;
|
|
83
|
+
try {
|
|
84
|
+
const marks = [];
|
|
85
|
+
const t0 = performance.now();
|
|
86
|
+
const mark = (name) => marks.push({ name, ms: Math.round(performance.now() - t0) });
|
|
87
|
+
|
|
88
|
+
mark("start");
|
|
89
|
+
agent = await suppressCursorSdkOutput(() =>
|
|
90
|
+
Agent.create({
|
|
91
|
+
apiKey,
|
|
92
|
+
model: { id: "composer-2.5" },
|
|
93
|
+
local: settingSources
|
|
94
|
+
? { cwd: process.cwd(), settingSources }
|
|
95
|
+
: { cwd: process.cwd() },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
mark("agent.create");
|
|
99
|
+
|
|
100
|
+
let firstDeltaMs;
|
|
101
|
+
const run = await suppressCursorSdkOutput(() =>
|
|
102
|
+
agent.send("Reply with exactly: pong", {
|
|
103
|
+
onDelta: ({ update }) => {
|
|
104
|
+
if (firstDeltaMs === undefined && update.type === "text-delta") {
|
|
105
|
+
firstDeltaMs = Math.round(performance.now() - t0);
|
|
106
|
+
mark("first-delta");
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
mark("agent.send-returned");
|
|
112
|
+
|
|
113
|
+
const result = await suppressCursorSdkOutput(() => run.wait());
|
|
114
|
+
mark("run.wait");
|
|
115
|
+
|
|
116
|
+
await suppressCursorSdkOutput(() => agent[Symbol.asyncDispose]());
|
|
117
|
+
agent = undefined;
|
|
118
|
+
mark("dispose");
|
|
119
|
+
|
|
120
|
+
const sendReturnedMs = marks.find((entry) => entry.name === "agent.send-returned")?.ms;
|
|
121
|
+
const mcpBlockingMs =
|
|
122
|
+
firstDeltaMs !== undefined && sendReturnedMs !== undefined ? firstDeltaMs - sendReturnedMs : undefined;
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
label,
|
|
126
|
+
settingSources: settingSources ?? null,
|
|
127
|
+
installConnectOverride,
|
|
128
|
+
marks,
|
|
129
|
+
firstDeltaMs,
|
|
130
|
+
mcpBlockingMs,
|
|
131
|
+
status: result.status,
|
|
132
|
+
text: typeof result.result === "string" ? result.result.slice(0, 120) : null,
|
|
133
|
+
};
|
|
134
|
+
} finally {
|
|
135
|
+
if (agent) {
|
|
136
|
+
await suppressCursorSdkOutput(() => agent[Symbol.asyncDispose]()).catch(() => undefined);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function runScenarioInThisProcess(args, scenario) {
|
|
142
|
+
const restoreOutputFilter = installCursorSdkOutputFilter();
|
|
143
|
+
try {
|
|
144
|
+
if (scenario.installConnectOverride) {
|
|
145
|
+
const state = installCursorMcpToolTimeoutOverride();
|
|
146
|
+
console.error(
|
|
147
|
+
`probe-mcp-coldstart: installed connect override (${state.connectTimeoutMs}ms initialize/listTools, ${state.timeoutMs}ms callTool)`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const { Agent } = await suppressCursorSdkOutput(() => import("@cursor/sdk"));
|
|
151
|
+
console.log(JSON.stringify(await probe(Agent, args.apiKey, scenario.label, scenario)));
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
+
console.log(
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
label: scenario.label,
|
|
157
|
+
error: scrubSensitiveText(message, args.apiKey),
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
} finally {
|
|
161
|
+
restoreCursorMcpToolTimeoutOverride();
|
|
162
|
+
restoreOutputFilter();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function runScenarioChild(args, scenario) {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
const child = spawn(process.execPath, [SCRIPT_PATH, "--scenario", scenario.label], {
|
|
169
|
+
cwd: process.cwd(),
|
|
170
|
+
env: { ...process.env, CURSOR_API_KEY: args.apiKey },
|
|
171
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
172
|
+
});
|
|
173
|
+
let stdout = "";
|
|
174
|
+
let stderr = "";
|
|
175
|
+
|
|
176
|
+
child.stdout.on("data", (chunk) => {
|
|
177
|
+
stdout += chunk;
|
|
178
|
+
});
|
|
179
|
+
child.stderr.on("data", (chunk) => {
|
|
180
|
+
stderr += chunk;
|
|
181
|
+
});
|
|
182
|
+
child.on("error", (error) => {
|
|
183
|
+
stderr += error instanceof Error ? error.message : String(error);
|
|
184
|
+
});
|
|
185
|
+
child.on("close", (code) => {
|
|
186
|
+
const scrubbedStderr = scrubSensitiveText(stderr, args.apiKey);
|
|
187
|
+
if (scrubbedStderr) process.stderr.write(scrubbedStderr.endsWith("\n") ? scrubbedStderr : `${scrubbedStderr}\n`);
|
|
188
|
+
if (code === 0 && stdout.trim()) {
|
|
189
|
+
process.stdout.write(stdout.endsWith("\n") ? stdout : `${stdout}\n`);
|
|
190
|
+
resolve();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const error = scrubbedStderr.trim() || `child process exited with code ${code ?? "unknown"}`;
|
|
194
|
+
console.log(JSON.stringify({ label: scenario.label, error }));
|
|
195
|
+
resolve();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function main(argv = process.argv.slice(2), env = process.env) {
|
|
201
|
+
const args = parseArgs(argv, env);
|
|
202
|
+
if (args.help) {
|
|
203
|
+
printHelp();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (!args.apiKey) {
|
|
207
|
+
fail("CURSOR_API_KEY is required. Set CURSOR_API_KEY or pass --api-key.");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const scenario = args.scenario ? findScenario(args.scenario) : undefined;
|
|
211
|
+
if (scenario) {
|
|
212
|
+
await runScenarioInThisProcess(args, scenario);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
for (const scenarioToRun of SCENARIOS) {
|
|
217
|
+
await runScenarioChild(args, scenarioToRun);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (import.meta.url === new URL(process.argv[1], "file:").href) {
|
|
222
|
+
main().catch((error) => {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
fail(message, apiKeySecretsFromProcess());
|
|
225
|
+
});
|
|
226
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { basename, resolve } from "node:path";
|
|
4
3
|
import { Cursor } from "@cursor/sdk";
|
|
4
|
+
import { defaultApiKeyFromEnv, parseArgv } from "./lib/cursor-cli-args.mjs";
|
|
5
|
+
import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs";
|
|
6
|
+
import { createScriptFail } from "./lib/cursor-script-fail.mjs";
|
|
5
7
|
|
|
6
8
|
const FALLBACK_MODELS_PATH = "src/cursor-fallback-models.generated.ts";
|
|
7
9
|
const CONTEXT_WINDOWS_PATH = "src/bundled-context-windows.ts";
|
|
@@ -38,70 +40,32 @@ Notes:
|
|
|
38
40
|
requires successful local SDK runs; this script does not start agents.`);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
console.error(`refresh-cursor-snapshots: ${message}`);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
43
|
+
const fail = createScriptFail("refresh-cursor-snapshots");
|
|
45
44
|
|
|
46
|
-
function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
return scrubbed
|
|
52
|
-
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]")
|
|
53
|
-
.replace(/(api[_-]?key|authorization|auth[_-]?token)([\"'\s:=]+)[^\"'\s,}]+/gi, "$1$2[REDACTED]");
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function parseArgs(argv) {
|
|
57
|
-
const args = {
|
|
58
|
-
write: false,
|
|
59
|
-
apiKey: process.env.CURSOR_API_KEY?.trim() || undefined,
|
|
60
|
-
contextWindowsPath: undefined,
|
|
61
|
-
fallbackContextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
62
|
-
};
|
|
63
|
-
for (let index = 0; index < argv.length; index++) {
|
|
64
|
-
const arg = argv[index];
|
|
65
|
-
if (arg === "-h" || arg === "--help") {
|
|
66
|
-
printHelp();
|
|
67
|
-
process.exit(0);
|
|
68
|
-
}
|
|
69
|
-
if (arg === "--write") {
|
|
70
|
-
args.write = true;
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
if (arg === "--api-key") {
|
|
74
|
-
const value = argv[++index];
|
|
75
|
-
if (!value || value.startsWith("--")) fail("--api-key requires a value");
|
|
76
|
-
args.apiKey = value.trim();
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (arg.startsWith("--api-key=")) {
|
|
80
|
-
args.apiKey = arg.slice("--api-key=".length).trim();
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
if (arg === "--context-windows") {
|
|
84
|
-
const value = argv[++index];
|
|
85
|
-
if (!value || value.startsWith("--")) fail("--context-windows requires a file path");
|
|
86
|
-
args.contextWindowsPath = value;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (arg.startsWith("--context-windows=")) {
|
|
90
|
-
args.contextWindowsPath = arg.slice("--context-windows=".length);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (arg === "--fallback-context-window") {
|
|
94
|
-
const value = argv[++index];
|
|
95
|
-
if (!value || value.startsWith("--")) fail("--fallback-context-window requires a positive integer");
|
|
96
|
-
args.fallbackContextWindow = parsePositiveInteger(value, "--fallback-context-window");
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
if (arg.startsWith("--fallback-context-window=")) {
|
|
100
|
-
args.fallbackContextWindow = parsePositiveInteger(arg.slice("--fallback-context-window=".length), "--fallback-context-window");
|
|
101
|
-
continue;
|
|
102
|
-
}
|
|
103
|
-
fail(`unknown argument ${arg}`);
|
|
45
|
+
function parseRefreshArgs(argv) {
|
|
46
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
47
|
+
printHelp();
|
|
48
|
+
process.exit(0);
|
|
104
49
|
}
|
|
50
|
+
const write = argv.includes("--write");
|
|
51
|
+
const filteredArgv = argv.filter((arg) => arg !== "--write");
|
|
52
|
+
const args = parseArgv(filteredArgv, {
|
|
53
|
+
defaults: {
|
|
54
|
+
write,
|
|
55
|
+
apiKey: defaultApiKeyFromEnv(),
|
|
56
|
+
contextWindowsPath: undefined,
|
|
57
|
+
fallbackContextWindow: DEFAULT_CONTEXT_WINDOW,
|
|
58
|
+
},
|
|
59
|
+
flags: {
|
|
60
|
+
apiKey: { names: ["--api-key"], assign: (value) => value.trim() },
|
|
61
|
+
contextWindowsPath: { names: ["--context-windows"] },
|
|
62
|
+
fallbackContextWindow: {
|
|
63
|
+
names: ["--fallback-context-window"],
|
|
64
|
+
assign: (value) => parsePositiveInteger(value, "--fallback-context-window"),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
fail,
|
|
68
|
+
});
|
|
105
69
|
if (!args.apiKey) fail("missing Cursor API key; set CURSOR_API_KEY or pass --api-key");
|
|
106
70
|
return args;
|
|
107
71
|
}
|
|
@@ -200,13 +164,13 @@ function formatContextWindows(models, checkpointWindows, fallbackContextWindow)
|
|
|
200
164
|
return `// Generated from Cursor SDK checkpoint tokenDetails.maxTokens on ${date}.\n// Refresh with: npm run refresh:cursor-snapshots -- --write --context-windows ~/.pi/agent/cursor-sdk-context-windows.json\n// These are default/non-Max-mode SDK context windows for Cursor models that do not\n// expose a catalog \`context\` parameter. Do not replace them with Max Mode values\n// unless the Cursor SDK exposes an exact Max Mode model selection and the extension\n// uses that selection for matching pi model IDs.\nexport const BUNDLED_CONTEXT_WINDOWS = {\n${lines.join("\n")}\n} as const satisfies Record<string, number>;\n`;
|
|
201
165
|
}
|
|
202
166
|
|
|
203
|
-
const args =
|
|
167
|
+
const args = parseRefreshArgs(process.argv.slice(2));
|
|
204
168
|
let rawModels;
|
|
205
169
|
try {
|
|
206
170
|
rawModels = await Cursor.models.list({ apiKey: args.apiKey });
|
|
207
171
|
} catch (error) {
|
|
208
172
|
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
209
|
-
fail(`Cursor.models.list() failed: ${scrubSensitiveText(rawMessage,
|
|
173
|
+
fail(`Cursor.models.list() failed: ${scrubSensitiveText(rawMessage, args.apiKey)}`);
|
|
210
174
|
}
|
|
211
175
|
if (!Array.isArray(rawModels) || rawModels.length === 0) fail("Cursor.models.list() returned no models");
|
|
212
176
|
|
|
@@ -2,13 +2,20 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* RPC steering smoke: queue steer after a native-replay tool-use turn completes execution.
|
|
4
4
|
*/
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { accessSync, chmodSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
8
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { parseJsonLines, terminateChild, waitForChildClose } from "./lib/cursor-child-process.mjs";
|
|
12
|
+
import { apiKeySecretsFromProcess } from "./lib/cursor-cli-args.mjs";
|
|
13
|
+
import { buildCursorSmokeEnv, CURSOR_SDK_EVENT_DEBUG_ENV_NAMES } from "./lib/cursor-smoke-env.mjs";
|
|
14
|
+
import { scrubSensitiveText } from "../shared/cursor-sensitive-text.mjs";
|
|
9
15
|
|
|
10
16
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
11
|
-
const
|
|
17
|
+
const DEBUG_ENV_NAMES = CURSOR_SDK_EVENT_DEBUG_ENV_NAMES;
|
|
18
|
+
const DEFAULT_CHILD_CLOSE_TIMEOUT_MS = 30_000;
|
|
12
19
|
|
|
13
20
|
function printHelp() {
|
|
14
21
|
console.log(`RPC steering smoke for pi-cursor-sdk live runs.
|
|
@@ -18,10 +25,12 @@ Usage:
|
|
|
18
25
|
|
|
19
26
|
Environment:
|
|
20
27
|
SMOKE_SESSION_DIR Session directory for the RPC pi run. Defaults to /tmp/pi-cursor-steer-smoke-<timestamp>.
|
|
21
|
-
|
|
28
|
+
PI_BIN Optional absolute pi executable path. smoke:live injects the parent-resolved path.
|
|
29
|
+
CURSOR_API_KEY Optional fallback auth. Stored pi auth in ~/.pi/agent/auth.json is also supported.
|
|
22
30
|
|
|
23
31
|
Options:
|
|
24
32
|
-h, --help Show this help.
|
|
33
|
+
--self-test Run the fake-PATH resolver probe without launching pi.
|
|
25
34
|
|
|
26
35
|
Exit codes:
|
|
27
36
|
0 steering scenario completed without AgentBusyError; STEER_OK and STEER_CHAIN present
|
|
@@ -37,17 +46,63 @@ function fail(message) {
|
|
|
37
46
|
throw new Error(message);
|
|
38
47
|
}
|
|
39
48
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
for (const
|
|
43
|
-
if (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
function scrubForReport(text) {
|
|
50
|
+
let scrubbed = scrubSensitiveText(text);
|
|
51
|
+
for (const secret of apiKeySecretsFromProcess()) {
|
|
52
|
+
if (secret) scrubbed = scrubSensitiveText(scrubbed, secret);
|
|
53
|
+
}
|
|
54
|
+
return scrubbed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function smokeOutputTail(stdout, stderr) {
|
|
58
|
+
return `stdoutTail=${scrubForReport(stdout.slice(-2000))}\nstderrTail=${scrubForReport(stderr.slice(-2000))}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function waitForChildCloseWithTimeout(child, timeoutMs, outputSummary = () => "") {
|
|
62
|
+
let timeout;
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([
|
|
65
|
+
waitForChildClose(child),
|
|
66
|
+
new Promise((_, reject) => {
|
|
67
|
+
timeout = setTimeout(() => {
|
|
68
|
+
const summary = outputSummary();
|
|
69
|
+
reject(new Error(`pi did not exit within ${timeoutMs}ms after agent_end${summary ? `\n${summary}` : ""}`));
|
|
70
|
+
}, timeoutMs);
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isExecutable(path) {
|
|
79
|
+
try {
|
|
80
|
+
accessSync(path, constants.X_OK);
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveCommand(command, envPath = process.env.PATH ?? "") {
|
|
88
|
+
if (!command.trim()) fail("empty command name");
|
|
89
|
+
if (command.includes("/")) {
|
|
90
|
+
const path = resolve(command);
|
|
91
|
+
if (!isExecutable(path)) fail(`${command} is not executable`);
|
|
92
|
+
return path;
|
|
93
|
+
}
|
|
94
|
+
for (const entry of envPath.split(delimiter)) {
|
|
95
|
+
if (!entry) continue;
|
|
96
|
+
const candidate = resolve(entry, command);
|
|
97
|
+
if (isExecutable(candidate)) return candidate;
|
|
49
98
|
}
|
|
50
|
-
|
|
99
|
+
fail(`${command} is required on PATH`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolvePiBin() {
|
|
103
|
+
const path = process.env.PI_BIN?.trim() || resolveCommand("pi");
|
|
104
|
+
if (!path.startsWith("/")) fail(`PI_BIN must be an absolute path when provided: ${path}`);
|
|
105
|
+
return path;
|
|
51
106
|
}
|
|
52
107
|
|
|
53
108
|
function assistantText(events) {
|
|
@@ -79,7 +134,7 @@ function waitFor(getStdout, predicate, timeoutMs = 300_000) {
|
|
|
79
134
|
const start = Date.now();
|
|
80
135
|
return new Promise((resolve, reject) => {
|
|
81
136
|
const tick = () => {
|
|
82
|
-
const events =
|
|
137
|
+
const events = parseJsonLines(getStdout());
|
|
83
138
|
if (predicate(events)) {
|
|
84
139
|
resolve(events);
|
|
85
140
|
return;
|
|
@@ -98,52 +153,22 @@ function waitFor(getStdout, predicate, timeoutMs = 300_000) {
|
|
|
98
153
|
});
|
|
99
154
|
}
|
|
100
155
|
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
156
|
+
function buildPiRpcEnv(baseEnv = process.env, nodePath = process.execPath) {
|
|
157
|
+
return buildCursorSmokeEnv({
|
|
158
|
+
baseEnv,
|
|
159
|
+
nodePath,
|
|
160
|
+
settingSources: "none",
|
|
161
|
+
nativeToolDisplay: true,
|
|
162
|
+
registerNativeTools: true,
|
|
163
|
+
bridge: false,
|
|
105
164
|
});
|
|
106
165
|
}
|
|
107
166
|
|
|
108
|
-
function
|
|
109
|
-
if (!child.pid) return;
|
|
110
|
-
try {
|
|
111
|
-
if (process.platform === "win32") {
|
|
112
|
-
child.kill(signal);
|
|
113
|
-
} else {
|
|
114
|
-
process.kill(-child.pid, signal);
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
try {
|
|
118
|
-
child.kill(signal);
|
|
119
|
-
} catch {
|
|
120
|
-
// child already exited
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function terminateChild(child) {
|
|
126
|
-
child.stdin.destroy();
|
|
127
|
-
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
128
|
-
signalChild(child, "SIGTERM");
|
|
129
|
-
const killTimer = setTimeout(() => signalChild(child, "SIGKILL"), CHILD_SHUTDOWN_GRACE_MS);
|
|
130
|
-
try {
|
|
131
|
-
await waitForChildClose(child);
|
|
132
|
-
} finally {
|
|
133
|
-
clearTimeout(killTimer);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function runPiRpcSmoke(sessionDir) {
|
|
167
|
+
async function runPiRpcSmoke(sessionDir, piBin) {
|
|
138
168
|
const args = ["-e", root, "--cursor-no-fast", "--model", "cursor/composer-2.5", "--mode", "rpc", "--session-dir", sessionDir];
|
|
139
|
-
const env =
|
|
140
|
-
...process.env,
|
|
141
|
-
PI_CURSOR_SETTING_SOURCES: "none",
|
|
142
|
-
PI_CURSOR_NATIVE_TOOL_DISPLAY: "1",
|
|
143
|
-
PI_CURSOR_PI_TOOL_BRIDGE: "0",
|
|
144
|
-
};
|
|
169
|
+
const env = buildPiRpcEnv();
|
|
145
170
|
|
|
146
|
-
const child = spawn(
|
|
171
|
+
const child = spawn(piBin, args, { cwd: root, env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
147
172
|
let closed = false;
|
|
148
173
|
let stdout = "";
|
|
149
174
|
let stderr = "";
|
|
@@ -191,7 +216,7 @@ async function runPiRpcSmoke(sessionDir) {
|
|
|
191
216
|
fail("AgentBusyError detected in smoke output");
|
|
192
217
|
}
|
|
193
218
|
|
|
194
|
-
const text = assistantText(
|
|
219
|
+
const text = assistantText(parseJsonLines(stdout));
|
|
195
220
|
if (!text.includes("STEER_OK=yes")) {
|
|
196
221
|
fail(`missing STEER_OK=yes in assistant output: ${text.slice(0, 500)}`);
|
|
197
222
|
}
|
|
@@ -200,10 +225,10 @@ async function runPiRpcSmoke(sessionDir) {
|
|
|
200
225
|
}
|
|
201
226
|
|
|
202
227
|
child.stdin.end();
|
|
203
|
-
const exitCode = await
|
|
228
|
+
const exitCode = await waitForChildCloseWithTimeout(child, DEFAULT_CHILD_CLOSE_TIMEOUT_MS, () => smokeOutputTail(stdout, stderr));
|
|
204
229
|
closed = true;
|
|
205
230
|
if (exitCode !== 0) {
|
|
206
|
-
fail(`pi exited ${exitCode}\nstderr=${stderr.slice(-2000)}`);
|
|
231
|
+
fail(`pi exited ${exitCode}\nstderr=${scrubForReport(stderr.slice(-2000))}`);
|
|
207
232
|
}
|
|
208
233
|
|
|
209
234
|
return {
|
|
@@ -217,22 +242,102 @@ async function runPiRpcSmoke(sessionDir) {
|
|
|
217
242
|
}
|
|
218
243
|
}
|
|
219
244
|
|
|
245
|
+
async function runSelfTest() {
|
|
246
|
+
const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-steering-self-test-"));
|
|
247
|
+
try {
|
|
248
|
+
const binDir = join(tempDir, "bin");
|
|
249
|
+
mkdirSync(binDir, { recursive: true });
|
|
250
|
+
const fakePi = join(binDir, "pi");
|
|
251
|
+
const fakeNode = join(binDir, "node");
|
|
252
|
+
const fakeNodeMarker = join(tempDir, "fake-node-used");
|
|
253
|
+
const envCapture = join(tempDir, "fake-pi.env");
|
|
254
|
+
writeFileSync(
|
|
255
|
+
fakePi,
|
|
256
|
+
`#!/usr/bin/env node\nconst { writeFileSync } = require("node:fs");\nwriteFileSync(${JSON.stringify(envCapture)}, Object.entries(process.env).map(([key, value]) => key + "=" + (value ?? "")).join("\\n") + "\\n", "utf8");\n`,
|
|
257
|
+
"utf8",
|
|
258
|
+
);
|
|
259
|
+
writeFileSync(fakeNode, `#!/bin/sh\necho fake-node-used > ${JSON.stringify(fakeNodeMarker)}\nexit 99\n`, "utf8");
|
|
260
|
+
chmodSync(fakePi, 0o755);
|
|
261
|
+
chmodSync(fakeNode, 0o755);
|
|
262
|
+
const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
|
|
263
|
+
if (buildPiRpcEnv({ PATH: "" }).PATH?.includes(delimiter)) fail("self-test failed: empty inherited PATH left an empty PATH segment");
|
|
264
|
+
if (resolveCommand("pi", hostilePath) !== fakePi) fail("self-test failed: direct PATH resolver did not prefer fake PATH head");
|
|
265
|
+
const originalPiBin = process.env.PI_BIN;
|
|
266
|
+
const originalPath = process.env.PATH;
|
|
267
|
+
try {
|
|
268
|
+
delete process.env.PI_BIN;
|
|
269
|
+
process.env.PATH = hostilePath;
|
|
270
|
+
if (resolvePiBin() !== fakePi) fail("self-test failed: resolvePiBin should use PATH when PI_BIN is absent");
|
|
271
|
+
process.env.PI_BIN = fakePi;
|
|
272
|
+
if (resolvePiBin() !== fakePi) fail("self-test failed: resolvePiBin should honor absolute PI_BIN");
|
|
273
|
+
const hostileEnv = buildPiRpcEnv({
|
|
274
|
+
...Object.fromEntries(DEBUG_ENV_NAMES.map((name) => [name, join(tempDir, name)])),
|
|
275
|
+
PATH: hostilePath,
|
|
276
|
+
PI_CURSOR_REGISTER_NATIVE_TOOLS: "0",
|
|
277
|
+
PI_CURSOR_SETTING_SOURCES: "all",
|
|
278
|
+
PI_CURSOR_PI_TOOL_BRIDGE: "1",
|
|
279
|
+
});
|
|
280
|
+
if ((hostileEnv.PATH ?? "").split(delimiter)[0] !== dirname(process.execPath)) fail("self-test failed: sealed PATH should start with resolved node directory");
|
|
281
|
+
if (hostileEnv.PI_CURSOR_REGISTER_NATIVE_TOOLS !== "1") fail("self-test failed: native registration should be forced on");
|
|
282
|
+
if (hostileEnv.PI_CURSOR_SETTING_SOURCES !== "none") fail("self-test failed: setting sources should be forced off");
|
|
283
|
+
if (hostileEnv.PI_CURSOR_PI_TOOL_BRIDGE !== "0") fail("self-test failed: bridge should be forced off");
|
|
284
|
+
for (const name of DEBUG_ENV_NAMES) {
|
|
285
|
+
if (name in hostileEnv) fail(`self-test failed: ${name} should be cleared`);
|
|
286
|
+
}
|
|
287
|
+
const probe = spawnSync(fakePi, ["--version"], { cwd: root, env: hostileEnv, encoding: "utf8" });
|
|
288
|
+
if (probe.status !== 0) fail(`self-test failed: fake pi shim exited ${probe.status}; stderr=${probe.stderr}`);
|
|
289
|
+
let fakeNodeUsed = false;
|
|
290
|
+
try {
|
|
291
|
+
accessSync(fakeNodeMarker, constants.F_OK);
|
|
292
|
+
fakeNodeUsed = true;
|
|
293
|
+
} catch {
|
|
294
|
+
// Expected: sealed PATH should keep /usr/bin/env node away from the hostile fake node.
|
|
295
|
+
}
|
|
296
|
+
if (fakeNodeUsed) fail("self-test failed: steering child env used hostile fake node");
|
|
297
|
+
const hangingChild = new EventEmitter();
|
|
298
|
+
hangingChild.exitCode = null;
|
|
299
|
+
hangingChild.signalCode = null;
|
|
300
|
+
let closeTimedOut = false;
|
|
301
|
+
try {
|
|
302
|
+
await waitForChildCloseWithTimeout(hangingChild, 10, () => smokeOutputTail("STEER_OK=yes", ""));
|
|
303
|
+
} catch (error) {
|
|
304
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
305
|
+
closeTimedOut = message.includes("pi did not exit within 10ms after agent_end") && message.includes("stdoutTail=STEER_OK=yes");
|
|
306
|
+
}
|
|
307
|
+
if (!closeTimedOut) fail("self-test failed: post-agent_end child close wait should be bounded and report output tail");
|
|
308
|
+
} finally {
|
|
309
|
+
if (originalPiBin === undefined) delete process.env.PI_BIN;
|
|
310
|
+
else process.env.PI_BIN = originalPiBin;
|
|
311
|
+
if (originalPath === undefined) delete process.env.PATH;
|
|
312
|
+
else process.env.PATH = originalPath;
|
|
313
|
+
}
|
|
314
|
+
console.log("[steering-rpc-smoke] self-test PASS");
|
|
315
|
+
} finally {
|
|
316
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
220
320
|
async function main() {
|
|
221
321
|
if (process.argv.includes("-h") || process.argv.includes("--help")) {
|
|
222
322
|
printHelp();
|
|
223
323
|
return;
|
|
224
324
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
325
|
+
if (process.argv.includes("--self-test")) {
|
|
326
|
+
await runSelfTest();
|
|
327
|
+
return;
|
|
228
328
|
}
|
|
229
329
|
|
|
230
330
|
const sessionDir = process.env.SMOKE_SESSION_DIR ?? join("/tmp", `pi-cursor-steer-smoke-${Date.now()}`);
|
|
331
|
+
const piBin = resolvePiBin();
|
|
231
332
|
mkdirSync(sessionDir, { recursive: true });
|
|
232
|
-
console.log(JSON.stringify(await runPiRpcSmoke(sessionDir)));
|
|
333
|
+
console.log(JSON.stringify(await runPiRpcSmoke(sessionDir, piBin)));
|
|
233
334
|
}
|
|
234
335
|
|
|
235
336
|
main().catch((error) => {
|
|
236
|
-
|
|
337
|
+
let scrubbed = scrubSensitiveText(error instanceof Error ? error.message : String(error));
|
|
338
|
+
for (const secret of apiKeySecretsFromProcess()) {
|
|
339
|
+
if (secret) scrubbed = scrubSensitiveText(scrubbed, secret);
|
|
340
|
+
}
|
|
341
|
+
console.error(scrubbed);
|
|
237
342
|
process.exitCode = 1;
|
|
238
343
|
});
|