pi-cursor-sdk 0.1.59 → 0.1.61

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 (57) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +124 -10
  3. package/docs/cursor-model-ux-spec.md +19 -11
  4. package/docs/cursor-native-tool-replay.md +5 -3
  5. package/docs/cursor-testing-lessons.md +2 -2
  6. package/docs/cursor-tool-surfaces.md +7 -1
  7. package/docs/platform-smoke.md +51 -20
  8. package/package.json +13 -2
  9. package/platform-smoke.config.mjs +1 -0
  10. package/scripts/cloud-runtime-smoke.d.mts +40 -1
  11. package/scripts/cloud-runtime-smoke.mjs +444 -325
  12. package/scripts/lib/cloud-smoke-artifacts.d.mts +16 -0
  13. package/scripts/lib/cloud-smoke-artifacts.mjs +94 -0
  14. package/scripts/lib/cloud-smoke-cleanup-evidence.d.mts +209 -0
  15. package/scripts/lib/cloud-smoke-cleanup-evidence.mjs +585 -0
  16. package/scripts/lib/cloud-smoke-github.d.mts +78 -0
  17. package/scripts/lib/cloud-smoke-github.mjs +331 -0
  18. package/scripts/lib/cloud-smoke-pi-runner.d.mts +42 -0
  19. package/scripts/lib/cloud-smoke-pi-runner.mjs +214 -0
  20. package/scripts/lib/cloud-smoke-shutdown.d.mts +67 -0
  21. package/scripts/lib/cloud-smoke-shutdown.mjs +133 -0
  22. package/scripts/lib/local-resume-smoke-harness.mjs +2 -0
  23. package/scripts/local-resume-cleanup-smoke.mjs +11 -2
  24. package/scripts/platform-smoke/card-detect.mjs +1 -0
  25. package/scripts/platform-smoke/scenarios.d.mts +22 -0
  26. package/scripts/platform-smoke/scenarios.mjs +17 -0
  27. package/scripts/platform-smoke/targets.mjs +1 -0
  28. package/src/cursor-agent-message-web-tools.ts +14 -6
  29. package/src/cursor-cloud-local-state.ts +536 -0
  30. package/src/cursor-cloud-options.ts +46 -70
  31. package/src/cursor-config.ts +160 -14
  32. package/src/cursor-http1.ts +65 -0
  33. package/src/cursor-live-run-coordinator.ts +8 -1
  34. package/src/cursor-pi-tool-bridge-abort.ts +10 -3
  35. package/src/cursor-pi-tool-bridge-constants.ts +6 -0
  36. package/src/cursor-pi-tool-bridge-env.ts +9 -0
  37. package/src/cursor-pi-tool-bridge-run.ts +27 -5
  38. package/src/cursor-pi-tool-bridge.ts +11 -2
  39. package/src/cursor-provider-errors.ts +72 -2
  40. package/src/cursor-provider-live-run-drain.ts +2 -1
  41. package/src/cursor-provider-run-finalizer.ts +8 -2
  42. package/src/cursor-provider-run-outcome.ts +7 -1
  43. package/src/cursor-provider-turn-finalize.ts +23 -4
  44. package/src/cursor-provider-turn-prepare.ts +17 -3
  45. package/src/cursor-provider-turn-runner.ts +15 -1
  46. package/src/cursor-provider-turn-send.ts +1 -1
  47. package/src/cursor-question-tool.ts +8 -0
  48. package/src/cursor-ripgrep-path.ts +32 -0
  49. package/src/cursor-runtime-state.ts +119 -100
  50. package/src/cursor-sdk-process-error-guard.ts +62 -0
  51. package/src/cursor-session-agent-cleanup.ts +66 -15
  52. package/src/cursor-session-agent-lifecycle.ts +9 -4
  53. package/src/cursor-session-agent-resume.ts +50 -7
  54. package/src/cursor-session-agent.ts +131 -71
  55. package/src/cursor-session-scope.ts +22 -2
  56. package/src/cursor-session-store.ts +155 -0
  57. package/src/cursor-state.ts +162 -21
