pi-cursor-sdk 0.0.0 → 0.1.0

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/package.json CHANGED
@@ -1,10 +1,42 @@
1
1
  {
2
2
  "name": "pi-cursor-sdk",
3
- "version": "0.0.0",
4
- "description": "Reserved package name. Real release coming soon.",
5
- "type": "module",
6
- "main": "index.js",
7
- "license": "MIT",
3
+ "version": "0.1.0",
4
+ "description": "pi provider extension backed by @cursor/sdk local agents",
8
5
  "author": "Mitch Fultz (https://github.com/fitchmultz)",
9
- "keywords": ["pi-package", "pi", "pi-extension", "extension", "cursor", "sdk"]
6
+ "license": "MIT",
7
+ "keywords": ["pi-package", "pi", "pi-extension", "cursor", "cursor-sdk", "ai", "extension"],
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/fitchmultz/pi-cursor-sdk.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/fitchmultz/pi-cursor-sdk/issues"
14
+ },
15
+ "homepage": "https://github.com/fitchmultz/pi-cursor-sdk#readme",
16
+ "files": ["src", "README.md", "docs", "LICENSE", "CHANGELOG.md"],
17
+ "type": "module",
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "typecheck": "tsc --noEmit",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest"
25
+ },
26
+ "dependencies": {
27
+ "@cursor/sdk": "^1.0.12"
28
+ },
29
+ "peerDependencies": {
30
+ "@mariozechner/pi-ai": "*",
31
+ "@mariozechner/pi-coding-agent": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@mariozechner/pi-coding-agent": "^0.72.1",
35
+ "@mariozechner/pi-ai": "^0.72.1",
36
+ "typescript": "^6.0.3",
37
+ "vitest": "^4.1.5"
38
+ },
39
+ "pi": {
40
+ "extensions": ["./src/index.ts"]
41
+ }
10
42
  }
