pi-cursor-sdk 0.1.58 → 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 +12 -1
- package/README.md +3 -3
- package/docs/cursor-live-smoke-checklist.md +4 -4
- package/docs/cursor-model-ux-spec.md +2 -2
- package/docs/cursor-native-tool-replay.md +1 -1
- package/docs/cursor-native-tool-visual-audit.md +1 -1
- package/docs/cursor-testing-lessons.md +3 -3
- package/package.json +4 -4
- package/src/cursor-api-key.ts +3 -2
- 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,6 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
+
|
|
9
|
+
## 0.1.59 - 2026-07-16
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Migrate stored Cursor API-key reads from the removed root `AuthStorage` export to the Pi 0.80.9 `readStoredCredential()` contract, accepting only stored `api_key` credentials before the existing environment fallback.
|
|
14
|
+
- Build extension test contexts through an isolated async `ModelRuntime` and `ModelRegistry` compatibility facade, and refresh exact Pi development dependencies to 0.80.9 while preserving wildcard runtime peers.
|
|
4
15
|
|
|
5
16
|
## 0.1.58 - 2026-07-14
|
|
6
17
|
|
package/README.md
CHANGED
|
@@ -51,10 +51,10 @@ If pi started without a key, run `/cursor-refresh-models` after `/login` to refr
|
|
|
51
51
|
## Requirements
|
|
52
52
|
|
|
53
53
|
- Node.js 22.19+
|
|
54
|
-
- pi 0.80.
|
|
54
|
+
- pi 0.80.9 or newer recommended; pi core peer metadata is intentionally unpinned so newer pi releases are not blocked
|
|
55
55
|
- a Cursor SDK API key saved through `/login`, available as `CURSOR_API_KEY`, or passed with pi's `--api-key`
|
|
56
56
|
|
|
57
|
-
No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.23`, so normal package installation brings in the SDK version this extension was built and tested against. Cursor SDK 1.0.23 declares its Node ConnectRPC transport dependency directly, so npm installs place `@connectrpc/connect-node` where the SDK can resolve it. The extension intentionally does not bundle `@cursor/sdk` or its platform packages, because packing from one maintainer OS can otherwise ship the wrong optional SDK binary for another OS. Cursor SDK 1.0.23 keeps the older `sqlite3 -> node-gyp@8` dependency chain out of the runtime tree, so deprecated install warnings for `inflight`, `rimraf`, `glob@7`, `npmlog`, `gauge`, `are-we-there-yet`, and `tar@6` from that chain are not expected. This package follows pi package guidance by declaring pi core package peers with `"*"` ranges, so users who update pi before this extension is republished are not blocked by peer metadata. The current recommended and validated pi baseline is 0.80.
|
|
57
|
+
No global `@cursor/sdk` install is required. This package depends on exact `@cursor/sdk@1.0.23`, so normal package installation brings in the SDK version this extension was built and tested against. Cursor SDK 1.0.23 declares its Node ConnectRPC transport dependency directly, so npm installs place `@connectrpc/connect-node` where the SDK can resolve it. The extension intentionally does not bundle `@cursor/sdk` or its platform packages, because packing from one maintainer OS can otherwise ship the wrong optional SDK binary for another OS. Cursor SDK 1.0.23 keeps the older `sqlite3 -> node-gyp@8` dependency chain out of the runtime tree, so deprecated install warnings for `inflight`, `rimraf`, `glob@7`, `npmlog`, `gauge`, `are-we-there-yet`, and `tar@6` from that chain are not expected. This package follows pi package guidance by declaring pi core package peers with `"*"` ranges, so users who update pi before this extension is republished are not blocked by peer metadata. The current recommended and validated pi baseline is 0.80.9 plus Cursor SDK 1.0.23; older pi compatibility paths are best-effort and older Cursor SDK compatibility paths are not maintained.
|
|
58
58
|
|
|
59
59
|
## Install
|
|
60
60
|
|
|
@@ -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
|
|
|
@@ -67,8 +67,8 @@ The replay scan flags only error `toolResult` / error assistant messages with `T
|
|
|
67
67
|
|
|
68
68
|
Pass criteria:
|
|
69
69
|
|
|
70
|
-
- `pi --version` reports pi 0.80.
|
|
71
|
-
- `npm ls` shows `@cursor/sdk@1.0.23` and local `@earendil-works/*@0.80.
|
|
70
|
+
- `pi --version` reports pi 0.80.9 for this cutover baseline.
|
|
71
|
+
- `npm ls` shows `@cursor/sdk@1.0.23` and local `@earendil-works/*@0.80.9` packages.
|
|
72
72
|
- `cursor/composer-2-5` appears in the model list.
|
|
73
73
|
- No Cursor key or auth token is printed.
|
|
74
74
|
- If neither `~/.pi/agent/auth.json` cursor auth nor `CURSOR_API_KEY` is available, stop and report the live smoke as blocked.
|
|
@@ -125,7 +125,7 @@ Observe with `tmux capture-pane -pt "$SESSION"` or attach manually.
|
|
|
125
125
|
Pass criteria:
|
|
126
126
|
|
|
127
127
|
- Footer shows `(cursor) composer-2-5`. With `--cursor-no-fast`, Cursor fast mode is off and the Cursor extension status should show `cursor:local · fast:off`; ignore unrelated status text from other extensions.
|
|
128
|
-
- The run uses pi 0.80.
|
|
128
|
+
- The run uses pi 0.80.9 `--session-id` successfully.
|
|
129
129
|
- Assistant answer appears correctly.
|
|
130
130
|
- `/session` shows one user and one assistant message for the simple run.
|
|
131
131
|
- Persisted JSONL has one assistant message. If the screen appears duplicated, inspect JSONL before deciding whether it is a rendering bug.
|
|
@@ -133,7 +133,7 @@ Pass criteria:
|
|
|
133
133
|
|
|
134
134
|
## 4. Focused visual card/color rendering check
|
|
135
135
|
|
|
136
|
-
This is the canonical inner-loop visual debug path for Cursor provider/runtime changes. It requires offscreen TUI visual inspection, not only JSONL or code review. Use pi 0.80.
|
|
136
|
+
This is the canonical inner-loop visual debug path for Cursor provider/runtime changes. It requires offscreen TUI visual inspection, not only JSONL or code review. Use pi 0.80.9, `@cursor/sdk@1.0.23`, a fresh temporary session dir, Cursor SDK `plan` mode, native replay enabled, and the checked-in visual runner. The runner resolves `pi` by directly walking the parent `PATH`, uses `process.execPath` for Node, and prepends that Node directory for both prereq checks and tmux launches so `#!/usr/bin/env node` shims use the validated Node. The default matrix is native replay only: native replay registration is forced on, settings sources are `none`, the pi bridge is off, overlapping built-in pi tools are not exposed, and inherited Cursor SDK event-debug artifact env is cleared. With `--event-debug`, debug capture writes to a deterministic directory under `VISUAL_DIR`.
|
|
137
137
|
|
|
138
138
|
```bash
|
|
139
139
|
VISUAL_DIR="$(mktemp -d /tmp/pi-cursor-sdk-1016-visual.XXXXXX)"
|
|
@@ -15,7 +15,7 @@ Current implementation notes:
|
|
|
15
15
|
- Cursor status uses one coordinated `ctx.ui.setStatus("cursor", ...)` value for fast and non-default plan mode; the default pi footer remains intact.
|
|
16
16
|
- Installed `@cursor/sdk` user messages accept images, and Cursor models are treated as image-capable; registered input metadata is `text` plus `image`.
|
|
17
17
|
- Image payload forwarding sends images only from the latest user message. If the latest user turn is plain text after an earlier image turn, the transcript keeps an `[image omitted from transcript]` placeholder but no image bytes are sent to Cursor. The prompt explicitly tells Cursor that prior image bytes are unavailable and to ask the user to reattach or describe a prior image when needed. Carrying images forward across turns remains a future product decision because it affects token cost, privacy, stale visual context, and expected multimodal follow-up behavior.
|
|
18
|
-
- Exact `@cursor/sdk@1.0.23` is a package dependency of this extension; users should not need a global SDK install. pi 0.80.
|
|
18
|
+
- Exact `@cursor/sdk@1.0.23` is a package dependency of this extension; users should not need a global SDK install. pi 0.80.9 is the current recommended validation baseline, while published pi core peer dependencies use `"*"` ranges per current pi package guidance. Newer pi versions are allowed to attempt loading this extension before a matching extension release exists; compatibility is best-effort until validated.
|
|
19
19
|
- Startup discovery does not duplicate Pi CLI parsing: it uses stored `~/.pi/agent/auth.json` API-key auth from `/login`, then `CURSOR_API_KEY`, otherwise it registers the bundled fallback catalog. Provider turns keep Pi's resolved `options.apiKey`. `/cursor-refresh-models` and `/cursor-cloud` mutations resolve provider `cursor` through the command context's Pi ModelRegistry, then normalize placeholders through `CURSOR_API_KEY`. The extension config file stores only non-secret Cursor-only state such as fast defaults.
|
|
20
20
|
- Cursor Cloud repository overrides accept only HTTPS repository URLs without userinfo, query parameters, or fragments. Invalid values fail during preflight before `Agent.create()`, messages never echo the supplied URL, and shared provider/maintainer scrubbing removes URL/SCP-style userinfo defensively.
|
|
21
21
|
- Cursor Cloud requires a persisted pi session. Immediately after remote `Agent.create()` returns, before debug work or abort checks, the provider appends a branch-local pi lifecycle entry, fsyncs the existing Pi session JSONL anchor through a read-write descriptor, and then fsyncs a newline-framed sidecar keyed by the stable pi session ID (POSIX mode `0600`; Windows inherits the user session directory ACL) in the session directory. Journal creation is exclusive, and existing append/read opens reject symlinks and require matching regular-file descriptor/path identity before using the descriptor. Existing session files use the exact lifecycle entry ID as anchor; fileless first turns use an orphan marker that a same-session-ID restart durably claims onto exactly one matching or replacement branch, surviving the timestamped path change and later JSONL creation without granting sibling access; returned run IDs are fsynced before abort/wait handling, readers skip malformed/truncated lines independently, and branch-only history events are reduced after deduplicated sidecar history, and tombstones are tracked before records exist. A durable sidecar result remains authoritative if its optional Pi mirror append fails; if the sidecar result itself fails, the prior durable intent remains pending and blocks retry rather than claiming success. `--no-session` and durable-ledger failures fail closed before send, while post-send ledger failure requests bounded cancellation. `/cursor-cloud` always validates the embedded session ID, accepts only null anchors while truly fileless, and reconciles each orphan to one branch before listing or mutation. Successive record events merge run/branch metadata monotonically even when mirrored sources arrive out of order. Archive/delete require resolved Cursor auth, fsync a durable intent before the SDK call, and fsync a durable success result afterward; unresolved intent blocks repeat mutation and requires dashboard inspection.
|
|
@@ -83,7 +83,7 @@ Cursor.models.list({ apiKey });
|
|
|
83
83
|
|
|
84
84
|
Startup discovery resolves `apiKey` in this order:
|
|
85
85
|
|
|
86
|
-
1. Stored pi auth for provider `cursor` from `
|
|
86
|
+
1. Stored pi auth for provider `cursor` from `readStoredCredential("cursor")`, accepting only an `api_key` credential.
|
|
87
87
|
2. `CURSOR_API_KEY`.
|
|
88
88
|
|
|
89
89
|
Startup never parses `process.argv`; Pi remains the sole owner of CLI model/provider/key parsing. Provider turns keep Pi's resolved `options.apiKey`. Users can persist the stored key through `/login` -> `Use an API key` -> `Cursor`. If auth is added after startup, fallback models can run once Pi resolves the saved key for provider requests, and `/cursor-refresh-models` asks the command context's ModelRegistry for provider `cursor` and passes that normalized key explicitly to a forced live refresh.
|
|
@@ -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
|
|
|
@@ -6,7 +6,7 @@ This workflow is the canonical repo path for verifying Cursor SDK tool replay th
|
|
|
6
6
|
|
|
7
7
|
Use it before accepting replay-card commits or PRs, and for every Cursor provider/runtime release where TUI card/color behavior could regress. Text logs and JSONL are necessary, but they are not enough when the claim is visual parity: always keep PNGs for the exact prompt, and keep before/after PNGs when reviewing a rendering change.
|
|
8
8
|
|
|
9
|
-
Current validation baseline: pi 0.80.
|
|
9
|
+
Current validation baseline: pi 0.80.9, exact `@cursor/sdk@1.0.23`, local validation packages `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` at 0.80.9. Published pi core peer dependencies use `"*"` ranges per current pi package guidance, so newer pi installs can try the extension before a matching validation release exists.
|
|
10
10
|
|
|
11
11
|
## Cursor SDK 1.0.17 / pi 0.79.0 cutover visual record
|
|
12
12
|
|
|
@@ -256,7 +256,7 @@ The script writes timestamped artifacts under `--out` (default `/tmp/pi-cursor-s
|
|
|
256
256
|
|
|
257
257
|
Stdout prints artifact paths and summary counts only. Raw payloads stay on disk and may contain local paths, project text, tool args/results, or secrets — do not commit or share them.
|
|
258
258
|
|
|
259
|
-
Hard repo rule: Cursor SDK behavior claims must come from the installed `@cursor/sdk` package and/or https://cursor.com/docs/sdk/typescript, not from memory or ad-hoc probes alone. Current cutover validation targets exact `@cursor/sdk@1.0.23` and pi 0.80.
|
|
259
|
+
Hard repo rule: Cursor SDK behavior claims must come from the installed `@cursor/sdk` package and/or https://cursor.com/docs/sdk/typescript, not from memory or ad-hoc probes alone. Current cutover validation targets exact `@cursor/sdk@1.0.23` and pi 0.80.9 local packages.
|
|
260
260
|
|
|
261
261
|
## Pi provider SDK event capture
|
|
262
262
|
|
|
@@ -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.
|
|
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",
|
|
@@ -151,9 +151,9 @@
|
|
|
151
151
|
"typebox": "*"
|
|
152
152
|
},
|
|
153
153
|
"devDependencies": {
|
|
154
|
-
"@earendil-works/pi-ai": "0.80.
|
|
155
|
-
"@earendil-works/pi-coding-agent": "0.80.
|
|
156
|
-
"@earendil-works/pi-tui": "0.80.
|
|
154
|
+
"@earendil-works/pi-ai": "0.80.9",
|
|
155
|
+
"@earendil-works/pi-coding-agent": "0.80.9",
|
|
156
|
+
"@earendil-works/pi-tui": "0.80.9",
|
|
157
157
|
"@xterm/xterm": "^6.0.0",
|
|
158
158
|
"node-pty": "^1.1.0",
|
|
159
159
|
"playwright": "^1.60.0",
|
package/src/cursor-api-key.ts
CHANGED
|
@@ -23,8 +23,9 @@ export function resolveCursorApiKey(apiKey?: string): string | undefined {
|
|
|
23
23
|
|
|
24
24
|
async function getStoredCursorApiKey(): Promise<string | undefined> {
|
|
25
25
|
try {
|
|
26
|
-
const {
|
|
27
|
-
|
|
26
|
+
const { readStoredCredential } = await import("@earendil-works/pi-coding-agent");
|
|
27
|
+
const credential = readStoredCredential(CURSOR_PROVIDER_ID);
|
|
28
|
+
return resolveCursorApiKey(credential?.type === "api_key" ? credential.key : undefined);
|
|
28
29
|
} catch {
|
|
29
30
|
return undefined;
|
|
30
31
|
}
|
|
@@ -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
|
};
|