pi-cursor-sdk 0.1.56 → 0.1.57

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +100 -28
  3. package/docs/cursor-live-smoke-checklist.md +7 -7
  4. package/docs/cursor-model-ux-spec.md +45 -38
  5. package/docs/cursor-native-tool-replay.md +2 -2
  6. package/docs/cursor-native-tool-visual-audit.md +1 -1
  7. package/docs/cursor-testing-lessons.md +20 -7
  8. package/docs/cursor-tool-surfaces.md +13 -2
  9. package/docs/platform-smoke-implementation.md +220 -0
  10. package/docs/platform-smoke.md +183 -247
  11. package/package.json +38 -6
  12. package/platform-smoke.config.mjs +5 -1
  13. package/scripts/cloud-runtime-smoke.d.mts +3 -0
  14. package/scripts/cloud-runtime-smoke.mjs +502 -0
  15. package/scripts/debug-provider-events.mjs +7 -2
  16. package/scripts/isolated-cursor-smoke.sh +4 -6
  17. package/scripts/lib/cursor-child-process.d.mts +1 -0
  18. package/scripts/lib/cursor-child-process.mjs +137 -7
  19. package/scripts/lib/local-resume-smoke-harness.mjs +543 -0
  20. package/scripts/local-resume-cleanup-smoke.mjs +108 -0
  21. package/scripts/local-resume-smoke.d.mts +1 -0
  22. package/scripts/local-resume-smoke.mjs +642 -0
  23. package/scripts/platform-smoke/artifact-anchored-extract.d.mts +10 -0
  24. package/scripts/platform-smoke/artifact-anchored-extract.mjs +111 -0
  25. package/scripts/platform-smoke/artifact-bundle-chunk.mjs +57 -0
  26. package/scripts/platform-smoke/artifact-bundle-contract.mjs +34 -0
  27. package/scripts/platform-smoke/artifact-fs-safety.mjs +311 -0
  28. package/scripts/platform-smoke/artifact-openat-extract.c +335 -0
  29. package/scripts/platform-smoke/artifact-secrets.mjs +155 -0
  30. package/scripts/platform-smoke/artifacts.mjs +293 -65
  31. package/scripts/platform-smoke/card-detect.mjs +16 -4
  32. package/scripts/platform-smoke/crabbox-runner.mjs +45 -3
  33. package/scripts/platform-smoke/doctor.mjs +20 -10
  34. package/scripts/platform-smoke/live-suite-runner.mjs +18 -57
  35. package/scripts/platform-smoke/local-resume-runner.mjs +252 -0
  36. package/scripts/platform-smoke/local-resume-suites.d.mts +15 -0
  37. package/scripts/platform-smoke/local-resume-suites.mjs +104 -0
  38. package/scripts/platform-smoke/scenarios.mjs +16 -2
  39. package/scripts/platform-smoke/target-runtime.mjs +206 -0
  40. package/scripts/platform-smoke/targets.mjs +33 -141
  41. package/scripts/platform-smoke/visual-evidence.mjs +6 -7
  42. package/scripts/platform-smoke/wrapped-line-match.mjs +9 -0
  43. package/scripts/platform-smoke.mjs +40 -27
  44. package/scripts/refresh-cursor-model-snapshots.mjs +18 -6
  45. package/scripts/steering-rpc-smoke.mjs +12 -2
  46. package/scripts/tmux-live-smoke.sh +3 -5
  47. package/shared/cursor-cloud-lifecycle-constants.d.mts +3 -0
  48. package/shared/cursor-cloud-lifecycle-constants.mjs +7 -0
  49. package/shared/cursor-sensitive-text.mjs +7 -1
  50. package/src/context.ts +5 -2
  51. package/src/cursor-agents-context-registration.ts +7 -0
  52. package/src/cursor-agents-context.ts +3 -1
  53. package/src/cursor-api-key.ts +15 -1
  54. package/src/cursor-bridge-contract.ts +3 -0
  55. package/src/cursor-cloud-lifecycle.ts +733 -0
  56. package/src/cursor-cloud-options.ts +206 -0
  57. package/src/cursor-cloud-reporting.ts +246 -0
  58. package/src/cursor-config.ts +659 -0
  59. package/src/cursor-display-only-trace.ts +14 -0
  60. package/src/cursor-display-text.ts +8 -2
  61. package/src/cursor-durable-fs.ts +49 -0
  62. package/src/cursor-fallback-models.generated.ts +2045 -485
  63. package/src/cursor-live-run-accounting.ts +7 -1
  64. package/src/cursor-live-run-coordinator.ts +1 -0
  65. package/src/cursor-pi-tool-bridge-run.ts +14 -4
  66. package/src/cursor-provider-live-run-drain.ts +5 -0
  67. package/src/cursor-provider-run-finalizer.ts +36 -28
  68. package/src/cursor-provider-turn-finalize.ts +72 -6
  69. package/src/cursor-provider-turn-prepare.ts +228 -12
  70. package/src/cursor-provider-turn-runner.ts +41 -13
  71. package/src/cursor-provider-turn-send.ts +59 -16
  72. package/src/cursor-provider-turn-types.ts +44 -10
  73. package/src/cursor-runtime-state.ts +478 -0
  74. package/src/cursor-sdk-event-debug.ts +46 -6
  75. package/src/cursor-sdk-process-error-guard.ts +101 -30
  76. package/src/cursor-session-agent-cleanup.ts +328 -0
  77. package/src/cursor-session-agent-resume.ts +439 -0
  78. package/src/cursor-session-agent.ts +109 -13
  79. package/src/cursor-session-scope.ts +35 -2
  80. package/src/cursor-session-send-policy.ts +1 -1
  81. package/src/cursor-skill-tool.ts +30 -11
  82. package/src/cursor-state.ts +112 -69
  83. package/src/cursor-usage-accounting.ts +14 -4
  84. package/src/index.ts +11 -2
  85. package/src/model-discovery.ts +10 -56
