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 +6 -0
- package/README.md +1 -1
- package/docs/cursor-native-tool-replay.md +1 -1
- package/docs/cursor-testing-lessons.md +2 -2
- package/package.json +1 -1
- package/src/cursor-provider-turn-runner.ts +10 -1
- package/src/cursor-sdk-process-error-guard.ts +43 -0
- package/src/cursor-session-agent.ts +25 -4
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,7 @@
|
|
|
1
1
|
import { CursorLiveRunAbortError } from "./cursor-live-run-coordinator.js";
|
|
2
2
|
import { drainExistingCursorLiveRunBeforeSend } from "./cursor-provider-live-run-drain.js";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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
|
};
|