@@ -7,6 +7,7 @@ import {
7
7
  CURSOR_AUTO_REVIEW_ENV,
8
8
  CURSOR_CLOUD_ACK_ENV,
9
9
  CURSOR_CLOUD_ALLOW_LOCAL_STATE_ENV,
10
+ CURSOR_CLOUD_AUTO_CREATE_PR_ENV,
10
11
  CURSOR_CLOUD_BRANCH_ENV,
11
12
  CURSOR_CLOUD_CONTEXT_ENV,
12
13
  CURSOR_CLOUD_DIRECT_PUSH_ENV,
@@ -15,19 +16,18 @@ import {
15
16
  CURSOR_CLOUD_ENV_NAME_ENV,
16
17
  CURSOR_CLOUD_ENV_TYPE_ENV,
17
18
  CURSOR_CLOUD_REPO_ENV,
19
+ CURSOR_CLOUD_SKIP_REVIEWER_REQUEST_ENV,
18
20
  CURSOR_LOCAL_FORCE_ENV,
19
21
  CURSOR_LOCAL_RESUME_ENV,
20
22
  CURSOR_RUNTIME_ENV,
21
23
  CURSOR_SANDBOX_ENV,
24
+ getCursorSdkProjectConfigPath,
25
+ getCursorSdkUserConfigPath,
22
26
  loadCursorSdkConfig,
23
- loadCursorSdkProjectConfig,
24
- loadCursorSdkUserConfig,
25
- mergeCursorSdkConfig,
26
- parseCursorSdkConfig,
27
+ mergeCursorSdkConfigForUpdate,
27
28
  parseExplicitCursorCloudEnvNames,
28
29
  resolveCursorSdkConfig,
29
- saveCursorSdkProjectConfig,
30
- saveCursorSdkUserConfig,
30
+ updateCursorSdkConfig,
31
31
  type CursorExplicitSdkConfig,
32
32
  type CursorResolvedSdkConfig,
33
33
  type CursorResolvedSetting,
@@ -35,6 +35,8 @@ import {
35
35
  type CursorSdkConfig,
36
36
  } from "./cursor-config.js";
37
37
  import { asRecord } from "./cursor-record-utils.js";
38
+ import { getResolvedSessionCursorHttp1Enabled } from "./cursor-http1.js";
39
+ import { getCursorSessionCwd, getCursorSessionProjectTrusted } from "./cursor-session-scope.js";
38
40
 
39
41
  export const CURSOR_RUNTIME_ENTRY_TYPE = "cursor-runtime-state";
40
42
 
@@ -57,28 +59,18 @@ export type CursorRuntimeStateExtensionApi = Pick<
57
59
  "appendEntry" | "getFlag" | "registerFlag" | "registerCommand" | "on"
58
60
  >;
59
61
 
60
- type CursorRuntimeContext = Pick<ExtensionContext, "cwd"> & Partial<Pick<ExtensionContext, "isProjectTrusted">>;
62
+ type CursorRuntimeContext = Pick<ExtensionContext, "cwd">;
61
63
 
62
64
  type CursorStatusRefresh = (ctx: ExtensionContext) => void;
63
65
 
64
- let cliAutoReview = false;
65
- let cliSandbox = false;
66
- let cliLocalForce = false;
66
+ interface CursorCliConfigSnapshot {
67
+ config: CursorExplicitSdkConfig;
68
+ cloudEnvNames?: string;
69
+ }
70
+
71
+ let cliCursorSnapshot: CursorCliConfigSnapshot = { config: {} };
67
72
  let cliLocalForceConsumed = false;
68
- let cliLocalResume = false;
69
- let cliNoLocalResume = false;
70
73
  let envLocalForceConsumed = false;
71
- let cliCursorRuntime: string | undefined;
72
- let cliCursorCloudRepo: string | undefined;
73
- let cliCursorCloudBranch: string | undefined;
74
- let cliCursorCloudContext: string | undefined;
75
- let cliCursorCloudDirectPush = false;
76
- let cliCursorCloudAllowLocalState = false;
77
- let cliCursorCloudEnv: string | undefined;
78
- let cliCursorCloudEnvFromFiles = false;
79
- let cliCursorCloudEnvType: string | undefined;
80
- let cliCursorCloudEnvName: string | undefined;
81
- let cliCursorCloudAck = false;
82
74
  let sessionCursorRuntime: CursorRuntime | undefined;
83
75
  let sessionCursorCloudAcknowledged = false;
84
76
 
@@ -94,43 +86,23 @@ function stringFlagValue(value: boolean | string | undefined): string | undefine
94
86
  }
95
87
 
96
88
  export function getCursorCliConfig(): CursorExplicitSdkConfig {
97
- const parsed = parseCursorSdkConfig({
98
- cloud: {
99
- repo: cliCursorCloudRepo,
100
- branch: cliCursorCloudBranch,
101
- ...(cliCursorCloudDirectPush ? { directPush: true } : {}),
102
- ...(cliCursorCloudAllowLocalState ? { allowLocalState: true } : {}),
103
- envNames: parseExplicitCursorCloudEnvNames(cliCursorCloudEnv, "--cursor-cloud-env"),
104
- ...(cliCursorCloudEnvFromFiles ? { envFromFiles: true } : {}),
105
- environment: {
106
- type: cliCursorCloudEnvType,
107
- name: cliCursorCloudEnvName,
108
- },
109
- ...(cliCursorCloudAck ? { acknowledged: true } : {}),
110
- },
111
- local: {
112
- ...(cliAutoReview ? { autoReview: true } : {}),
113
- ...(cliSandbox ? { sandboxOptions: { enabled: true } } : {}),
114
- ...(cliLocalForce ? { force: true } : {}),
115
- ...(cliNoLocalResume ? { resume: false } : cliLocalResume ? { resume: true } : {}),
116
- },
117
- }) ?? {};
118
- return {
119
- ...parsed,
120
- ...(cliCursorRuntime ? { runtime: cliCursorRuntime } : {}),
121
- ...(cliCursorCloudContext
122
- ? { cloud: { ...parsed.cloud, contextHandoff: cliCursorCloudContext } }
123
- : {}),
124
- };
89
+ const config = structuredClone(cliCursorSnapshot.config);
90
+ const envNames = parseExplicitCursorCloudEnvNames(cliCursorSnapshot.cloudEnvNames, "--cursor-cloud-env");
91
+ if (envNames) config.cloud = { ...config.cloud, envNames };
92
+ return config;
125
93
  }
126
94
 
127
95
  export function getCursorSessionConfig(): CursorSdkConfig {
128
- return sessionCursorRuntime
129
- ? {
130
- runtime: sessionCursorRuntime,
131
- ...(sessionCursorCloudAcknowledged ? { cloud: { acknowledged: true } } : {}),
132
- }
133
- : {};
96
+ const useHttp1ForAgent = getResolvedSessionCursorHttp1Enabled();
97
+ return {
98
+ ...(sessionCursorRuntime
99
+ ? {
100
+ runtime: sessionCursorRuntime,
101
+ ...(sessionCursorCloudAcknowledged ? { cloud: { acknowledged: true } } : {}),
102
+ }
103
+ : {}),
104
+ ...(useHttp1ForAgent === undefined ? {} : { local: { useHttp1ForAgent } }),
105
+ };
134
106
  }
135
107
 
136
108
  export function resolveEffectiveCursorConfig(options: {
@@ -147,16 +119,28 @@ export function resolveEffectiveCursorConfig(options: {
147
119
  }
148
120
 
149
121
  export function resolveEffectiveCursorConfigForContext(ctx: CursorRuntimeContext): CursorResolvedSdkConfig {
150
- return resolveEffectiveCursorConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted?.() === true });
122
+ return resolveEffectiveCursorConfig({
123
+ cwd: ctx.cwd,
124
+ projectTrusted: getCursorSessionCwd() === ctx.cwd && getCursorSessionProjectTrusted(),
125
+ });
151
126
  }
152
127
 
153
128
  export type CursorRuntimeResolution =
154
- | { kind: "valid"; runtime: CursorResolvedSetting<CursorRuntime> }
129
+ | {
130
+ kind: "valid";
131
+ runtime: CursorResolvedSetting<CursorRuntime>;
132
+ useHttp1ForAgent: CursorResolvedSetting<boolean>;
133
+ }
155
134
  | { kind: "invalid"; message: string };
156
135
 
157
136
  export function resolveCursorStatusRuntime(ctx: CursorRuntimeContext): CursorRuntimeResolution {
158
137
  try {
159
- return { kind: "valid", runtime: resolveEffectiveCursorConfigForContext(ctx).runtime };
138
+ const config = resolveEffectiveCursorConfigForContext(ctx);
139
+ return {
140
+ kind: "valid",
141
+ runtime: config.runtime,
142
+ useHttp1ForAgent: config.local.useHttp1ForAgent,
143
+ };
160
144
  } catch (error) {
161
145
  return { kind: "invalid", message: error instanceof Error ? error.message : String(error) };
162
146
  }
@@ -173,8 +157,10 @@ export function formatCursorStatus(
173
157
  runtime: CursorRuntime | "invalid",
174
158
  fast: boolean | undefined,
175
159
  mode: "agent" | "plan" | "invalid",
160
+ useHttp1ForAgent = false,
176
161
  ): string {
177
162
  const parts = [`cursor:${runtime}`, fast === true ? "fast:on" : fast === false ? "fast:off" : "fast:n/a"];
163
+ if (runtime === "local" && useHttp1ForAgent) parts.push("http1");
178
164
  if (mode === "invalid") parts.push("mode invalid");
179
165
  else if (mode === "plan") parts.push("plan");
180
166
  return parts.join(" · ");
@@ -183,7 +169,10 @@ export function formatCursorStatus(
183
169
  export function consumeCursorLocalForceOverride(resolved: { value: boolean; source: string }): boolean {
184
170
  if (!resolved.value) return false;
185
171
  if (resolved.source === "cli" && !cliLocalForceConsumed) {
186
- cliLocalForce = false;
172
+ if (cliCursorSnapshot.config.local) {
173
+ const { force: _, ...local } = cliCursorSnapshot.config.local;
174
+ cliCursorSnapshot.config.local = local;
175
+ }
187
176
  cliLocalForceConsumed = true;
188
177
  return true;
189
178
  }
@@ -207,22 +196,45 @@ export function restoreSessionCursorRuntimeState(branch: readonly SessionEntry[]
207
196
  }
208
197
 
209
198
  export function restoreCursorCliState(pi: Pick<ExtensionAPI, "getFlag">): void {
210
- cliAutoReview = pi.getFlag("cursor-auto-review") === true;
211
- cliSandbox = pi.getFlag("cursor-sandbox") === true;
212
- cliLocalForce = !cliLocalForceConsumed && pi.getFlag("cursor-local-force") === true;
213
- cliLocalResume = pi.getFlag("cursor-local-resume") === true;
214
- cliNoLocalResume = pi.getFlag("cursor-no-local-resume") === true;
215
- cliCursorRuntime = stringFlagValue(pi.getFlag("cursor-runtime"));
216
- cliCursorCloudRepo = stringFlagValue(pi.getFlag("cursor-cloud-repo"));
217
- cliCursorCloudBranch = stringFlagValue(pi.getFlag("cursor-cloud-branch"));
218
- cliCursorCloudContext = stringFlagValue(pi.getFlag("cursor-cloud-context"));
219
- cliCursorCloudDirectPush = pi.getFlag("cursor-cloud-direct-push") === true;
220
- cliCursorCloudAllowLocalState = pi.getFlag("cursor-cloud-allow-local-state") === true;
221
- cliCursorCloudEnv = stringFlagValue(pi.getFlag("cursor-cloud-env"));
222
- cliCursorCloudEnvFromFiles = pi.getFlag("cursor-cloud-env-from-files") === true;
223
- cliCursorCloudEnvType = stringFlagValue(pi.getFlag("cursor-cloud-env-type"));
224
- cliCursorCloudEnvName = stringFlagValue(pi.getFlag("cursor-cloud-env-name"));
225
- cliCursorCloudAck = pi.getFlag("cursor-cloud-ack") === true;
199
+ const runtime = stringFlagValue(pi.getFlag("cursor-runtime"));
200
+ const repo = stringFlagValue(pi.getFlag("cursor-cloud-repo"));
201
+ const branch = stringFlagValue(pi.getFlag("cursor-cloud-branch"));
202
+ const contextHandoff = stringFlagValue(pi.getFlag("cursor-cloud-context"));
203
+ const environmentType = stringFlagValue(pi.getFlag("cursor-cloud-env-type"));
204
+ const environmentName = stringFlagValue(pi.getFlag("cursor-cloud-env-name"));
205
+ const cloudEnvNames = stringFlagValue(pi.getFlag("cursor-cloud-env"));
206
+ const cloud: NonNullable<CursorExplicitSdkConfig["cloud"]> = {
207
+ ...(repo ? { repo } : {}),
208
+ ...(branch ? { branch } : {}),
209
+ ...(contextHandoff ? { contextHandoff } : {}),
210
+ ...(pi.getFlag("cursor-cloud-direct-push") === true ? { directPush: true } : {}),
211
+ ...(pi.getFlag("cursor-cloud-auto-create-pr") === true ? { autoCreatePR: true } : {}),
212
+ ...(pi.getFlag("cursor-cloud-skip-reviewer-request") === true ? { skipReviewerRequest: true } : {}),
213
+ ...(pi.getFlag("cursor-cloud-allow-local-state") === true ? { allowLocalState: true } : {}),
214
+ ...(pi.getFlag("cursor-cloud-env-from-files") === true ? { envFromFiles: true } : {}),
215
+ ...(environmentType || environmentName
216
+ ? { environment: { ...(environmentType ? { type: environmentType } : {}), ...(environmentName ? { name: environmentName } : {}) } }
217
+ : {}),
218
+ ...(pi.getFlag("cursor-cloud-ack") === true ? { acknowledged: true } : {}),
219
+ };
220
+ const local: NonNullable<CursorExplicitSdkConfig["local"]> = {
221
+ ...(pi.getFlag("cursor-auto-review") === true ? { autoReview: true } : {}),
222
+ ...(pi.getFlag("cursor-sandbox") === true ? { sandboxOptions: { enabled: true } } : {}),
223
+ ...(!cliLocalForceConsumed && pi.getFlag("cursor-local-force") === true ? { force: true } : {}),
224
+ ...(pi.getFlag("cursor-no-local-resume") === true
225
+ ? { resume: false }
226
+ : pi.getFlag("cursor-local-resume") === true
227
+ ? { resume: true }
228
+ : {}),
229
+ };
230
+ cliCursorSnapshot = {
231
+ config: {
232
+ ...(runtime ? { runtime } : {}),
233
+ ...(Object.keys(cloud).length ? { cloud } : {}),
234
+ ...(Object.keys(local).length ? { local } : {}),
235
+ },
236
+ ...(cloudEnvNames ? { cloudEnvNames } : {}),
237
+ };
226
238
  }
227
239
 
228
240
  function persistCursorRuntimePreference(
@@ -265,6 +277,16 @@ function registerCursorRuntimeFlags(pi: Pick<ExtensionAPI, "registerFlag">): voi
265
277
  type: "boolean",
266
278
  default: false,
267
279
  });
280
+ pi.registerFlag("cursor-cloud-auto-create-pr", {
281
+ description: `Ask Cursor cloud to create a pull request for this run (or set ${CURSOR_CLOUD_AUTO_CREATE_PR_ENV}=1)`,
282
+ type: "boolean",
283
+ default: false,
284
+ });
285
+ pi.registerFlag("cursor-cloud-skip-reviewer-request", {
286
+ description: `Ask Cursor cloud not to request you as a reviewer for this run (or set ${CURSOR_CLOUD_SKIP_REVIEWER_REQUEST_ENV}=1)`,
287
+ type: "boolean",
288
+ default: false,
289
+ });
268
290
  pi.registerFlag("cursor-cloud-allow-local-state", {
269
291
  description: `Allow Cursor cloud to proceed with local-only state for this run (or set ${CURSOR_CLOUD_ALLOW_LOCAL_STATE_ENV}=1)`,
270
292
  type: "boolean",
@@ -370,6 +392,13 @@ function registerCursorRuntimeCommand(
370
392
  ctx.ui.notify(`${currentResolution.message} Fix the explicit override before changing the session runtime.`, "error");
371
393
  return;
372
394
  }
395
+ if (saveProject && (getCursorSessionCwd() !== ctx.cwd || !getCursorSessionProjectTrusted())) {
396
+ ctx.ui.notify(
397
+ "Cannot save Cursor project config without explicit project-trust provenance. Ensure .pi/settings.json or another Pi project resource exists, trust the project, then restart pi; or restart with --approve. Project-local package installs must use --approve on every run that reads or writes .pi/cursor-sdk.json.",
398
+ "error",
399
+ );
400
+ return;
401
+ }
373
402
  const cloudAcknowledged = raw === "cloud" && await confirmCloudAcknowledgement(
374
403
  ctx,
375
404
  resolveEffectiveCursorConfigForContext(ctx).cloud.acknowledged.value,
@@ -378,14 +407,19 @@ function registerCursorRuntimeCommand(
378
407
  if (saveUser || saveProject) {
379
408
  try {
380
409
  if (saveUser) {
381
- const current = loadCursorSdkUserConfig();
382
- saveCursorSdkUserConfig(mergeCursorSdkConfig(current, {
383
- runtime: raw,
384
- ...(cloudAcknowledged ? { cloud: { acknowledged: true } } : {}),
385
- }));
410
+ updateCursorSdkConfig(
411
+ getCursorSdkUserConfigPath(),
412
+ (current) => mergeCursorSdkConfigForUpdate(current, {
413
+ runtime: raw,
414
+ ...(cloudAcknowledged ? { cloud: { acknowledged: true } } : {}),
415
+ }),
416
+ { newFileMode: 0o600 },
417
+ );
386
418
  } else {
387
- const current = loadCursorSdkProjectConfig(ctx.cwd, true) ?? {};
388
- saveCursorSdkProjectConfig(ctx.cwd, mergeCursorSdkConfig(current, { runtime: raw }));
419
+ updateCursorSdkConfig(
420
+ getCursorSdkProjectConfigPath(ctx.cwd),
421
+ (current) => mergeCursorSdkConfigForUpdate(current, { runtime: raw }),
422
+ );
389
423
  }
390
424
  } catch (error) {
391
425
  const effectiveResolution = resolveCursorStatusRuntime(ctx);
@@ -455,24 +489,9 @@ export function registerCursorCloudRuntimeControls(
455
489
  }
456
490
 
457
491
  export function resetCursorRuntimeStateForTests(): void {
458
- cliAutoReview = false;
459
- cliSandbox = false;
460
- cliLocalForce = false;
492
+ cliCursorSnapshot = { config: {} };
461
493
  cliLocalForceConsumed = false;
462
- cliLocalResume = false;
463
- cliNoLocalResume = false;
464
494
  envLocalForceConsumed = false;
465
- cliCursorRuntime = undefined;
466
- cliCursorCloudRepo = undefined;
467
- cliCursorCloudBranch = undefined;
468
- cliCursorCloudContext = undefined;
469
- cliCursorCloudDirectPush = false;
470
- cliCursorCloudAllowLocalState = false;
471
- cliCursorCloudEnv = undefined;
472
- cliCursorCloudEnvFromFiles = false;
473
- cliCursorCloudEnvType = undefined;
474
- cliCursorCloudEnvName = undefined;
475
- cliCursorCloudAck = false;
476
495
  sessionCursorRuntime = undefined;
477
496
  sessionCursorCloudAcknowledged = false;
478
497
  }
@@ -3,12 +3,14 @@ import { classifyCursorConnectError, isCursorSdkAbortConnectError } from "./curs
3
3
 
4
4
  interface CursorSdkProcessErrorGuardToken {
5
5
  suppressAbortErrors: boolean;
6
+ onLocalTransportClosedPipe?: () => void;
6
7
  }
7
8
 
8
9
  interface CursorSdkSessionProcessErrorGuardToken {}
9
10
 
10
11
  export interface CursorSdkProcessErrorGuard {
11
12
  suppressAbortErrors(): void;
13
+ containLocalTransportClosedPipe(onClosedPipe: () => void): void;
12
14
  dispose(): void;
13
15
  }
14
16
 
@@ -52,10 +54,66 @@ function isCursorSdkWriteIterableClosedError(error: unknown): boolean {
52
54
  );
53
55
  }
54
56
 
57
+ // The Cursor SDK aborts an in-flight controlled-exec turn via its internal
58
+ // `AbortController.abort()` (user interrupt or stall-detector cancellation),
59
+ // which surfaces as a raw `DOMException [AbortError]` rather than a
60
+ // `ConnectError`. `classifyCursorConnectError` returns undefined for it, so it
61
+ // otherwise falls through the emit patch and terminates the process. A
62
+ // DOMException is not `instanceof Error`, so match structurally on the
63
+ // `AbortError` name plus the same `@cursor/sdk/dist` stack provenance the
64
+ // WriteIterableClosedError recognizer uses, keeping unrelated AbortErrors fatal.
65
+ function isCursorSdkAbortError(error: unknown): boolean {
66
+ if (typeof error !== "object" || error === null) return false;
67
+ const { name, stack } = error as { name?: unknown; stack?: unknown };
68
+ return (
69
+ name === "AbortError" &&
70
+ typeof stack === "string" &&
71
+ /(?:^|[\\/])node_modules[\\/]@cursor[\\/]sdk[\\/]dist[\\/]/.test(stack)
72
+ );
73
+ }
74
+
75
+ // The exact observed incident: the Cursor SDK 1.0.23 local shell executor writes a
76
+ // spawned child's stdin without a stream 'error' listener, so a child exiting while
77
+ // a write is in flight surfaces a raw `write EPIPE` uncaught exception whose stack
78
+ // is exactly the single async pipe-write completion frame. Pi's own piped-stdout or
79
+ // dead-terminal EPIPE normally surfaces through the synchronous write-dispatch path
80
+ // with multiple frames (afterWriteDispatched/Socket._writeGeneric) and must stay
81
+ // fatal per Unix convention, so anything beyond this one-frame contract is rejected.
82
+ const OBSERVED_CLOSED_PIPE_STACK_FRAME =
83
+ /^\s+at WriteWrap\.onWriteComplete \[as oncomplete\] \(node:internal\/stream_base_commons:\d+:\d+\)$/;
84
+
85
+ function isObservedLocalTransportClosedPipeWriteError(error: unknown): boolean {
86
+ if (!(error instanceof Error) || error.name !== "Error") return false;
87
+ const { code, syscall } = error as NodeJS.ErrnoException;
88
+ if (code !== "EPIPE" || syscall !== "write" || !error.message.startsWith("write EPIPE")) return false;
89
+ const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line));
90
+ return frames.length === 1 && OBSERVED_CLOSED_PIPE_STACK_FRAME.test(frames[0] ?? "");
91
+ }
92
+
93
+ // Contained only while a provider turn that declared a local transport is active;
94
+ // each contained turn invalidates its own session-agent scope for recreation.
95
+ function containLocalTransportClosedPipeError(): boolean {
96
+ let contained = false;
97
+ for (const turn of [...activeProviderTurns]) {
98
+ if (!turn.onLocalTransportClosedPipe) continue;
99
+ contained = true;
100
+ try {
101
+ turn.onLocalTransportClosedPipe();
102
+ } catch {
103
+ // stale-agent invalidation must not throw inside process error handling
104
+ }
105
+ }
106
+ return contained;
107
+ }
108
+
55
109
  function shouldSuppressProcessError(event: string | symbol, args: readonly unknown[]): boolean {
56
110
  if (event !== "uncaughtException" && event !== "unhandledRejection") return false;
57
111
  const error = args[0];
112
+ if (isObservedLocalTransportClosedPipeWriteError(error)) {
113
+ return containLocalTransportClosedPipeError();
114
+ }
58
115
  if (isCursorSdkWriteIterableClosedError(error)) return activeSessions.size > 0;
116
+ if (isCursorSdkAbortError(error)) return hasActiveAbortSuppression();
59
117
  const classification = classifyCursorConnectError(error);
60
118
  if (!classification) return false;
61
119
  if (classification.kind === "abort") return hasActiveAbortSuppression();
@@ -137,6 +195,10 @@ export function installCursorSdkProcessErrorGuard(): CursorSdkProcessErrorGuard
137
195
  if (disposed) return;
138
196
  token.suppressAbortErrors = true;
139
197
  },
198
+ containLocalTransportClosedPipe(onClosedPipe: () => void): void {
199
+ if (disposed) return;
200
+ token.onLocalTransportClosedPipe = onClosedPipe;
201
+ },
140
202
  dispose(): void {
141
203
  if (disposed) return;
142
204
  disposed = true;
@@ -1,3 +1,4 @@
1
+ import type { LocalAgentStore } from "@cursor/sdk";
1
2
  import type { ExtensionAPI, ExtensionCommandContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
3
  import { asRecord, getString } from "./cursor-record-utils.js";
3
4
  import { fsyncExistingRegularFile } from "./cursor-durable-fs.js";
@@ -10,9 +11,15 @@ import {
10
11
  parseCursorSessionAgentResumeEntryData,
11
12
  readResumableCursorSessionAgentIds,
12
13
  resolveCursorSessionRepoRoot,
14
+ type CursorSessionAgentCleanupCandidate,
13
15
  type CursorSessionAgentResumeEntryData,
14
16
  type CursorSessionAgentResumeScope,
15
17
  } from "./cursor-session-agent-resume.js";
18
+ import {
19
+ cursorSessionStoreIdentitiesEqual,
20
+ getCursorSessionStoreIdentities,
21
+ openCursorSessionStore,
22
+ } from "./cursor-session-store.js";
16
23
 
17
24
  export const CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE = "cursor-sdk-agent-cleanup";
18
25
 
@@ -22,6 +29,7 @@ type CleanupPhase = "intent" | "result";
22
29
  export interface CursorSessionAgentCleanupFailure {
23
30
  agentId: string;
24
31
  error: string;
32
+ retryable?: boolean;
25
33
  }
26
34
 
27
35
  export interface CursorSessionAgentCleanupEntryData {
@@ -48,9 +56,11 @@ type LocalResumeCleanupCommandContext = Pick<ExtensionCommandContext, "cwd"> & {
48
56
  ui: Pick<ExtensionCommandContext["ui"], "notify">;
49
57
  };
50
58
  type LocalResumeCleanupSdkOperations = {
51
- delete(agentId: string, options?: { cwd?: string }): Promise<void>;
59
+ delete(agentId: string, options?: { cwd?: string; store?: LocalAgentStore }): Promise<void>;
52
60
  };
53
61
 
62
+ class InvalidCursorSessionStoreIdentityError extends Error {}
63
+
54
64
  // ponytail: grows for the process lifetime, but its ceiling is the exact agent IDs this process
55
65
  // attempted to delete (never global) — it only fills the gap until this process exits; the durable
56
66
  // intent entry, not this Set, is the authority a restarted process relies on to block retries.
@@ -106,7 +116,9 @@ function parseCleanupEntryData(value: unknown): CursorSessionAgentCleanupEntryDa
106
116
  const failedAgentIds = Array.isArray(record.failedAgentIds)
107
117
  ? record.failedAgentIds.flatMap((item): CursorSessionAgentCleanupFailure[] => {
108
118
  const failure = asRecord(item);
109
- return isCursorLocalAgentId(failure?.agentId) && typeof failure.error === "string" ? [{ agentId: failure.agentId, error: failure.error }] : [];
119
+ return isCursorLocalAgentId(failure?.agentId) && typeof failure.error === "string"
120
+ ? [{ agentId: failure.agentId, error: failure.error, ...(failure.retryable === false ? { retryable: false } : {}) }]
121
+ : [];
110
122
  })
111
123
  : undefined;
112
124
  return {
@@ -124,6 +136,7 @@ function parseCleanupEntryData(value: unknown): CursorSessionAgentCleanupEntryDa
124
136
  function readUnavailableAgentIds(entries: readonly SessionEntry[]): Set<string> {
125
137
  const deleted = new Set<string>();
126
138
  const pending = new Set<string>();
139
+ const permanentlyFailed = new Set<string>();
127
140
  for (const entry of entries) {
128
141
  if (entry.type !== "custom" || entry.customType !== CURSOR_SESSION_AGENT_CLEANUP_ENTRY_TYPE) continue;
129
142
  const data = parseCleanupEntryData(entry.data);
@@ -139,39 +152,57 @@ function readUnavailableAgentIds(entries: readonly SessionEntry[]): Set<string>
139
152
  pending.delete(agentId);
140
153
  }
141
154
  if (data.phase === "result") {
142
- for (const { agentId } of data.failedAgentIds ?? []) pending.delete(agentId);
155
+ for (const failure of data.failedAgentIds ?? []) {
156
+ pending.delete(failure.agentId);
157
+ if (failure.retryable === false) permanentlyFailed.add(failure.agentId);
158
+ }
143
159
  }
144
160
  }
145
- return new Set([...deleted, ...pending, ...nondurableCleanupResultAgentIds]);
161
+ return new Set([...deleted, ...pending, ...permanentlyFailed, ...nondurableCleanupResultAgentIds]);
146
162
  }
147
163
 
148
164
  function readLatestBranchAgentId(branch: readonly SessionEntry[], scope: CursorSessionAgentCleanupScope): string | undefined {
149
165
  return readResumeEntries(branch).filter((entry) => resumeEntryMatchesCleanupScope(entry, scope)).at(-1)?.agentId;
150
166
  }
151
167
 
152
- export function readCursorSessionAgentCleanupPlan(
168
+ function readCursorSessionAgentCleanupPlanDetails(
153
169
  entries: readonly SessionEntry[],
154
170
  branch: readonly SessionEntry[],
155
171
  scope: CursorSessionAgentCleanupScope,
156
- ): CursorSessionAgentCleanupPlan {
172
+ ): CursorSessionAgentCleanupPlan & { candidates: CursorSessionAgentCleanupCandidate[] } {
157
173
  const unavailable = readUnavailableAgentIds(entries);
158
174
  const latestBranchAgentId = readLatestBranchAgentId(branch, scope);
159
175
  const protectedAgentIds = new Set(readResumableCursorSessionAgentIds(entries, scope));
160
176
  if (latestBranchAgentId && isCursorLocalAgentId(latestBranchAgentId)) protectedAgentIds.add(latestBranchAgentId);
161
- const candidates = new Set<string>();
177
+ const candidates = new Map<string, CursorSessionAgentCleanupCandidate>();
162
178
  for (const resume of readResumeEntries(entries)) {
163
179
  if (!resumeEntryMatchesCleanupScope(resume, scope)) continue;
164
- for (const agentId of resume.cleanupCandidateAgentIds ?? []) {
165
- if (!isCursorLocalAgentId(agentId) || protectedAgentIds.has(agentId) || unavailable.has(agentId)) continue;
166
- candidates.add(agentId);
180
+ const recordedCandidates: CursorSessionAgentCleanupCandidate[] = [
181
+ ...(resume.cleanupCandidateAgentIds ?? []).map((agentId) => ({ agentId })),
182
+ ...(resume.cleanupCandidates ?? []),
183
+ ];
184
+ for (const candidate of recordedCandidates) {
185
+ if (!isCursorLocalAgentId(candidate.agentId) || protectedAgentIds.has(candidate.agentId) || unavailable.has(candidate.agentId)) continue;
186
+ const existing = candidates.get(candidate.agentId);
187
+ if (!existing?.storeIdentity || candidate.storeIdentity) candidates.set(candidate.agentId, candidate);
167
188
  }
168
189
  }
169
190
  return {
170
- candidateAgentIds: uniqueSorted(candidates),
191
+ candidates: [...candidates.values()].sort((left, right) => left.agentId.localeCompare(right.agentId)),
192
+ candidateAgentIds: uniqueSorted([...candidates.values()].map((candidate) => candidate.agentId)),
171
193
  protectedAgentIds: uniqueSorted(protectedAgentIds),
172
194
  };
173
195
  }
174
196
 
197
+ export function readCursorSessionAgentCleanupPlan(
198
+ entries: readonly SessionEntry[],
199
+ branch: readonly SessionEntry[],
200
+ scope: CursorSessionAgentCleanupScope,
201
+ ): CursorSessionAgentCleanupPlan {
202
+ const { candidates: _, ...plan } = readCursorSessionAgentCleanupPlanDetails(entries, branch, scope);
203
+ return plan;
204
+ }
205
+
175
206
  function formatCleanupPlan(plan: CursorSessionAgentCleanupPlan): string {
176
207
  if (plan.candidateAgentIds.length === 0) return "No recorded superseded local Cursor SDK agents are cleanup-eligible.";
177
208
  return [
@@ -245,7 +276,8 @@ export async function runCursorSessionAgentCleanupCommand(pi: LocalResumeCleanup
245
276
 
246
277
  const entries = ctx.sessionManager.getEntries();
247
278
  const branch = ctx.sessionManager.getBranch();
248
- const plan = readCursorSessionAgentCleanupPlan(entries, branch, getCurrentCleanupScope(ctx));
279
+ const scope = getCurrentCleanupScope(ctx);
280
+ const plan = readCursorSessionAgentCleanupPlanDetails(entries, branch, scope);
249
281
  const baseEntry = {
250
282
  runtime: "local" as const,
251
283
  timestamp: new Date().toISOString(),
@@ -274,19 +306,38 @@ export async function runCursorSessionAgentCleanupCommand(pi: LocalResumeCleanup
274
306
 
275
307
  const deletedAgentIds: string[] = [];
276
308
  const failedAgentIds: CursorSessionAgentCleanupFailure[] = [];
309
+ const openedStores = new Map<string, Awaited<ReturnType<typeof openCursorSessionStore>>>();
277
310
  try {
278
311
  const operations = await getSdkOperations();
279
- for (const agentId of plan.candidateAgentIds) {
312
+ const identities = await getCursorSessionStoreIdentities(ctx.cwd, scope.scopeKey, scope.sessionFile !== undefined);
313
+ for (const candidate of plan.candidates) {
314
+ const { agentId } = candidate;
280
315
  try {
281
- await operations.delete(agentId, { cwd: ctx.cwd });
316
+ const identity = candidate.storeIdentity ?? identities.defaultStore;
317
+ if (
318
+ !cursorSessionStoreIdentitiesEqual(identity, identities.defaultStore) &&
319
+ !cursorSessionStoreIdentitiesEqual(identity, identities.sessionStore)
320
+ ) throw new InvalidCursorSessionStoreIdentityError("Recorded Cursor local store identity is not valid for this pi session");
321
+ let openedStore = openedStores.get(identity.stateRoot);
322
+ if (!openedStore) {
323
+ openedStore = await openCursorSessionStore(ctx.cwd, identity);
324
+ openedStores.set(identity.stateRoot, openedStore);
325
+ }
326
+ await operations.delete(agentId, { cwd: ctx.cwd, store: openedStore.store });
282
327
  deletedAgentIds.push(agentId);
283
328
  } catch (error) {
284
- failedAgentIds.push({ agentId, error: scrubSensitiveText(getString(asRecord(error), "message") ?? String(error)) });
329
+ failedAgentIds.push({
330
+ agentId,
331
+ error: scrubSensitiveText(getString(asRecord(error), "message") ?? String(error)),
332
+ ...(error instanceof InvalidCursorSessionStoreIdentityError ? { retryable: false } : {}),
333
+ });
285
334
  }
286
335
  }
287
336
  } catch (error) {
288
337
  const message = scrubSensitiveText(getString(asRecord(error), "message") ?? String(error));
289
338
  failedAgentIds.push(...plan.candidateAgentIds.map((agentId) => ({ agentId, error: message })));
339
+ } finally {
340
+ await Promise.all([...openedStores.values()].map((store) => store.dispose().catch(() => undefined)));
290
341
  }
291
342
  if (!appendDurableCleanupEntry(pi, ctx, {
292
343
  action: "delete",
@@ -5,6 +5,7 @@ import type {
5
5
  SessionShutdownEvent,
6
6
  SessionTreeEvent,
7
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { clearCursorSdkHttp1 } from "./cursor-http1.js";
8
9
  import { onCursorSessionScopeKeyChange } from "./cursor-session-scope.js";
9
10
 
10
11
  export interface CursorSessionAgentLifecycleExtensionApi {
@@ -22,11 +23,15 @@ export function registerCursorSessionAgentLifecycle(pi: CursorSessionAgentLifecy
22
23
  });
23
24
  pi.on("session_shutdown", async (event) => {
24
25
  const { disposeSessionCursorAgent, resetSessionCursorAgent } = await import("./cursor-session-agent.js");
25
- if (event.reason === "reload") {
26
- await resetSessionCursorAgent();
27
- return;
26
+ try {
27
+ if (event.reason === "reload") {
28
+ await resetSessionCursorAgent();
29
+ return;
30
+ }
31
+ await disposeSessionCursorAgent();
32
+ } finally {
33
+ clearCursorSdkHttp1();
28
34
  }
29
- await disposeSessionCursorAgent();
30
35
  });
31
36
  pi.on("session_compact", async () => {
32
37
  const { invalidateSessionAgent } = await import("./cursor-session-agent.js");