@@ -0,0 +1,27 @@
1
+ // Generated from Cursor SDK checkpoint tokenDetails.maxTokens on 2026-05-04.
2
+ // These are default/non-Max-mode SDK context windows for Cursor models that do not
3
+ // expose a catalog `context` parameter. Do not replace them with Max Mode values
4
+ // unless the Cursor SDK exposes an exact Max Mode model selection and the extension
5
+ // uses that selection for matching pi model IDs.
6
+ export const BUNDLED_CONTEXT_WINDOWS = {
7
+ "claude-haiku-4-5": 200000,
8
+ "claude-opus-4-5": 200000,
9
+ "composer-1.5": 200000,
10
+ "composer-2": 200000,
11
+ default: 200000,
12
+ "gemini-2.5-flash": 200000,
13
+ "gemini-3-flash": 200000,
14
+ "gemini-3.1-pro": 200000,
15
+ "gpt-5-mini": 272000,
16
+ "gpt-5.1": 272000,
17
+ "gpt-5.1-codex-max": 272000,
18
+ "gpt-5.1-codex-mini": 272000,
19
+ "gpt-5.2": 272000,
20
+ "gpt-5.2-codex": 272000,
21
+ "gpt-5.3-codex": 272000,
22
+ "gpt-5.3-codex-spark": 128000,
23
+ "gpt-5.4-mini": 272000,
24
+ "gpt-5.4-nano": 272000,
25
+ "grok-4-20": 200000,
26
+ "kimi-k2.5": 262000,
27
+ } as const satisfies Record<string, number>;
@@ -0,0 +1,77 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ import { BUNDLED_CONTEXT_WINDOWS } from "./bundled-context-windows.js";
5
+
6
+ const CONTEXT_WINDOW_CACHE_FILE = "cursor-sdk-context-windows.json";
7
+
8
+ interface ContextWindowCacheFile {
9
+ contextWindows?: Record<string, number>;
10
+ }
11
+
12
+ function getCachePath(): string {
13
+ return join(getAgentDir(), CONTEXT_WINDOW_CACHE_FILE);
14
+ }
15
+
16
+ function isPositiveInteger(value: unknown): value is number {
17
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
18
+ }
19
+
20
+ function loadUserContextWindowOverrides(): Map<string, number> {
21
+ const path = getCachePath();
22
+ const overrides = new Map<string, number>();
23
+ if (!existsSync(path)) return overrides;
24
+ try {
25
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as ContextWindowCacheFile;
26
+ for (const [modelId, contextWindow] of Object.entries(parsed.contextWindows ?? {})) {
27
+ if (isPositiveInteger(contextWindow)) overrides.set(modelId, contextWindow);
28
+ }
29
+ } catch {
30
+ return overrides;
31
+ }
32
+ return overrides;
33
+ }
34
+
35
+ export function loadContextWindowCache(): Map<string, number> {
36
+ const cache = new Map<string, number>(Object.entries(BUNDLED_CONTEXT_WINDOWS));
37
+ for (const [modelId, contextWindow] of loadUserContextWindowOverrides()) {
38
+ cache.set(modelId, contextWindow);
39
+ }
40
+ return cache;
41
+ }
42
+
43
+ export function getCachedContextWindow(modelId: string): number | undefined {
44
+ return loadContextWindowCache().get(modelId);
45
+ }
46
+
47
+ export function getCheckpointContextWindow(checkpoint: unknown): number | undefined {
48
+ if (checkpoint === null || typeof checkpoint !== "object") return undefined;
49
+ const tokenDetails = (checkpoint as Record<PropertyKey, unknown>).tokenDetails;
50
+ if (tokenDetails === null || typeof tokenDetails !== "object") return undefined;
51
+ const maxTokens = (tokenDetails as Record<PropertyKey, unknown>).maxTokens;
52
+ if (!isPositiveInteger(maxTokens)) return undefined;
53
+ return maxTokens;
54
+ }
55
+
56
+ export function saveCachedContextWindow(modelId: string, contextWindow: number): void {
57
+ if (!isPositiveInteger(contextWindow)) return;
58
+ const overrides = loadUserContextWindowOverrides();
59
+ const bundledContextWindow = BUNDLED_CONTEXT_WINDOWS[modelId as keyof typeof BUNDLED_CONTEXT_WINDOWS];
60
+ if (bundledContextWindow === contextWindow) {
61
+ if (!overrides.has(modelId)) return;
62
+ overrides.delete(modelId);
63
+ } else {
64
+ if (overrides.get(modelId) === contextWindow) return;
65
+ overrides.set(modelId, contextWindow);
66
+ }
67
+ const path = getCachePath();
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ const data: ContextWindowCacheFile = {
70
+ contextWindows: Object.fromEntries([...overrides.entries()].sort(([a], [b]) => a.localeCompare(b))),
71
+ };
72
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
73
+ }
74
+
75
+ export const __testUtils = {
76
+ getCachePath,
77
+ };
package/src/context.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { Context, Message, ToolCall } from "@mariozechner/pi-ai";
2
+ import type { SDKImage } from "@cursor/sdk";
3
+
4
+ export interface CursorPrompt {
5
+ text: string;
6
+ images: SDKImage[];
7
+ }
8
+
9
+ function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
10
+ return block.type === "text";
11
+ }
12
+
13
+ function isImageBlock(block: { type: string }): block is { type: "image"; data: string; mimeType: string } {
14
+ return block.type === "image";
15
+ }
16
+
17
+ function isToolCallBlock(block: { type: string }): block is ToolCall {
18
+ return block.type === "toolCall";
19
+ }
20
+
21
+ function extractLatestImages(messages: Message[]): SDKImage[] {
22
+ // Find the last user message and extract images only from it
23
+ for (let i = messages.length - 1; i >= 0; i--) {
24
+ const msg = messages[i];
25
+ if (msg.role !== "user") continue;
26
+ if (typeof msg.content === "string") return [];
27
+
28
+ const images: SDKImage[] = [];
29
+ for (const block of msg.content) {
30
+ if (isImageBlock(block) && block.data && block.mimeType) {
31
+ images.push({ data: block.data, mimeType: block.mimeType });
32
+ }
33
+ }
34
+ return images;
35
+ }
36
+ return [];
37
+ }
38
+
39
+ function formatContentBlocks(content: string | { type: string; text?: string; data?: string; mimeType?: string }[]): string {
40
+ if (typeof content === "string") return content;
41
+ return content
42
+ .map((block) => {
43
+ if (isTextBlock(block)) return block.text;
44
+ if (isImageBlock(block)) return "[image omitted from transcript]";
45
+ return "";
46
+ })
47
+ .filter(Boolean)
48
+ .join("\n");
49
+ }
50
+
51
+ function formatToolCall(toolCall: ToolCall): string {
52
+ const args = JSON.stringify(toolCall.arguments);
53
+ return `Tool call (${toolCall.name}, call ${toolCall.id}): ${args}`;
54
+ }
55
+
56
+ export function buildCursorPrompt(context: Context): CursorPrompt {
57
+ const parts: string[] = [];
58
+
59
+ if (context.systemPrompt) {
60
+ parts.push(`System instructions from pi:\n${context.systemPrompt}`);
61
+ }
62
+
63
+ for (const msg of context.messages) {
64
+ switch (msg.role) {
65
+ case "user": {
66
+ const text = formatContentBlocks(msg.content);
67
+ if (text) parts.push(`User: ${text}`);
68
+ break;
69
+ }
70
+ case "assistant": {
71
+ const blocks = Array.isArray(msg.content) ? msg.content : [{ type: "text" as const, text: String(msg.content) }];
72
+ const textParts: string[] = [];
73
+ for (const block of blocks) {
74
+ if (isTextBlock(block)) {
75
+ textParts.push(block.text);
76
+ } else if (isToolCallBlock(block)) {
77
+ textParts.push(formatToolCall(block));
78
+ }
79
+ // Omit thinking content from transcript
80
+ }
81
+ if (textParts.length > 0) {
82
+ parts.push(`Assistant: ${textParts.join("\n")}`);
83
+ }
84
+ break;
85
+ }
86
+ case "toolResult": {
87
+ const text = formatContentBlocks(msg.content);
88
+ const label = msg.isError ? "Tool error" : "Tool result";
89
+ parts.push(`${label} (${msg.toolName}, call ${msg.toolCallId}): ${text}`);
90
+ break;
91
+ }
92
+ }
93
+ }
94
+
95
+ parts.push("Answer the latest user request above using your capabilities. Do not assume access to pi tools.");
96
+
97
+ const images = extractLatestImages(context.messages);
98
+
99
+ return { text: parts.join("\n\n"), images };
100
+ }
@@ -0,0 +1,342 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessageEventStream,
4
+ type Context,
5
+ createAssistantMessageEventStream,
6
+ type Model,
7
+ type SimpleStreamOptions,
8
+ type AssistantMessage,
9
+ } from "@mariozechner/pi-ai";
10
+ import { Agent, createAgentPlatform } from "@cursor/sdk";
11
+ import type { InteractionUpdate, SDKAgent } from "@cursor/sdk";
12
+ import { buildCursorPrompt } from "./context.js";
13
+ import { getEffectiveFastForModelId } from "./cursor-state.js";
14
+ import { buildCursorModelSelection } from "./model-discovery.js";
15
+ import { getCheckpointContextWindow, saveCachedContextWindow } from "./context-window-cache.js";
16
+
17
+ function makeInitialMessage(model: Model<Api>): AssistantMessage {
18
+ return {
19
+ role: "assistant",
20
+ content: [],
21
+ api: model.api,
22
+ provider: model.provider,
23
+ model: model.id,
24
+ usage: {
25
+ input: 0,
26
+ output: 0,
27
+ cacheRead: 0,
28
+ cacheWrite: 0,
29
+ totalTokens: 0,
30
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
31
+ },
32
+ stopReason: "stop",
33
+ timestamp: Date.now(),
34
+ };
35
+ }
36
+
37
+ class CursorAbortError extends Error {
38
+ constructor() {
39
+ super("aborted");
40
+ this.name = "CursorAbortError";
41
+ }
42
+ }
43
+
44
+ const CURSOR_API_KEY_ENV_VAR = "CURSOR_API_KEY";
45
+ const MISSING_API_KEY_MESSAGE =
46
+ "Cursor SDK runs require CURSOR_API_KEY or pi --api-key. Set CURSOR_API_KEY before starting pi, or restart pi with --api-key.";
47
+ const GENERIC_CURSOR_SDK_ERROR_MESSAGE =
48
+ "Cursor SDK request failed. The API key may be missing, invalid, or unauthorized. Verify CURSOR_API_KEY or pass --api-key, then retry.";
49
+ const AUTH_CURSOR_SDK_ERROR_MESSAGE =
50
+ "Cursor SDK request failed because the API key may be invalid or unauthorized. Verify CURSOR_API_KEY or pass --api-key, then retry.";
51
+
52
+ function escapeRegExp(value: string): string {
53
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ }
55
+
56
+ function scrubSensitiveText(text: string, apiKey?: string): string {
57
+ let scrubbed = text;
58
+ const trimmedKey = apiKey?.trim();
59
+ if (trimmedKey) {
60
+ scrubbed = scrubbed.replace(new RegExp(escapeRegExp(trimmedKey), "g"), "[redacted]");
61
+ }
62
+ return scrubbed
63
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]")
64
+ .replace(/(authorization\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]")
65
+ .replace(/(api[_-]?key\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]")
66
+ .replace(/(token\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]")
67
+ .replace(/(cookie\s*[:=]\s*)[^\n]+/gi, "$1[redacted]")
68
+ .replace(/(session\s*[:=]\s*)[^\s,;}]+/gi, "$1[redacted]");
69
+ }
70
+
71
+ function isGenericErrorMessage(message: string): boolean {
72
+ const normalized = message.trim().toLowerCase();
73
+ return normalized === "" || normalized === "error" || normalized === "unknown error";
74
+ }
75
+
76
+ function isLikelyAuthError(message: string): boolean {
77
+ return /\b(unauthorized|unauthorised|forbidden|invalid api key|invalid key|authentication|auth|401|403)\b/i.test(message);
78
+ }
79
+
80
+ function hasEnvCursorApiKey(): boolean {
81
+ return Boolean(process.env.CURSOR_API_KEY?.trim());
82
+ }
83
+
84
+ function isMissingCursorApiKey(apiKey?: string): boolean {
85
+ return !apiKey || (apiKey === CURSOR_API_KEY_ENV_VAR && !hasEnvCursorApiKey());
86
+ }
87
+
88
+ function sanitizeError(error: unknown, apiKey?: string): string {
89
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
90
+ if (message === MISSING_API_KEY_MESSAGE) return MISSING_API_KEY_MESSAGE;
91
+ const scrubbed = scrubSensitiveText(message, apiKey).trim();
92
+ if (isGenericErrorMessage(scrubbed)) return GENERIC_CURSOR_SDK_ERROR_MESSAGE;
93
+ if (isLikelyAuthError(scrubbed)) return AUTH_CURSOR_SDK_ERROR_MESSAGE;
94
+ return scrubbed || GENERIC_CURSOR_SDK_ERROR_MESSAGE;
95
+ }
96
+
97
+ function getObjectField(value: unknown, field: string): unknown {
98
+ if (!value || typeof value !== "object") return undefined;
99
+ return (value as Record<string, unknown>)[field];
100
+ }
101
+
102
+ function getCursorToolName(toolCall: unknown): string {
103
+ if (!toolCall || typeof toolCall !== "object") return "unknown";
104
+ const data = toolCall as Record<string, unknown>;
105
+ if (typeof data.name === "string") return data.name;
106
+ if (typeof data.type === "string") return data.type;
107
+ return "unknown";
108
+ }
109
+
110
+ function getCursorToolResult(toolCall: unknown): unknown {
111
+ return getObjectField(toolCall, "result");
112
+ }
113
+
114
+ async function cacheSdkContextWindow(agentId: string, modelId: string): Promise<void> {
115
+ try {
116
+ const platform = await createAgentPlatform();
117
+ const checkpoint = await platform.checkpointStore.loadLatest(agentId);
118
+ const contextWindow = getCheckpointContextWindow(checkpoint);
119
+ if (contextWindow) saveCachedContextWindow(modelId, contextWindow);
120
+ } catch {
121
+ // Context-window cache failures must not affect response streaming.
122
+ }
123
+ }
124
+
125
+ function summarizeCursorToolResult(result: unknown): string {
126
+ if (result === undefined) return "";
127
+ const parts: string[] = [];
128
+ const status = getObjectField(result, "status");
129
+ if (typeof status === "string") parts.push(status);
130
+ const value = getObjectField(result, "value");
131
+ const exitCode = getObjectField(value, "exitCode") ?? getObjectField(result, "exitCode");
132
+ if (typeof exitCode === "number") parts.push(`exit ${exitCode}`);
133
+ return parts.length > 0 ? `: ${parts.join(", ")}` : "";
134
+ }
135
+
136
+ export function streamCursor(
137
+ model: Model<Api>,
138
+ context: Context,
139
+ options?: SimpleStreamOptions,
140
+ ): AssistantMessageEventStream {
141
+ const stream = createAssistantMessageEventStream();
142
+
143
+ (async () => {
144
+ const partial = makeInitialMessage(model);
145
+ let agent: SDKAgent | null = null;
146
+ let abortSignal: AbortSignal | undefined;
147
+ let abortListener: (() => void) | undefined;
148
+
149
+ try {
150
+ const throwIfAborted = (): void => {
151
+ if (options?.signal?.aborted) throw new CursorAbortError();
152
+ };
153
+
154
+ stream.push({ type: "start", partial });
155
+ throwIfAborted();
156
+
157
+ const apiKey = options?.apiKey;
158
+ if (isMissingCursorApiKey(apiKey)) throw new Error(MISSING_API_KEY_MESSAGE);
159
+
160
+ const cwd = process.cwd();
161
+ const fastEnabled = getEffectiveFastForModelId(model.id);
162
+ const selection = buildCursorModelSelection(model.id, options?.reasoning ?? "off", fastEnabled);
163
+
164
+ agent = await Agent.create({
165
+ apiKey,
166
+ model: selection,
167
+ // Do not pass settingSources here. The Cursor SDK currently writes
168
+ // setting/rule loading INFO logs directly to process output, which corrupts pi's TUI.
169
+ local: { cwd },
170
+ });
171
+ throwIfAborted();
172
+
173
+ const prompt = buildCursorPrompt(context);
174
+ let textContentIndex = -1;
175
+ let thinkingContentIndex = -1;
176
+ const textDeltas: string[] = [];
177
+
178
+ const appendBufferedTextDelta = (text: string): void => {
179
+ textDeltas.push(text);
180
+ };
181
+
182
+ const appendTraceDelta = (text: string): void => {
183
+ if (thinkingContentIndex < 0) {
184
+ thinkingContentIndex = partial.content.length;
185
+ partial.content.push({ type: "thinking", thinking: "" });
186
+ stream.push({ type: "thinking_start", contentIndex: thinkingContentIndex, partial });
187
+ }
188
+ const block = partial.content[thinkingContentIndex];
189
+ if (block.type === "thinking") {
190
+ block.thinking += text;
191
+ stream.push({
192
+ type: "thinking_delta",
193
+ contentIndex: thinkingContentIndex,
194
+ delta: text,
195
+ partial,
196
+ });
197
+ }
198
+ };
199
+
200
+ const appendCursorToolStatus = (text: string): void => {
201
+ appendTraceDelta(`${text}\n`);
202
+ };
203
+
204
+ const flushBufferedText = (fallbackText?: string): void => {
205
+ const deltas = textDeltas.length > 0 ? textDeltas : fallbackText ? [fallbackText] : [];
206
+ if (deltas.length === 0) return;
207
+ textContentIndex = partial.content.length;
208
+ partial.content.push({ type: "text", text: "" });
209
+ stream.push({ type: "text_start", contentIndex: textContentIndex, partial });
210
+ const block = partial.content[textContentIndex];
211
+ if (block.type !== "text") return;
212
+ for (const delta of deltas) {
213
+ block.text += delta;
214
+ stream.push({
215
+ type: "text_delta",
216
+ contentIndex: textContentIndex,
217
+ delta,
218
+ partial,
219
+ });
220
+ }
221
+ stream.push({
222
+ type: "text_end",
223
+ contentIndex: textContentIndex,
224
+ content: block.text,
225
+ partial,
226
+ });
227
+ };
228
+
229
+ const onDelta = (args: { update: InteractionUpdate }): void => {
230
+ const update = args.update;
231
+
232
+ if (update.type === "text-delta") {
233
+ appendBufferedTextDelta(update.text);
234
+ } else if (update.type === "thinking-delta") {
235
+ appendTraceDelta(update.text);
236
+ } else if (update.type === "thinking-completed") {
237
+ if (thinkingContentIndex >= 0) {
238
+ const block = partial.content[thinkingContentIndex];
239
+ if (block.type === "thinking") {
240
+ stream.push({
241
+ type: "thinking_end",
242
+ contentIndex: thinkingContentIndex,
243
+ content: block.thinking,
244
+ partial,
245
+ });
246
+ }
247
+ thinkingContentIndex = -1;
248
+ }
249
+ } else if (update.type === "tool-call-started") {
250
+ appendCursorToolStatus(`Cursor tool started (${getCursorToolName(update.toolCall)}, call ${update.callId})`);
251
+ } else if (update.type === "tool-call-completed") {
252
+ const suffix = summarizeCursorToolResult(getCursorToolResult(update.toolCall));
253
+ appendCursorToolStatus(`Cursor tool completed (${getCursorToolName(update.toolCall)}, call ${update.callId})${suffix}`);
254
+ } else if (update.type === "summary") {
255
+ appendCursorToolStatus(`Cursor summary: ${update.summary}`);
256
+ } else if (update.type === "turn-ended" && update.usage) {
257
+ partial.usage.input = update.usage.inputTokens;
258
+ partial.usage.output = update.usage.outputTokens;
259
+ partial.usage.cacheRead = update.usage.cacheReadTokens;
260
+ partial.usage.cacheWrite = update.usage.cacheWriteTokens;
261
+ partial.usage.totalTokens =
262
+ update.usage.inputTokens + update.usage.outputTokens + update.usage.cacheReadTokens + update.usage.cacheWriteTokens;
263
+ }
264
+ // partial-tool-call, summary-started, summary-completed,
265
+ // shell-output-delta, token-delta, step-* are intentionally not surfaced.
266
+ };
267
+
268
+ // Handle abort signal
269
+ let run: Awaited<ReturnType<SDKAgent["send"]>> | null = null;
270
+ abortListener = () => {
271
+ if (run) {
272
+ run.cancel().catch(() => {});
273
+ }
274
+ };
275
+ abortSignal = options?.signal;
276
+ abortSignal?.addEventListener("abort", abortListener, { once: true });
277
+
278
+ throwIfAborted();
279
+ run = await agent.send(
280
+ { text: prompt.text, images: prompt.images.length > 0 ? prompt.images : undefined },
281
+ { onDelta },
282
+ );
283
+ if (options?.signal?.aborted) {
284
+ await run.cancel().catch(() => {});
285
+ throw new CursorAbortError();
286
+ }
287
+
288
+ const result = await run.wait();
289
+ await cacheSdkContextWindow(agent.agentId, model.id);
290
+
291
+ // Close open thinking/trace before flushing final assistant text so saved
292
+ // message content is trace first, final answer second.
293
+ if (thinkingContentIndex >= 0) {
294
+ const block = partial.content[thinkingContentIndex];
295
+ if (block.type === "thinking") {
296
+ stream.push({
297
+ type: "thinking_end",
298
+ contentIndex: thinkingContentIndex,
299
+ content: block.thinking,
300
+ partial,
301
+ });
302
+ }
303
+ thinkingContentIndex = -1;
304
+ }
305
+
306
+ flushBufferedText(result.result);
307
+
308
+ if (result.status === "cancelled") {
309
+ partial.stopReason = "aborted";
310
+ stream.push({ type: "error", reason: "aborted", error: partial });
311
+ } else {
312
+ stream.push({ type: "done", reason: "stop", message: partial });
313
+ }
314
+ } catch (error) {
315
+ if (error instanceof CursorAbortError) {
316
+ partial.stopReason = "aborted";
317
+ stream.push({ type: "error", reason: "aborted", error: partial });
318
+ } else {
319
+ partial.stopReason = "error";
320
+ partial.errorMessage = sanitizeError(error, options?.apiKey);
321
+ stream.push({ type: "error", reason: "error", error: partial });
322
+ }
323
+ } finally {
324
+ if (abortSignal && abortListener) {
325
+ abortSignal.removeEventListener("abort", abortListener);
326
+ }
327
+
328
+ if (agent) {
329
+ try {
330
+ await agent[Symbol.asyncDispose]();
331
+ } catch {
332
+ // disposal failure should not mask original error
333
+ }
334
+ agent = null;
335
+ }
336
+ }
337
+
338
+ stream.end();
339
+ })();
340
+
341
+ return stream;
342
+ }