pi-cursor-sdk 0.1.59 → 0.1.60

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.60 - 2026-07-17
4
+
5
+ ### Fixed
6
+
7
+ - Contain the fatal raw `write EPIPE` uncaught exception the Cursor SDK's local shell executor can surface when a spawned child exits mid stdin write. Containment matches only the exact observed shape (code `EPIPE`, syscall `write`, stack exactly the single async pipe-write completion frame `WriteWrap.onWriteComplete`) and only while a local Cursor provider turn is active (from the turn's pre-send live-run drain through run completion, including the live-run wait window). A contained hit marks that turn's pooled/resumable local agent transport dead so the next acquire disposes it with a bounded wait and recreates it. EPIPE outside an active local turn — including idle pooled agents between turns — and the multi-frame synchronous write-path EPIPE from pi's own piped stdout or a dead terminal stay fatal. Known limit: an in-flight async pipe write anywhere in the process produces an identical shape, so one such EPIPE landing exactly during an active local turn is also contained; the next write to that pipe still dies normally.
8
+
3
9
  ## 0.1.59 - 2026-07-16
4
10
 
5
11
  ### Changed
package/README.md CHANGED
@@ -438,7 +438,7 @@ When a Cursor run fails after auth is configured, pi now surfaces scrubbed provi
438
438
 
439
439
  Aborted runs now include a likely cause when determinable, for example `Cancelled: prompt interrupted.` for user cancel or `Cancelled: Cursor SDK run was cancelled.` for SDK-side cancellation.
440
440
 
441
- Network failures from the Cursor SDK connect layer (for example `ConnectError: read ETIMEDOUT` or `ConnectError: [aborted] read ECONNRESET`) surface as scrubbed `Network error` messages instead of crashing pi, matching pi's native auto-retry classifier. The exact Cursor SDK 1.0.23-provenance `WriteIterableClosedError: WritableIterable is closed` race is contained for the Pi session lifecycle because controlled-exec can reject after the originating provider turn; Connect/network/abort suppression remains active-turn scoped, and unrelated failures remain fatal. The affected run may still report its underlying transport or tool failure normally. Persistent failures may indicate a transient Cursor service or network issue.
441
+ Network failures from the Cursor SDK connect layer (for example `ConnectError: read ETIMEDOUT` or `ConnectError: [aborted] read ECONNRESET`) surface as scrubbed `Network error` messages instead of crashing pi, matching pi's native auto-retry classifier. The exact Cursor SDK 1.0.23-provenance `WriteIterableClosedError: WritableIterable is closed` race is contained for the Pi session lifecycle because controlled-exec can reject after the originating provider turn; Connect/network/abort suppression remains active-turn scoped, and unrelated failures remain fatal. The observed raw `write EPIPE` uncaught exception (SDK 1.0.23 local shell executor writes child stdin without a stream error listener, so a child exiting mid-write surfaces a single-frame async pipe-write EPIPE) is contained only for that exact shape and only while a local Cursor provider turn is active; a contained closed pipe marks that turn's pooled/resumable agent transport dead so the next turn recreates it. EPIPE outside an active local turn, and the multi-frame synchronous write-path EPIPE from pi's own piped stdout or a dead terminal, stay fatal. The affected run may still report its underlying transport or tool failure normally. Persistent failures may indicate a transient Cursor service or network issue.
442
442
 
443
443
  You can also restart pi with a key in the same shell or launcher that starts pi:
444
444
 
@@ -158,7 +158,7 @@ These are integration boundaries, not pi replay bugs:
158
158
  - **Live web-search ordering:** local Cursor WebSearch can be absent from live `onDelta`, `onStep`, and `run.stream()` tool events. When the only evidence is a post-run local transcript `webSearchToolCall`, pi can display the **Cursor web search** card only after `run.wait()` finishes. The extension intentionally keeps assistant text streaming instead of buffering the whole answer just to reorder that card.
159
159
  - **WebFetch availability:** `pi-cursor-sdk` can display a Cursor web fetch only after the SDK reports a `webFetchToolCall`, web-fetch-shaped MCP completion, or web-fetch host alias. It cannot make the Cursor SDK expose or execute WebFetch in a run where Cursor's tool set does not include it.