@@ -0,0 +1,659 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { parseOptionalEnvBoolean } from "./cursor-env-boolean.js";
6
+ import { asRecord } from "./cursor-record-utils.js";
7
+
8
+ export const CURSOR_SDK_CONFIG_FILE = "cursor-sdk.json";
9
+
10
+ export const CURSOR_RUNTIME_ENV = "PI_CURSOR_RUNTIME";
11
+ export const CURSOR_CLOUD_REPO_ENV = "PI_CURSOR_CLOUD_REPO";
12
+ export const CURSOR_CLOUD_BRANCH_ENV = "PI_CURSOR_CLOUD_BRANCH";
13
+ export const CURSOR_CLOUD_CONTEXT_ENV = "PI_CURSOR_CLOUD_CONTEXT";
14
+ export const CURSOR_CLOUD_DIRECT_PUSH_ENV = "PI_CURSOR_CLOUD_DIRECT_PUSH";
15
+ export const CURSOR_CLOUD_ALLOW_LOCAL_STATE_ENV = "PI_CURSOR_CLOUD_ALLOW_LOCAL_STATE";
16
+ export const CURSOR_CLOUD_ENV_ENV = "PI_CURSOR_CLOUD_ENV";
17
+ export const CURSOR_CLOUD_ENV_FROM_FILES_ENV = "PI_CURSOR_CLOUD_ENV_FROM_FILES";
18
+ export const CURSOR_CLOUD_ENV_TYPE_ENV = "PI_CURSOR_CLOUD_ENV_TYPE";
19
+ export const CURSOR_CLOUD_ENV_NAME_ENV = "PI_CURSOR_CLOUD_ENV_NAME";
20
+ export const CURSOR_CLOUD_ACK_ENV = "PI_CURSOR_CLOUD_ACK";
21
+ export const CURSOR_AUTO_REVIEW_ENV = "PI_CURSOR_AUTO_REVIEW";
22
+ export const CURSOR_SANDBOX_ENV = "PI_CURSOR_SANDBOX";
23
+ export const CURSOR_LOCAL_FORCE_ENV = "PI_CURSOR_LOCAL_FORCE";
24
+ export const CURSOR_LOCAL_RESUME_ENV = "PI_CURSOR_LOCAL_RESUME";
25
+
26
+ export type CursorConfigSource = "cli" | "environment" | "project" | "user" | "session" | "model-alias" | "builtin";
27
+ export type CursorConfigTrustLevel = "one-shot" | "environment" | "trusted-project" | "user" | "session" | "model-catalog" | "builtin";
28
+ export type CursorRuntime = "local" | "cloud";
29
+ export type CursorCloudContextHandoff = "never" | "fresh" | "bootstrap";
30
+ export type CursorCloudEnvironmentType = "cloud" | "pool" | "machine";
31
+
32
+ export interface CursorCloudEnvironmentConfig {
33
+ type?: CursorCloudEnvironmentType | string;
34
+ name?: string;
35
+ }
36
+
37
+ export interface CursorSdkConfig {
38
+ fastDefaults?: Record<string, boolean>;
39
+ runtime?: CursorRuntime;
40
+ cloud?: {
41
+ repo?: string;
42
+ branch?: string;
43
+ contextHandoff?: CursorCloudContextHandoff;
44
+ directPush?: boolean;
45
+ allowLocalState?: boolean;
46
+ envNames?: string[];
47
+ envFromFiles?: boolean;
48
+ environment?: CursorCloudEnvironmentConfig;
49
+ acknowledged?: boolean;
50
+ };
51
+ local?: {
52
+ autoReview?: boolean;
53
+ sandbox?: boolean;
54
+ sandboxOptions?: {
55
+ enabled?: boolean;
56
+ };
57
+ force?: boolean;
58
+ resume?: boolean;
59
+ };
60
+ }
61
+
62
+ export interface CursorSafetyCap<T> {
63
+ source: CursorConfigSource;
64
+ trustLevel: CursorConfigTrustLevel;
65
+ value: T;
66
+ cappedSource: CursorConfigSource;
67
+ cappedValue: T;
68
+ reason: "safer-source";
69
+ }
70
+
71
+ export interface CursorResolvedSetting<T> {
72
+ value: T;
73
+ source: CursorConfigSource;
74
+ trustLevel: CursorConfigTrustLevel;
75
+ cappedBy?: CursorSafetyCap<T>;
76
+ }
77
+
78
+ export interface CursorResolvedSdkConfig {
79
+ runtime: CursorResolvedSetting<CursorRuntime>;
80
+ cloud: {
81
+ repo: CursorResolvedSetting<string | undefined>;
82
+ branch: CursorResolvedSetting<string | undefined>;
83
+ contextHandoff: CursorResolvedSetting<CursorCloudContextHandoff>;
84
+ directPush: CursorResolvedSetting<boolean>;
85
+ allowLocalState: CursorResolvedSetting<boolean>;
86
+ envNames: CursorResolvedSetting<string[]>;
87
+ envFromFiles: CursorResolvedSetting<boolean>;
88
+ environment: CursorResolvedSetting<CursorCloudEnvironmentConfig | undefined>;
89
+ acknowledged: CursorResolvedSetting<boolean>;
90
+ };
91
+ local: {
92
+ autoReview: CursorResolvedSetting<boolean>;
93
+ sandboxEnabled: CursorResolvedSetting<boolean>;
94
+ force: CursorResolvedSetting<boolean>;
95
+ resume: CursorResolvedSetting<boolean>;
96
+ };
97
+ }
98
+
99
+ // Widens string-literal-union fields (e.g. runtime, contextHandoff) to raw `string` so CLI callers can
100
+ // pass unvalidated input through to validateExplicitValue, while keeping every other field's shape linked
101
+ // to CursorSdkConfig so new fields don't need a manually maintained parallel type.
102
+ type WidenLiterals<T> = T extends string
103
+ ? string
104
+ : T extends readonly (infer U)[]
105
+ ? WidenLiterals<U>[]
106
+ : T extends object
107
+ ? { [K in keyof T]: WidenLiterals<T[K]> }
108
+ : T;
109
+
110
+ export type CursorExplicitSdkConfig = WidenLiterals<CursorSdkConfig>;
111
+
112
+ export interface ResolveCursorSdkConfigOptions {
113
+ cli?: CursorExplicitSdkConfig;
114
+ env?: Record<string, string | undefined>;
115
+ session?: CursorSdkConfig;
116
+ project?: CursorSdkConfig;
117
+ user?: CursorSdkConfig;
118
+ builtIn?: CursorSdkConfig;
119
+ }
120
+
121
+ export interface LoadCursorSdkConfigOptions {
122
+ cwd?: string;
123
+ projectTrusted?: boolean;
124
+ agentDir?: string;
125
+ }
126
+
127
+ const TRUST_LEVELS: Record<CursorConfigSource, CursorConfigTrustLevel> = {
128
+ cli: "one-shot",
129
+ environment: "environment",
130
+ project: "trusted-project",
131
+ user: "user",
132
+ session: "session",
133
+ "model-alias": "model-catalog",
134
+ builtin: "builtin",
135
+ };
136
+
137
+ const BUILT_IN_CURSOR_CONFIG: Required<Pick<CursorSdkConfig, "runtime">> & {
138
+ cloud: Required<NonNullable<CursorSdkConfig["cloud"]>>;
139
+ } = {
140
+ runtime: "local",
141
+ cloud: {
142
+ repo: "",
143
+ branch: "",
144
+ contextHandoff: "fresh",
145
+ directPush: false,
146
+ allowLocalState: false,
147
+ envNames: [],
148
+ envFromFiles: false,
149
+ environment: {},
150
+ acknowledged: false,
151
+ },
152
+ };
153
+
154
+ export function isCursorRuntime(value: unknown): value is CursorRuntime {
155
+ return value === "local" || value === "cloud";
156
+ }
157
+
158
+ export function isCursorCloudContextHandoff(value: unknown): value is CursorCloudContextHandoff {
159
+ return value === "never" || value === "fresh" || value === "bootstrap";
160
+ }
161
+
162
+ function validateExplicitValue<T extends string>(
163
+ raw: string | undefined,
164
+ name: string,
165
+ isValid: (value: unknown) => value is T,
166
+ validValues: string,
167
+ ): T | undefined {
168
+ const value = raw?.trim();
169
+ if (!value) return undefined;
170
+ if (!isValid(value)) throw new Error(`Invalid ${name} "${value}". Use ${validValues}.`);
171
+ return value;
172
+ }
173
+
174
+ export function isCursorCloudEnvironmentType(value: unknown): value is CursorCloudEnvironmentType {
175
+ return value === "cloud" || value === "pool" || value === "machine";
176
+ }
177
+
178
+ function parseNonEmptyString(value: unknown): string | undefined {
179
+ if (typeof value !== "string") return undefined;
180
+ const trimmed = value.trim();
181
+ return trimmed ? trimmed : undefined;
182
+ }
183
+
184
+ function isAllowedEnvName(value: unknown): value is string {
185
+ return typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value) && !value.startsWith("CURSOR_");
186
+ }
187
+
188
+ function parseEnvNames(value: unknown): string[] | undefined {
189
+ const names = Array.isArray(value)
190
+ ? value
191
+ : typeof value === "string"
192
+ ? value.split(",")
193
+ : undefined;
194
+ if (!names) return undefined;
195
+ const parsed = [...new Set(names.map((name) => (typeof name === "string" ? name.trim() : "")).filter(isAllowedEnvName))];
196
+ return parsed.length > 0 || (Array.isArray(value) && value.length === 0) ? parsed : undefined;
197
+ }
198
+
199
+ export function parseExplicitCursorCloudEnvNames(value: string | undefined, name: string): string[] | undefined {
200
+ const request = parseNonEmptyString(value);
201
+ if (!request) return undefined;
202
+ const parsed = parseEnvNames(request);
203
+ if (!parsed?.length) throw new Error(`Invalid ${name}: no valid environment variable names were requested.`);
204
+ return parsed;
205
+ }
206
+
207
+ function parseCloudEnvironment(value: unknown): CursorCloudEnvironmentConfig | undefined {
208
+ const environment = asRecord(value);
209
+ if (!environment) return undefined;
210
+ const parsed: CursorCloudEnvironmentConfig = {};
211
+ const rawType = parseNonEmptyString(environment.type);
212
+ const name = parseNonEmptyString(environment.name);
213
+ if (rawType) parsed.type = rawType;
214
+ if (name) parsed.name = name;
215
+ return Object.keys(parsed).length > 0 ? parsed : undefined;
216
+ }
217
+
218
+ export function parseCursorSdkConfig(value: unknown): CursorSdkConfig | undefined {
219
+ const record = asRecord(value);
220
+ if (!record) return undefined;
221
+ const config: CursorSdkConfig = {};
222
+
223
+ if (isCursorRuntime(record.runtime)) config.runtime = record.runtime;
224
+
225
+ const fastDefaults = asRecord(record.fastDefaults);
226
+ if (fastDefaults) {
227
+ config.fastDefaults = Object.fromEntries(
228
+ Object.entries(fastDefaults).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"),
229
+ );
230
+ }
231
+
232
+ const cloud = asRecord(record.cloud);
233
+ if (cloud) {
234
+ const parsedCloud: NonNullable<CursorSdkConfig["cloud"]> = {};
235
+ const repo = parseNonEmptyString(cloud.repo);
236
+ const branch = parseNonEmptyString(cloud.branch);
237
+ const envNames = parseEnvNames(cloud.envNames);
238
+ const environment = parseCloudEnvironment(cloud.environment);
239
+ if (repo) parsedCloud.repo = repo;
240
+ if (branch) parsedCloud.branch = branch;
241
+ if (isCursorCloudContextHandoff(cloud.contextHandoff)) parsedCloud.contextHandoff = cloud.contextHandoff;
242
+ if (typeof cloud.directPush === "boolean") parsedCloud.directPush = cloud.directPush;
243
+ if (typeof cloud.allowLocalState === "boolean") parsedCloud.allowLocalState = cloud.allowLocalState;
244
+ if (envNames) parsedCloud.envNames = envNames;
245
+ if (typeof cloud.envFromFiles === "boolean") parsedCloud.envFromFiles = cloud.envFromFiles;
246
+ if (environment) parsedCloud.environment = environment;
247
+ if (typeof cloud.acknowledged === "boolean") parsedCloud.acknowledged = cloud.acknowledged;
248
+ if (Object.keys(parsedCloud).length > 0) config.cloud = parsedCloud;
249
+ }
250
+
251
+ const local = asRecord(record.local);
252
+ if (local) {
253
+ const parsedLocal: NonNullable<CursorSdkConfig["local"]> = {};
254
+ if (typeof local.autoReview === "boolean") parsedLocal.autoReview = local.autoReview;
255
+ if (typeof local.sandbox === "boolean") parsedLocal.sandbox = local.sandbox;
256
+ if (typeof local.force === "boolean") parsedLocal.force = local.force;
257
+ if (typeof local.resume === "boolean") parsedLocal.resume = local.resume;
258
+ const sandboxOptions = asRecord(local.sandboxOptions);
259
+ if (typeof sandboxOptions?.enabled === "boolean") parsedLocal.sandboxOptions = { enabled: sandboxOptions.enabled };
260
+ if (Object.keys(parsedLocal).length > 0) config.local = parsedLocal;
261
+ }
262
+
263
+ return config;
264
+ }
265
+
266
+ export function getCursorSdkUserConfigPath(agentDir = getAgentDir()): string {
267
+ return join(agentDir, CURSOR_SDK_CONFIG_FILE);
268
+ }
269
+
270
+ export function getCursorSdkProjectConfigPath(cwd: string, configDirName = CONFIG_DIR_NAME): string {
271
+ return join(cwd, configDirName, CURSOR_SDK_CONFIG_FILE);
272
+ }
273
+
274
+ function readCursorSdkConfigFile(path: string): CursorSdkConfig {
275
+ if (!existsSync(path)) return {};
276
+ try {
277
+ return parseCursorSdkConfig(JSON.parse(readFileSync(path, "utf-8"))) ?? {};
278
+ } catch {
279
+ return {};
280
+ }
281
+ }
282
+
283
+ export function loadCursorSdkUserConfig(path = getCursorSdkUserConfigPath()): CursorSdkConfig {
284
+ return readCursorSdkConfigFile(path);
285
+ }
286
+
287
+ export function loadCursorSdkProjectConfig(cwd: string, projectTrusted: boolean): CursorSdkConfig | undefined {
288
+ if (!projectTrusted) return undefined;
289
+ const path = getCursorSdkProjectConfigPath(cwd);
290
+ return existsSync(path) ? readCursorSdkConfigFile(path) : undefined;
291
+ }
292
+
293
+ export function loadCursorSdkConfig(options: LoadCursorSdkConfigOptions = {}): { user: CursorSdkConfig; project?: CursorSdkConfig } {
294
+ const user = loadCursorSdkUserConfig(getCursorSdkUserConfigPath(options.agentDir));
295
+ const project = options.cwd ? loadCursorSdkProjectConfig(options.cwd, options.projectTrusted === true) : undefined;
296
+ return project ? { user, project } : { user };
297
+ }
298
+
299
+ function replaceJsonFile(path: string, config: CursorSdkConfig, mode?: number): void {
300
+ mkdirSync(dirname(path), { recursive: true });
301
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
302
+ let replaced = false;
303
+ try {
304
+ writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, {
305
+ encoding: "utf8",
306
+ flag: "wx",
307
+ ...(mode === undefined ? {} : { mode }),
308
+ });
309
+ if (mode !== undefined) chmodSync(tempPath, mode);
310
+ renameSync(tempPath, path);
311
+ replaced = true;
312
+ } finally {
313
+ if (!replaced) {
314
+ try {
315
+ rmSync(tempPath, { force: true });
316
+ } catch {
317
+ // Keep the replacement failure; cleanup is best effort.
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ export function saveCursorSdkUserConfig(config: CursorSdkConfig, path = getCursorSdkUserConfigPath()): void {
324
+ const mode = existsSync(path) ? statSync(path).mode & 0o777 : 0o600;
325
+ replaceJsonFile(path, config, mode);
326
+ }
327
+
328
+ export function saveCursorSdkProjectConfig(cwd: string, config: CursorSdkConfig, configDirName = CONFIG_DIR_NAME): void {
329
+ const path = getCursorSdkProjectConfigPath(cwd, configDirName);
330
+ const mode = existsSync(path) ? statSync(path).mode & 0o777 : undefined;
331
+ replaceJsonFile(path, config, mode);
332
+ }
333
+
334
+ export function mergeCursorSdkConfig(base: CursorSdkConfig, patch: CursorSdkConfig): CursorSdkConfig {
335
+ return {
336
+ ...base,
337
+ ...patch,
338
+ ...(base.cloud || patch.cloud
339
+ ? { cloud: { ...base.cloud, ...patch.cloud } }
340
+ : {}),
341
+ ...(base.local || patch.local
342
+ ? {
343
+ local: {
344
+ ...base.local,
345
+ ...patch.local,
346
+ ...(base.local?.sandboxOptions || patch.local?.sandboxOptions
347
+ ? { sandboxOptions: { ...base.local?.sandboxOptions, ...patch.local?.sandboxOptions } }
348
+ : {}),
349
+ },
350
+ }
351
+ : {}),
352
+ };
353
+ }
354
+
355
+ export function cursorFastDefaultsFromConfig(config: CursorSdkConfig | undefined): Map<string, boolean> {
356
+ return new Map(Object.entries(config?.fastDefaults ?? {}));
357
+ }
358
+
359
+ export function withCursorFastDefaults(config: CursorSdkConfig, fastDefaults: Map<string, boolean>): CursorSdkConfig {
360
+ return {
361
+ ...config,
362
+ fastDefaults: Object.fromEntries([...fastDefaults.entries()].sort(([a], [b]) => a.localeCompare(b))),
363
+ };
364
+ }
365
+
366
+ function resolved<T>(source: CursorConfigSource, value: T, cappedBy?: CursorSafetyCap<T>): CursorResolvedSetting<T> {
367
+ return { value, source, trustLevel: TRUST_LEVELS[source], ...(cappedBy ? { cappedBy } : {}) };
368
+ }
369
+
370
+ function valueFrom<T>(source: CursorConfigSource, value: T | undefined): CursorResolvedSetting<T> | undefined {
371
+ return value === undefined ? undefined : resolved(source, value);
372
+ }
373
+
374
+ function resolveOrdinary<T>(layers: Array<CursorResolvedSetting<T> | undefined>): CursorResolvedSetting<T> {
375
+ const match = layers.find((layer): layer is CursorResolvedSetting<T> => layer !== undefined);
376
+ if (!match) throw new Error("Cursor config resolver missing built-in default");
377
+ return match;
378
+ }
379
+
380
+ // One cap engine for every safety-capped field. `cap` receives the candidate cap layer's raw value and the
381
+ // resolved base setting; it returns the value to use once capped, or undefined to mean "don't cap". CLI is
382
+ // always uncapped (one-shot, explicit operator intent).
383
+ function resolveWithCap<T>(
384
+ baseLayers: Array<CursorResolvedSetting<T> | undefined>,
385
+ capLayer: CursorResolvedSetting<T> | undefined,
386
+ cap: (candidate: T, base: CursorResolvedSetting<T>) => T | undefined,
387
+ ): CursorResolvedSetting<T> {
388
+ const cli = baseLayers[0];
389
+ if (cli) return cli;
390
+ const base = resolveOrdinary(baseLayers.slice(1));
391
+ if (!capLayer) return base;
392
+ const cappedValue = cap(capLayer.value, base);
393
+ if (cappedValue === undefined) return base;
394
+ return resolved(capLayer.source, cappedValue, {
395
+ source: capLayer.source,
396
+ trustLevel: capLayer.trustLevel,
397
+ value: cappedValue,
398
+ cappedSource: base.source,
399
+ cappedValue: base.value,
400
+ reason: "safer-source",
401
+ });
402
+ }
403
+
404
+ function scalarRiskCap<T>(risk: (value: T) => number): (candidate: T, base: CursorResolvedSetting<T>) => T | undefined {
405
+ return (candidate, base) => (risk(candidate) < risk(base.value) ? candidate : undefined);
406
+ }
407
+
408
+ function envNamesCap(candidate: string[], base: CursorResolvedSetting<string[]>): string[] | undefined {
409
+ if (base.source === "user") return undefined;
410
+ const allowed = new Set(candidate);
411
+ const filtered = base.value.filter((name) => allowed.has(name));
412
+ return filtered.length === base.value.length ? undefined : filtered;
413
+ }
414
+
415
+ // Field/layer resolver table: each field declares the ordered list of sources it participates in (its
416
+ // precedence), plus its per-layer values. Sources omitted from a field's order (e.g. cloud fields skip
417
+ // "project", local fields skip "session", force skips both) are the field-specific omissions the review
418
+ // asked to keep explicit; test/cursor-config.test.ts asserts each one.
419
+ type CursorFieldSource = Exclude<CursorConfigSource, "model-alias">;
420
+ type CursorFieldValues<T> = Partial<Record<CursorFieldSource, T>>;
421
+
422
+ const RUNTIME_ORDER: CursorFieldSource[] = ["cli", "environment", "session", "project", "user", "builtin"];
423
+ const CLOUD_ORDER: CursorFieldSource[] = ["cli", "environment", "session", "user", "builtin"];
424
+ const LOCAL_ORDER: CursorFieldSource[] = ["cli", "environment", "project", "user", "builtin"];
425
+ const LOCAL_FORCE_ORDER: CursorFieldSource[] = ["cli", "environment", "builtin"];
426
+
427
+ function buildFieldLayers<T>(order: CursorFieldSource[], values: CursorFieldValues<T>): Array<CursorResolvedSetting<T> | undefined> {
428
+ return order.map((source) => (source === "builtin" ? resolved("builtin", values.builtin as T) : valueFrom(source, values[source])));
429
+ }
430
+
431
+ function resolveOrdinaryField<T>(order: CursorFieldSource[], values: CursorFieldValues<T>): CursorResolvedSetting<T> {
432
+ return resolveOrdinary(buildFieldLayers(order, values));
433
+ }
434
+
435
+ function resolveSafetyField<T>(order: CursorFieldSource[], values: CursorFieldValues<T>, risk: (value: T) => number): CursorResolvedSetting<T> {
436
+ return resolveWithCap(buildFieldLayers(order, values), valueFrom("user", values.user), scalarRiskCap(risk));
437
+ }
438
+
439
+ function resolveEnvNamesSafetyField(order: CursorFieldSource[], values: CursorFieldValues<string[]>): CursorResolvedSetting<string[]> {
440
+ return resolveWithCap(buildFieldLayers(order, values), valueFrom("user", values.user), envNamesCap);
441
+ }
442
+
443
+ export function cursorSdkConfigFromEnv(env: Record<string, string | undefined> = process.env): CursorSdkConfig {
444
+ const config: CursorSdkConfig = {};
445
+ const runtime = validateExplicitValue(env[CURSOR_RUNTIME_ENV], CURSOR_RUNTIME_ENV, isCursorRuntime, '"local" or "cloud"');
446
+ if (runtime) config.runtime = runtime;
447
+ const repo = parseNonEmptyString(env[CURSOR_CLOUD_REPO_ENV]);
448
+ const branch = parseNonEmptyString(env[CURSOR_CLOUD_BRANCH_ENV]);
449
+ const contextHandoff = validateExplicitValue(
450
+ env[CURSOR_CLOUD_CONTEXT_ENV],
451
+ CURSOR_CLOUD_CONTEXT_ENV,
452
+ isCursorCloudContextHandoff,
453
+ '"never", "fresh", or "bootstrap"',
454
+ );
455
+ const directPush = parseOptionalEnvBoolean(env[CURSOR_CLOUD_DIRECT_PUSH_ENV]);
456
+ const allowLocalState = parseOptionalEnvBoolean(env[CURSOR_CLOUD_ALLOW_LOCAL_STATE_ENV]);
457
+ const envNames = parseExplicitCursorCloudEnvNames(env[CURSOR_CLOUD_ENV_ENV], CURSOR_CLOUD_ENV_ENV);
458
+ const envFromFiles = parseOptionalEnvBoolean(env[CURSOR_CLOUD_ENV_FROM_FILES_ENV]);
459
+ const environment = parseCloudEnvironment({
460
+ type: env[CURSOR_CLOUD_ENV_TYPE_ENV],
461
+ name: env[CURSOR_CLOUD_ENV_NAME_ENV],
462
+ });
463
+ const acknowledged = parseOptionalEnvBoolean(env[CURSOR_CLOUD_ACK_ENV]);
464
+ if (
465
+ repo !== undefined ||
466
+ branch !== undefined ||
467
+ isCursorCloudContextHandoff(contextHandoff) ||
468
+ directPush !== undefined ||
469
+ allowLocalState !== undefined ||
470
+ envNames !== undefined ||
471
+ envFromFiles !== undefined ||
472
+ environment !== undefined ||
473
+ acknowledged !== undefined
474
+ ) {
475
+ config.cloud = {
476
+ ...(repo !== undefined ? { repo } : {}),
477
+ ...(branch !== undefined ? { branch } : {}),
478
+ ...(isCursorCloudContextHandoff(contextHandoff) ? { contextHandoff } : {}),
479
+ ...(directPush !== undefined ? { directPush } : {}),
480
+ ...(allowLocalState !== undefined ? { allowLocalState } : {}),
481
+ ...(envNames !== undefined ? { envNames } : {}),
482
+ ...(envFromFiles !== undefined ? { envFromFiles } : {}),
483
+ ...(environment !== undefined ? { environment } : {}),
484
+ ...(acknowledged !== undefined ? { acknowledged } : {}),
485
+ };
486
+ }
487
+ const autoReview = parseOptionalEnvBoolean(env[CURSOR_AUTO_REVIEW_ENV]);
488
+ const sandbox = parseOptionalEnvBoolean(env[CURSOR_SANDBOX_ENV]);
489
+ const force = parseOptionalEnvBoolean(env[CURSOR_LOCAL_FORCE_ENV]);
490
+ const resume = parseOptionalEnvBoolean(env[CURSOR_LOCAL_RESUME_ENV]);
491
+ if (autoReview !== undefined || sandbox !== undefined || force !== undefined || resume !== undefined) {
492
+ config.local = {
493
+ ...(autoReview !== undefined ? { autoReview } : {}),
494
+ ...(sandbox !== undefined ? { sandboxOptions: { enabled: sandbox } } : {}),
495
+ ...(force !== undefined ? { force } : {}),
496
+ ...(resume !== undefined ? { resume } : {}),
497
+ };
498
+ }
499
+ return config;
500
+ }
501
+
502
+ export function resolveCursorSdkConfig(options: ResolveCursorSdkConfigOptions = {}): CursorResolvedSdkConfig {
503
+ const cliRuntime = validateExplicitValue(options.cli?.runtime, "--cursor-runtime", isCursorRuntime, '"local" or "cloud"');
504
+ const cliContextHandoff = validateExplicitValue(
505
+ options.cli?.cloud?.contextHandoff,
506
+ "--cursor-cloud-context",
507
+ isCursorCloudContextHandoff,
508
+ '"never", "fresh", or "bootstrap"',
509
+ );
510
+ const env = cursorSdkConfigFromEnv(options.env);
511
+ const builtIn = {
512
+ ...BUILT_IN_CURSOR_CONFIG,
513
+ ...options.builtIn,
514
+ cloud: { ...BUILT_IN_CURSOR_CONFIG.cloud, ...options.builtIn?.cloud },
515
+ };
516
+ const cli = options.cli;
517
+ const session = options.session;
518
+ const project = options.project;
519
+ const user = options.user;
520
+ return {
521
+ runtime: resolveSafetyField(
522
+ RUNTIME_ORDER,
523
+ {
524
+ cli: cliRuntime,
525
+ environment: env.runtime,
526
+ session: session?.runtime,
527
+ project: project?.runtime,
528
+ user: user?.runtime,
529
+ builtin: builtIn.runtime,
530
+ },
531
+ (value) => (value === "cloud" ? 1 : 0),
532
+ ),
533
+ cloud: {
534
+ repo: resolveOrdinaryField(CLOUD_ORDER, {
535
+ cli: cli?.cloud?.repo,
536
+ environment: env.cloud?.repo,
537
+ session: session?.cloud?.repo,
538
+ user: user?.cloud?.repo,
539
+ builtin: undefined,
540
+ }),
541
+ branch: resolveOrdinaryField(CLOUD_ORDER, {
542
+ cli: cli?.cloud?.branch,
543
+ environment: env.cloud?.branch,
544
+ session: session?.cloud?.branch,
545
+ user: user?.cloud?.branch,
546
+ builtin: undefined,
547
+ }),
548
+ contextHandoff: resolveSafetyField(
549
+ CLOUD_ORDER,
550
+ {
551
+ cli: cliContextHandoff,
552
+ environment: env.cloud?.contextHandoff,
553
+ session: session?.cloud?.contextHandoff,
554
+ user: user?.cloud?.contextHandoff,
555
+ builtin: builtIn.cloud.contextHandoff,
556
+ },
557
+ (value) => ({ never: 0, fresh: 1, bootstrap: 2 })[value],
558
+ ),
559
+ directPush: resolveSafetyField(
560
+ CLOUD_ORDER,
561
+ {
562
+ cli: cli?.cloud?.directPush,
563
+ environment: env.cloud?.directPush,
564
+ session: session?.cloud?.directPush,
565
+ user: user?.cloud?.directPush,
566
+ builtin: builtIn.cloud.directPush,
567
+ },
568
+ (value) => (value ? 1 : 0),
569
+ ),
570
+ allowLocalState: resolveSafetyField(
571
+ CLOUD_ORDER,
572
+ {
573
+ cli: cli?.cloud?.allowLocalState,
574
+ environment: env.cloud?.allowLocalState,
575
+ session: session?.cloud?.allowLocalState,
576
+ user: user?.cloud?.allowLocalState,
577
+ builtin: builtIn.cloud.allowLocalState,
578
+ },
579
+ (value) => (value ? 1 : 0),
580
+ ),
581
+ envNames: resolveEnvNamesSafetyField(CLOUD_ORDER, {
582
+ cli: cli?.cloud?.envNames,
583
+ environment: env.cloud?.envNames,
584
+ session: session?.cloud?.envNames,
585
+ user: user?.cloud?.envNames,
586
+ builtin: builtIn.cloud.envNames,
587
+ }),
588
+ envFromFiles: resolveSafetyField(
589
+ CLOUD_ORDER,
590
+ {
591
+ cli: cli?.cloud?.envFromFiles,
592
+ environment: env.cloud?.envFromFiles,
593
+ session: session?.cloud?.envFromFiles,
594
+ user: user?.cloud?.envFromFiles,
595
+ builtin: builtIn.cloud.envFromFiles,
596
+ },
597
+ (value) => (value ? 1 : 0),
598
+ ),
599
+ environment: resolveOrdinaryField(CLOUD_ORDER, {
600
+ cli: cli?.cloud?.environment,
601
+ environment: env.cloud?.environment,
602
+ session: session?.cloud?.environment,
603
+ user: user?.cloud?.environment,
604
+ builtin: undefined,
605
+ }),
606
+ acknowledged: resolveOrdinaryField(CLOUD_ORDER, {
607
+ cli: cli?.cloud?.acknowledged,
608
+ environment: env.cloud?.acknowledged,
609
+ session: session?.cloud?.acknowledged,
610
+ user: user?.cloud?.acknowledged,
611
+ builtin: builtIn.cloud.acknowledged,
612
+ }),
613
+ },
614
+ local: {
615
+ autoReview: resolveOrdinaryField(LOCAL_ORDER, {
616
+ cli: cli?.local?.autoReview,
617
+ environment: env.local?.autoReview,
618
+ project: project?.local?.autoReview,
619
+ user: user?.local?.autoReview,
620
+ builtin: false,
621
+ }),
622
+ sandboxEnabled: resolveOrdinaryField(LOCAL_ORDER, {
623
+ cli: cli?.local?.sandboxOptions?.enabled ?? cli?.local?.sandbox,
624
+ environment: env.local?.sandboxOptions?.enabled ?? env.local?.sandbox,
625
+ project: project?.local?.sandboxOptions?.enabled ?? project?.local?.sandbox,
626
+ user: user?.local?.sandboxOptions?.enabled ?? user?.local?.sandbox,
627
+ builtin: false,
628
+ }),
629
+ force: resolveOrdinaryField(LOCAL_FORCE_ORDER, {
630
+ cli: cli?.local?.force,
631
+ environment: env.local?.force,
632
+ builtin: false,
633
+ }),
634
+ resume: resolveOrdinaryField(LOCAL_ORDER, {
635
+ cli: cli?.local?.resume,
636
+ environment: env.local?.resume,
637
+ project: project?.local?.resume,
638
+ user: user?.local?.resume,
639
+ builtin: true,
640
+ }),
641
+ },
642
+ };
643
+ }
644
+
645
+ export function resolveCursorFastDefault(options: {
646
+ cliForceFast?: boolean;
647
+ cliForceNoFast?: boolean;
648
+ aliasOverride?: boolean;
649
+ sessionValue?: boolean;
650
+ userValue?: boolean;
651
+ modelDefault: boolean;
652
+ }): CursorResolvedSetting<boolean> {
653
+ if (options.cliForceNoFast) return resolved("cli", false);
654
+ if (options.cliForceFast) return resolved("cli", true);
655
+ if (options.aliasOverride !== undefined) return resolved("model-alias", options.aliasOverride);
656
+ if (options.sessionValue !== undefined) return resolved("session", options.sessionValue);
657
+ if (options.userValue !== undefined) return resolved("user", options.userValue);
658
+ return resolved("builtin", options.modelDefault);
659
+ }
@@ -0,0 +1,14 @@
1
+ import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai/compat";
2
+
3
+ function withDisplayOnlyThinking(partial: AssistantMessage, thinking: string): AssistantMessage {
4
+ return { ...partial, content: [...partial.content, { type: "thinking", thinking }] };
5
+ }
6
+
7
+ export function emitDisplayOnlyTraceBlock(stream: AssistantMessageEventStream, partial: AssistantMessage, text: string): void {
8
+ const traceText = text.endsWith("\n") ? text : `${text}\n`;
9
+ const contentIndex = partial.content.length;
10
+ stream.push({ type: "thinking_start", contentIndex, partial: withDisplayOnlyThinking(partial, "") });
11
+ const displayPartial = withDisplayOnlyThinking(partial, traceText);
12
+ stream.push({ type: "thinking_delta", contentIndex, delta: traceText, partial: displayPartial });
13
+ stream.push({ type: "thinking_end", contentIndex, content: traceText, partial: displayPartial });
14
+ }
@@ -1,10 +1,16 @@
1
1
  /** Canonical single-line sanitization and truncation for Cursor replay/trace display. */
2
2
  export function sanitizeCursorDisplayLine(value: string): string {
3
- return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
3
+ return value
4
+ .replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "�")
5
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
6
+ .replace(/\s+/g, " ")
7
+ .trim();
4
8
  }
5
9
 
6
10
  export function truncateCursorDisplayLine(value: string, maxLength = 240): string {
11
+ if (maxLength <= 0) return "";
7
12
  const sanitized = sanitizeCursorDisplayLine(value);
8
13
  if (sanitized.length <= maxLength) return sanitized;
9
- return `${sanitized.slice(0, maxLength - 1)}…`;
14
+ if (maxLength === 1) return "…";
15
+ return `${sanitized.slice(0, maxLength - 1).replace(/[\uD800-\uDBFF]$/, "")}…`;
10
16
  }