160
160
  - **Future SDK tools:** Cursor's official SDK docs say tool names, args, and result payloads can change. Unknown completed tools therefore fall back to neutral Cursor activity cards with bounded, scrubbed text. The extension cannot render tools that the SDK never emits.
161
- - **Process-level transport exceptions:** Connect/network/abort suppression remains scoped to active provider turns. The exact SDK-provenance `WriteIterableClosedError: WritableIterable is closed` is guarded for the Pi session lifecycle because SDK 1.0.23 controlled-exec can reject after its originating provider turn when it writes an error frame after output closes. Unrelated failures remain fatal; a materially different future SDK process error must be added only after observation.
161
+ - **Process-level transport exceptions:** Connect/network/abort suppression remains scoped to active provider turns. The exact SDK-provenance `WriteIterableClosedError: WritableIterable is closed` is guarded for the Pi session lifecycle because SDK 1.0.23 controlled-exec can reject after its originating provider turn when it writes an error frame after output closes. The observed raw `write EPIPE` (SDK 1.0.23 local shell executor child stdin write with no stream error listener; stack exactly the single async `WriteWrap.onWriteComplete` frame, no SDK provenance) is guarded only while a local Cursor provider turn is active, and a contained closed pipe marks that turn's pooled/resumable agent transport dead for bounded disposal and recreation on the next acquire. Idle pooled agents do not suppress anything, and multi-frame synchronous write-path EPIPE (piped stdout, dead terminal) stays fatal. Unrelated failures remain fatal; a materially different future SDK process error must be added only after observation.
162
162
 
163
163
  Maintainer debug (`PI_CURSOR_SDK_EVENT_DEBUG=1`) still records the same discarded started-call events in `coordinator-events.jsonl` under phase `discarded-incomplete-started-tool-call` for investigation (**#52**), including fast local starts suppressed from successful text-producing runs. User-visible incomplete cards and debug artifacts are complementary: cards explain actionable gaps in the TUI; debug files retain normalized tool names and scrubbed call-id hashes without changing default stderr behavior.
164
164
 
@@ -412,7 +412,7 @@ npm run debug:sdk-events -- \
412
412
 
413
413
  Start with whether pi stayed alive:
414
414
 
415
- 0. **pi process exited / shell returned with an uncaught SDK transport error** — examples include `ConnectError` with `ETIMEDOUT`/`ECONNRESET` and `WriteIterableClosedError: WritableIterable is closed`. Current code keeps Connect/network/abort suppression scoped to active provider turns. The exact SDK-provenance closed-writable shape is guarded for the Pi session lifecycle because `@cursor/sdk` 1.0.23 controlled-exec can reject after the originating provider turn when it attempts to write a `throw` frame after its internal output iterable has already closed; Bun requires an explicit rejection listener because it bypasses the patched `process.emit` path. Unrelated failures remain fatal. This proves the secondary process-killing write, not the earlier condition that first closed the SDK iterable. Treat a fresh process exit as a process-guard regression, capture the stack/session tail, and route it separately from #40 model text echo. If tools were mid-flight, note whether session JSONL ends abruptly and whether the final tool call lacks a result.
415
+ 0. **pi process exited / shell returned with an uncaught SDK transport error** — examples include `ConnectError` with `ETIMEDOUT`/`ECONNRESET` and `WriteIterableClosedError: WritableIterable is closed`. Current code keeps Connect/network/abort suppression scoped to active provider turns. The exact SDK-provenance closed-writable shape is guarded for the Pi session lifecycle because `@cursor/sdk` 1.0.23 controlled-exec can reject after the originating provider turn when it attempts to write a `throw` frame after its internal output iterable has already closed; Bun requires an explicit rejection listener because it bypasses the patched `process.emit` path. The observed raw `write EPIPE` uncaught exception (code `EPIPE`, syscall `write`, stack exactly the single async `WriteWrap.onWriteComplete` frame) from the SDK 1.0.23 local shell executor writing a spawned child's stdin without a stream error listener is guarded only while a local Cursor provider turn is active; containment marks that turn's pooled/resumable agent transport dead so the next acquire disposes it with a bounded wait and recreates it. Idle pooled agents do not suppress EPIPE, and the multi-frame synchronous write-path shape (piped stdout, dead terminal) stays fatal. The Windows sibling surfaces as `write EOF` and intentionally stays fatal because it does not match the observed Node EPIPE contract. Unrelated failures remain fatal. This proves the secondary process-killing write, not the earlier condition that first closed the SDK iterable. Treat a fresh process exit as a process-guard regression, capture the stack/session tail, and route it separately from #40 model text echo. If tools were mid-flight, note whether session JSONL ends abruptly and whether the final tool call lacks a result.
416
416
 
417
417
  Then inspect the failing assistant turn in `$SMOKE_DIR/session/*.jsonl`:
418
418
 
@@ -432,7 +432,7 @@ rg '"type": "toolCall"|Tool call \(Cursor|cursor-replay-' "$SMOKE_DIR/session"/*
432
432
 
433
433
  ### When to file follow-ups
434
434
 
435
- - **#43/#107** — pi exited from an uncaught Cursor SDK transport failure (hard crash, not a scrubbed #55 toast). Observed Connect/network/abort shapes remain guarded only during active provider turns; the exact SDK-provenance `WriteIterableClosedError` is guarded for the Pi session lifecycle because controlled-exec can reject after a turn. Unrelated failures remain fatal, and new exits need stack/session evidence.
435
+ - **#43/#107** — pi exited from an uncaught Cursor SDK transport failure (hard crash, not a scrubbed #55 toast). Observed Connect/network/abort shapes remain guarded only during active provider turns; the exact local-turn `write EPIPE` shape is guarded only during active local Cursor provider turns and invalidates only that turn's local agent transport. The exact SDK-provenance `WriteIterableClosedError` is guarded for the Pi session lifecycle because controlled-exec can reject after a turn. Unrelated failures remain fatal, and new exits need stack/session evidence.
436
436
  - **#55** — caught SDK run failure or abort with missing/opaque detail (already addressed on main for surfacing).
437
437
  - **#52** — stale/inactive native replay routing after plan-strip or stale `context.tools` snapshot (`Tool * not found` in JSONL, `inactive_trace` in `display-decisions.jsonl`); or maintainer needs an explicit "started X, never completed" debug line when JSONL shows no completion and no model text echo.
438
438
  - **New issue** — bridge dispatch failure with `[pi-cursor-sdk:bridge]` evidence, or proven provider bug with JSONL showing missing `toolCall` despite SDK `tool-call-completed` in `on-delta.jsonl` from `debug:provider-events` or `debug:sdk-events` artifacts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.1.59",
3
+ "version": "0.1.60",
4
4
  "description": "pi provider extension backed by @cursor/sdk local and cloud agents",
5
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
6
6
  "license": "MIT",
@@ -1,6 +1,7 @@
1
1
  import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
2
2
  import { drainExistingCursorLiveRunBeforeSend } from "./cursor-provider-live-run-drain.js";
3
- import { getCursorSessionCwd } from "./cursor-session-scope.js";
3
+ import { invalidateSessionAgent } from "./cursor-session-agent.js";
4
+ import { getCursorSessionCwd, getCursorSessionScopeKey } from "./cursor-session-scope.js";
4
5
  import { installCursorSdkProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
5
6
  import { CursorSdkEventDebugSink } from "./cursor-sdk-event-debug.js";
6
7
  import { awaitFinalizeCursorRunOutcome } from "./cursor-provider-turn-finalize.js";
@@ -74,6 +75,14 @@ export class CursorProviderTurnRunner {
74
75
  // prepare dispatch below always act on the same config snapshot.
75
76
  const resolvedConfig = resolveCursorProviderTurnConfig(cwd);
76
77
  if (resolvedConfig.runtime.value === "local") {
78
+ // The observed local-executor closed-pipe EPIPE is contained only from this
79
+ // turn's pre-send live-run drain through run completion; for live runs the
80
+ // finalizer holds the guard until run.wait() settles. A hit marks
81
+ // this scope's pooled agent transport dead so the next acquire recreates it.
82
+ const localScopeKey = getCursorSessionScopeKey();
83
+ sdkProcessErrorGuard.containLocalTransportClosedPipe(() =>
84
+ invalidateSessionAgent(localScopeKey, { deadTransport: true }),
85
+ );
77
86
  if (
78
87
  (await drainExistingCursorLiveRunBeforeSend(stream, partial, model, context, options?.signal, this.sdkEventDebug)) ===
79
88
  "stream_ended"
@@ -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,9 +54,46 @@ function isCursorSdkWriteIterableClosedError(error: unknown): boolean {
52
54
  );
53
55
  }
54
56
 
57
+ // The exact observed incident: the Cursor SDK 1.0.23 local shell executor writes a
58
+ // spawned child's stdin without a stream 'error' listener, so a child exiting while
59
+ // a write is in flight surfaces a raw `write EPIPE` uncaught exception whose stack
60
+ // is exactly the single async pipe-write completion frame. Pi's own piped-stdout or
61
+ // dead-terminal EPIPE normally surfaces through the synchronous write-dispatch path
62
+ // with multiple frames (afterWriteDispatched/Socket._writeGeneric) and must stay
63
+ // fatal per Unix convention, so anything beyond this one-frame contract is rejected.
64
+ const OBSERVED_CLOSED_PIPE_STACK_FRAME =
65
+ /^\s+at WriteWrap\.onWriteComplete \[as oncomplete\] \(node:internal\/stream_base_commons:\d+:\d+\)$/;
66
+
67
+ function isObservedLocalTransportClosedPipeWriteError(error: unknown): boolean {
68
+ if (!(error instanceof Error) || error.name !== "Error") return false;
69
+ const { code, syscall } = error as NodeJS.ErrnoException;
70
+ if (code !== "EPIPE" || syscall !== "write" || !error.message.startsWith("write EPIPE")) return false;
71
+ const frames = (error.stack ?? "").split("\n").filter((line) => /^\s+at /.test(line));
72
+ return frames.length === 1 && OBSERVED_CLOSED_PIPE_STACK_FRAME.test(frames[0] ?? "");
73
+ }
74
+
75
+ // Contained only while a provider turn that declared a local transport is active;
76
+ // each contained turn invalidates its own session-agent scope for recreation.
77
+ function containLocalTransportClosedPipeError(): boolean {
78
+ let contained = false;
79
+ for (const turn of [...activeProviderTurns]) {
80
+ if (!turn.onLocalTransportClosedPipe) continue;
81
+ contained = true;
82
+ try {
83
+ turn.onLocalTransportClosedPipe();
84
+ } catch {
85
+ // stale-agent invalidation must not throw inside process error handling
86
+ }
87
+ }
88
+ return contained;
89
+ }
90
+
55
91
  function shouldSuppressProcessError(event: string | symbol, args: readonly unknown[]): boolean {
56
92
  if (event !== "uncaughtException" && event !== "unhandledRejection") return false;
57
93
  const error = args[0];
94
+ if (isObservedLocalTransportClosedPipeWriteError(error)) {
95
+ return containLocalTransportClosedPipeError();
96
+ }
58
97
  if (isCursorSdkWriteIterableClosedError(error)) return activeSessions.size > 0;
59
98
  const classification = classifyCursorConnectError(error);
60
99
  if (!classification) return false;
@@ -137,6 +176,10 @@ export function installCursorSdkProcessErrorGuard(): CursorSdkProcessErrorGuard
137
176
  if (disposed) return;
138
177
  token.suppressAbortErrors = true;
139
178
  },
179
+ containLocalTransportClosedPipe(onClosedPipe: () => void): void {
180
+ if (disposed) return;
181
+ token.onLocalTransportClosedPipe = onClosedPipe;
182
+ },
140
183
  dispose(): void {
141
184
  if (disposed) return;
142
185
  disposed = true;
@@ -126,6 +126,8 @@ interface SessionCursorAgentCreateParams {
126
126
 
127
127
  const sessionAgentsByScope = new Map<string, SessionCursorAgentPoolEntry>();
128
128
  const invalidatedScopeKeys = new Set<string>();
129
+ const deadTransportScopeKeys = new Set<string>();
130
+ let deadTransportAgentDisposeTimeoutMs = 3000;
129
131
  const terminalDisposedScopeGenerations = new Map<string, number>();
130
132
  const scopeCreationGenerations = new Map<string, number>();
131
133
  const EMPTY_POOL_STATE: SessionCursorAgentPoolState = { status: "empty" };
@@ -206,7 +208,7 @@ function buildSessionAgentPoolKey(scopeKey: string, params: SessionCursorAgentCr
206
208
  ].join("\0");
207
209
  }
208
210
 
209
- async function disposePoolEntry(entry: SessionCursorAgentPoolEntry): Promise<void> {
211
+ async function disposePoolEntry(entry: SessionCursorAgentPoolEntry, options?: { deadTransport?: boolean }): Promise<void> {
210
212
  if (!isActivePoolEntry(entry)) return;
211
213
  entry.bridgeRun?.cancel("Cursor session agent disposed");
212
214
  try {
@@ -215,7 +217,15 @@ async function disposePoolEntry(entry: SessionCursorAgentPoolEntry): Promise<voi
215
217
  // disposal failure should not block session replacement
216
218
  }
217
219
  try {
218
- await entry.agent[Symbol.asyncDispose]();
220
+ const disposal = Promise.resolve(entry.agent[Symbol.asyncDispose]()).catch(() => undefined);
221
+ // A dead local transport may never settle SDK disposal; bound the wait so the
222
+ // next acquire recreates instead of hanging on the dead agent.
223
+ await (options?.deadTransport
224
+ ? Promise.race([
225
+ disposal,
226
+ new Promise<void>((resolve) => setTimeout(resolve, deadTransportAgentDisposeTimeoutMs).unref?.()),
227
+ ])
228
+ : disposal);
219
229
  } catch {
220
230
  // disposal failure should not block session replacement
221
231
  }
@@ -228,6 +238,7 @@ async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?:
228
238
  }
229
239
  const entry = sessionAgentsByScope.get(scopeKey);
230
240
  invalidatedScopeKeys.delete(scopeKey);
241
+ const deadTransport = deadTransportScopeKeys.delete(scopeKey);
231
242
  if (!entry) return;
232
243
  sessionAgentsByScope.delete(scopeKey);
233
244
  if (entry.status === "busy") {
@@ -239,7 +250,7 @@ async function disposePoolEntryForScope(scopeKey: string, options?: { terminal?:
239
250
  });
240
251
  return;
241
252
  }
242
- await disposePoolEntry(entry);
253
+ await disposePoolEntry(entry, { deadTransport });
243
254
  }
244
255
 
245
256
  function createInitialSendState(): SessionCursorAgentSendState {
@@ -489,8 +500,12 @@ export {
489
500
  type CursorSessionSendPlan,
490
501
  } from "./cursor-session-send-policy.js";
491
502
 
492
- export function invalidateSessionAgent(scopeKey: string = getCursorSessionScopeKey()): void {
503
+ export function invalidateSessionAgent(
504
+ scopeKey: string = getCursorSessionScopeKey(),
505
+ options?: { deadTransport?: boolean },
506
+ ): void {
493
507
  invalidatedScopeKeys.add(scopeKey);
508
+ if (options?.deadTransport) deadTransportScopeKeys.add(scopeKey);
494
509
  }
495
510
 
496
511
  export async function acquireSessionCursorAgent(params: SessionCursorAgentCreateParams): Promise<SessionCursorAgentLease> {
@@ -611,6 +626,7 @@ export async function disposeAllSessionCursorAgents(): Promise<void> {
611
626
  const scopeKeys = [...new Set([...sessionAgentsByScope.keys(), ...terminalDisposedScopeGenerations.keys()])];
612
627
  await Promise.all(scopeKeys.map((scopeKey) => disposePoolEntryForScope(scopeKey, { terminal: true })));
613
628
  invalidatedScopeKeys.clear();
629
+ deadTransportScopeKeys.clear();
614
630
  terminalDisposedScopeGenerations.clear();
615
631
  }
616
632
 
@@ -624,6 +640,11 @@ export const __testUtils = {
624
640
  disposeAllSessionCursorAgents,
625
641
  buildApiKeyPoolKeyFingerprint,
626
642
  buildSessionAgentPoolKey,
643
+ setDeadTransportAgentDisposeTimeoutMs(ms: number): number {
644
+ const previous = deadTransportAgentDisposeTimeoutMs;
645
+ deadTransportAgentDisposeTimeoutMs = ms;
646
+ return previous;
647
+ },
627
648
  SessionCursorAgentCreationSupersededError,
628
649
  SessionCursorAgentScopeClosedError,
629
650
  };