@tt-a1i/openpi 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +30 -20
  2. package/SETUP.md +10 -4
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/bin/openpi.js +25 -15
  5. package/extensions/ai-providers/LICENSE.upstream +23 -0
  6. package/extensions/ai-providers/README.md +65 -0
  7. package/extensions/ai-providers/antigravity/credentials.ts +52 -0
  8. package/extensions/ai-providers/antigravity/discovery.ts +130 -0
  9. package/extensions/ai-providers/antigravity/google-conversion.ts +455 -0
  10. package/extensions/ai-providers/antigravity/models.ts +84 -0
  11. package/extensions/ai-providers/antigravity/oauth.ts +700 -0
  12. package/extensions/ai-providers/antigravity/provider.ts +1116 -0
  13. package/extensions/ai-providers/antigravity/routing.ts +340 -0
  14. package/extensions/ai-providers/antigravity/with-resolvers.d.ts +19 -0
  15. package/extensions/ai-providers/cursor/constants.ts +5 -0
  16. package/extensions/ai-providers/cursor/credentials.ts +14 -0
  17. package/extensions/ai-providers/cursor/discovery.ts +291 -0
  18. package/extensions/ai-providers/cursor/input-images.ts +105 -0
  19. package/extensions/ai-providers/cursor/models.ts +45 -0
  20. package/extensions/ai-providers/cursor/oauth.ts +263 -0
  21. package/extensions/ai-providers/cursor/proto.ts +1271 -0
  22. package/extensions/ai-providers/cursor/protobuf.ts +1181 -0
  23. package/extensions/ai-providers/cursor/provider.ts +1431 -0
  24. package/extensions/ai-providers/cursor/proxy.ts +213 -0
  25. package/extensions/ai-providers/cursor/tool-bridge.ts +68 -0
  26. package/extensions/ai-providers/cursor/with-resolvers.d.ts +12 -0
  27. package/extensions/ai-providers/index.ts +86 -0
  28. package/extensions/ai-providers/oauth-adapter.ts +81 -0
  29. package/extensions/ai-providers/usage.ts +10 -0
  30. package/extensions/background-terminals/index.ts +8 -1
  31. package/extensions/background-terminals/src/manager.ts +3 -5
  32. package/extensions/background-terminals/src/result-delivery.ts +43 -23
  33. package/extensions/cron/index.ts +68 -27
  34. package/extensions/cron/schedule.ts +5 -1
  35. package/extensions/model-info/cache-diagnostics.ts +220 -0
  36. package/extensions/model-info/index.ts +45 -1
  37. package/extensions/plan-mode/index.ts +75 -4
  38. package/extensions/setup/index.ts +15 -3
  39. package/extensions/shared/child-session.ts +39 -5
  40. package/extensions/shared/completion-inbox.ts +193 -0
  41. package/extensions/shared/setup-config.ts +10 -1
  42. package/extensions/shared/structured-output.ts +154 -0
  43. package/extensions/subagents/index.ts +64 -7
  44. package/extensions/subagents/src/agent-types.ts +5 -17
  45. package/extensions/subagents/src/backends/pi.ts +130 -48
  46. package/extensions/subagents/src/backends/tool-preview.ts +29 -0
  47. package/extensions/subagents/src/domain.ts +16 -1
  48. package/extensions/subagents/src/manager.ts +7 -71
  49. package/extensions/subagents/src/prompt.ts +19 -5
  50. package/extensions/subagents/src/result-artifact.ts +32 -0
  51. package/extensions/subagents/src/result-delivery.ts +33 -14
  52. package/extensions/subagents/src/runtime.ts +10 -3
  53. package/extensions/ui-customization/footer.ts +16 -5
  54. package/extensions/user-input-fold/index.ts +42 -6
  55. package/extensions/web/index.ts +25 -2
  56. package/extensions/workflows/acceptance.ts +43 -19
  57. package/extensions/workflows/completion-projection.ts +3 -1
  58. package/extensions/workflows/dashboard.ts +147 -21
  59. package/extensions/workflows/index.ts +75 -20
  60. package/extensions/workflows/model.ts +5 -1
  61. package/extensions/workflows/progress-projection.ts +7 -1
  62. package/extensions/workflows/prompt.ts +4 -10
  63. package/extensions/workflows/result-delivery.ts +96 -22
  64. package/extensions/workflows/retention.ts +6 -0
  65. package/extensions/workflows/runner.ts +11 -233
  66. package/extensions/workflows/sandbox.ts +4 -0
  67. package/package.json +7 -7
  68. package/skills/subagents/REFERENCE.md +9 -9
  69. package/skills/subagents/SKILL.md +2 -1
  70. package/skills/workflows/REFERENCE.md +5 -3
  71. package/skills/workflows/SKILL.md +1 -1
  72. package/web/adapter/pi-adapter.ts +3 -0
  73. package/web/host/pi-coding-agent-entry.ts +162 -0
  74. package/web/host/web-host.ts +330 -50
  75. package/web/protocol/types.ts +5 -0
  76. package/web/runtime/pi-runtime.ts +240 -25
  77. package/web/runtime/types.ts +32 -1
  78. package/web/ui/app.js +343 -41
  79. package/web/ui/index.html +3 -0
  80. package/web/ui/styles.css +119 -37
@@ -0,0 +1,1431 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import * as http2 from "node:http2";
3
+ import type {
4
+ Api,
5
+ AssistantMessage,
6
+ AssistantMessageEventStream,
7
+ Context,
8
+ ImageContent,
9
+ Message,
10
+ Model,
11
+ SimpleStreamOptions,
12
+ TextContent,
13
+ ToolCall,
14
+ } from "@earendil-works/pi-ai/compat";
15
+ import { createAssistantMessageEventStream } from "@earendil-works/pi-ai/compat";
16
+ import { emptyUsage } from "../usage.ts";
17
+ import {
18
+ CURSOR_API_URL,
19
+ CURSOR_CLIENT_VERSION,
20
+ CURSOR_RUN_PATH,
21
+ } from "./constants.ts";
22
+ import {
23
+ AgentClientMessageSchema,
24
+ AgentConversationTurnStructureSchema,
25
+ type AgentRunRequest,
26
+ AgentRunRequestSchema,
27
+ AgentServerMessageSchema,
28
+ AssistantMessageSchema,
29
+ ClientHeartbeatSchema,
30
+ ConversationActionSchema,
31
+ type ConversationStateStructure,
32
+ ConversationStateStructureSchema,
33
+ ConversationStepSchema,
34
+ ConversationTurnStructureSchema,
35
+ type CursorRule,
36
+ CursorRuleSchema,
37
+ CursorRuleTypeGlobalSchema,
38
+ CursorRuleTypeSchema,
39
+ CursorToolCallSchema,
40
+ ExecClientControlMessageSchema,
41
+ ExecClientMessageSchema,
42
+ ExecClientStreamCloseSchema,
43
+ ExecClientThrowSchema,
44
+ GetBlobResultSchema,
45
+ type InteractionUpdate,
46
+ KvClientMessageSchema,
47
+ type KvServerMessage,
48
+ KvServerMessageSchema,
49
+ McpArgsSchema,
50
+ McpImageContentSchema,
51
+ McpRejectedSchema,
52
+ McpResultSchema,
53
+ McpSuccessSchema,
54
+ McpTextContentSchema,
55
+ McpToolCallSchema,
56
+ McpToolResultContentItemSchema,
57
+ McpToolResultSchema,
58
+ type ModelDetails,
59
+ ModelDetailsSchema,
60
+ RequestContextResultSchema,
61
+ RequestContextSchema,
62
+ RequestContextSuccessSchema,
63
+ type RequestedModel_ModelParameterbytes,
64
+ RequestedModel_ModelParameterbytesSchema,
65
+ RequestedModelSchema,
66
+ ResumeActionSchema,
67
+ SelectedContextSchema,
68
+ SelectedImageSchema,
69
+ SetBlobResultSchema,
70
+ UserMessageActionSchema,
71
+ UserMessageSchema,
72
+ } from "./proto.ts";
73
+ import { create, encodeJsonValue, fromBinary, toBinary } from "./protobuf.ts";
74
+ import { connectCursorHttp2 } from "./proxy.ts";
75
+ import {
76
+ buildCursorTools,
77
+ CURSOR_PI_PROVIDER,
78
+ CURSOR_PI_TOOLS_SYSTEM_PROMPT,
79
+ decodeCursorTool,
80
+ } from "./tool-bridge.ts";
81
+
82
+ const CONNECT_END_STREAM_FLAG = 0b00000010;
83
+ const CONNECT_COMPRESSED_FLAG = 0b00000001;
84
+ const MAX_CONNECT_FRAME_BYTES = 16 * 1024 * 1024;
85
+ const HEARTBEAT_INTERVAL_MS = 5_000;
86
+ const PROXY_TUNNEL_TIMEOUT_MS = 30_000;
87
+
88
+ export const CURSOR_CHAT_ONLY_SYSTEM_PROMPT =
89
+ "This Cursor provider is running in chat-only mode. No filesystem, shell, code modification, MCP, web, or user-interaction tools are available. Never emit tool calls or interaction queries. Images attached to the user message are already available for direct analysis. If required information is unavailable, explain the limitation in text instead of attempting a tool.";
90
+
91
+ const HTTP2_FORBIDDEN_HEADERS = new Set([
92
+ "connection",
93
+ "keep-alive",
94
+ "proxy-connection",
95
+ "transfer-encoding",
96
+ "upgrade",
97
+ "http2-settings",
98
+ ]);
99
+
100
+ const CURSOR_RESERVED_HEADERS = new Set([
101
+ "content-type",
102
+ "connect-protocol-version",
103
+ "te",
104
+ "authorization",
105
+ "x-ghost-mode",
106
+ "x-cursor-client-version",
107
+ "x-cursor-client-type",
108
+ "x-request-id",
109
+ "host",
110
+ "content-length",
111
+ ]);
112
+
113
+ type CursorBlobStore = Map<string, Uint8Array>;
114
+
115
+ export interface CursorRequestBuild {
116
+ request: AgentRunRequest;
117
+ requestBytes: Uint8Array;
118
+ blobStore: CursorBlobStore;
119
+ conversationState: ConversationStateStructure;
120
+ }
121
+
122
+ /** Connect's five-byte big-endian envelope. */
123
+ export function frameConnectMessage(data: Uint8Array, flags = 0): Buffer {
124
+ const frame = Buffer.allocUnsafe(5 + data.length);
125
+ frame[0] = flags;
126
+ frame.writeUInt32BE(data.length, 1);
127
+ frame.set(data, 5);
128
+ return frame;
129
+ }
130
+
131
+ function createBlobId(data: Uint8Array): Uint8Array {
132
+ return new Uint8Array(createHash("sha256").update(data).digest());
133
+ }
134
+
135
+ function storeBlob(store: CursorBlobStore, data: Uint8Array): Uint8Array {
136
+ const id = createBlobId(data);
137
+ store.set(Buffer.from(id).toString("hex"), data);
138
+ return id;
139
+ }
140
+
141
+ function textFromContent(
142
+ content: string | (TextContent | ImageContent)[],
143
+ ): string {
144
+ if (typeof content === "string") return content.trim();
145
+ return content
146
+ .filter((item): item is TextContent => item.type === "text")
147
+ .map((item) => item.text)
148
+ .join("\n")
149
+ .trim();
150
+ }
151
+
152
+ function imagesFromContent(content: string | (TextContent | ImageContent)[]) {
153
+ if (typeof content === "string") return [];
154
+ return content
155
+ .filter((item): item is ImageContent => item.type === "image")
156
+ .map((item) =>
157
+ create(SelectedImageSchema, {
158
+ uuid: randomUUID(),
159
+ path: "",
160
+ mimeType: item.mimeType,
161
+ dataOrBlobId: {
162
+ case: "data",
163
+ value: Uint8Array.from(Buffer.from(item.data, "base64")),
164
+ },
165
+ }),
166
+ );
167
+ }
168
+
169
+ function userMessageFromContent(
170
+ content: string | (TextContent | ImageContent)[],
171
+ messageId = randomUUID(),
172
+ ) {
173
+ const text = textFromContent(content);
174
+ const images = imagesFromContent(content);
175
+ return create(UserMessageSchema, {
176
+ text,
177
+ messageId,
178
+ ...(images.length > 0
179
+ ? {
180
+ selectedContext: create(SelectedContextSchema, {
181
+ selectedImages: images,
182
+ }),
183
+ }
184
+ : {}),
185
+ });
186
+ }
187
+
188
+ function rootPromptContent(
189
+ content: string | (TextContent | ImageContent)[],
190
+ ): Array<
191
+ | { type: "text"; text: string }
192
+ | { type: "image"; image: string; mediaType: string }
193
+ > {
194
+ if (typeof content === "string") {
195
+ const text = content.trim();
196
+ return text ? [{ type: "text", text }] : [];
197
+ }
198
+ const parts: Array<
199
+ | { type: "text"; text: string }
200
+ | { type: "image"; image: string; mediaType: string }
201
+ > = [];
202
+ for (const item of content) {
203
+ if (item.type === "text") {
204
+ const text = item.text.trim();
205
+ if (text) parts.push({ type: "text", text });
206
+ } else {
207
+ parts.push({
208
+ type: "image",
209
+ image: `data:${item.mimeType};base64,${item.data}`,
210
+ mediaType: item.mimeType,
211
+ });
212
+ }
213
+ }
214
+ return parts;
215
+ }
216
+
217
+ function assistantRootContent(
218
+ message: Extract<Message, { role: "assistant" }>,
219
+ results: Map<string, Extract<Message, { role: "toolResult" }>>,
220
+ ) {
221
+ const content: Array<Record<string, unknown>> = [];
222
+ for (const item of message.content) {
223
+ if (item.type === "text" && item.text) {
224
+ content.push({ type: "text", text: item.text });
225
+ } else if (
226
+ item.type === "toolCall" &&
227
+ results.get(item.id)?.toolName === item.name
228
+ ) {
229
+ content.push({
230
+ type: "tool-call",
231
+ toolCallId: item.id,
232
+ toolName: item.name,
233
+ args: item.arguments,
234
+ });
235
+ }
236
+ }
237
+ return content;
238
+ }
239
+
240
+ function pairedToolResults(messages: Message[], end: number) {
241
+ const calls = new Map<string, string>();
242
+ const results = new Map<string, Extract<Message, { role: "toolResult" }>>();
243
+ for (const message of messages.slice(0, end < 0 ? undefined : end)) {
244
+ if (message.role === "assistant") {
245
+ for (const part of message.content)
246
+ if (part.type === "toolCall") calls.set(part.id, part.name);
247
+ } else if (
248
+ message.role === "toolResult" &&
249
+ calls.get(message.toolCallId) === message.toolName
250
+ ) {
251
+ results.set(message.toolCallId, message);
252
+ }
253
+ }
254
+ return results;
255
+ }
256
+
257
+ function buildHistoryRootPrompt(
258
+ messages: Message[],
259
+ store: CursorBlobStore,
260
+ activeUserIndex: number,
261
+ ): Uint8Array[] {
262
+ const entries: Uint8Array[] = [];
263
+ const results = pairedToolResults(messages, activeUserIndex);
264
+ for (let index = 0; index < messages.length; index++) {
265
+ if (index === activeUserIndex) break;
266
+ const message = messages[index];
267
+ let value: unknown;
268
+ if (message.role === "user") {
269
+ const content = rootPromptContent(message.content);
270
+ if (content.length === 0) continue;
271
+ value = { role: "user", content };
272
+ } else if (message.role === "assistant") {
273
+ const content = assistantRootContent(message, results);
274
+ if (content.length === 0) continue;
275
+ value = { role: "assistant", content };
276
+ } else {
277
+ if (results.get(message.toolCallId) !== message) continue;
278
+ value = {
279
+ role: "tool",
280
+ id: message.toolCallId,
281
+ content: [
282
+ {
283
+ type: "tool-result",
284
+ toolCallId: message.toolCallId,
285
+ toolName: message.toolName,
286
+ result: message.content.some((part) => part.type === "image")
287
+ ? rootPromptContent(message.content)
288
+ : message.content
289
+ .filter((part) => part.type === "text")
290
+ .map((part) => part.text)
291
+ .join("\n"),
292
+ ...(message.isError ? { isError: true } : {}),
293
+ },
294
+ ],
295
+ };
296
+ }
297
+ entries.push(
298
+ storeBlob(store, new TextEncoder().encode(JSON.stringify(value))),
299
+ );
300
+ }
301
+ return entries;
302
+ }
303
+
304
+ function buildSystemPrompt(
305
+ systemPrompt: Context["systemPrompt"],
306
+ store: CursorBlobStore,
307
+ hasTools = false,
308
+ ): Uint8Array[] {
309
+ const prompts = systemPrompt
310
+ ? Array.isArray(systemPrompt)
311
+ ? systemPrompt
312
+ : [systemPrompt]
313
+ : ["You are a helpful assistant."];
314
+ return [
315
+ ...prompts,
316
+ hasTools ? CURSOR_PI_TOOLS_SYSTEM_PROMPT : CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
317
+ ].map((prompt) =>
318
+ storeBlob(
319
+ store,
320
+ new TextEncoder().encode(
321
+ JSON.stringify({ role: "system", content: prompt }),
322
+ ),
323
+ ),
324
+ );
325
+ }
326
+
327
+ /**
328
+ * Cursor asks for these rules over the exec channel before generating text.
329
+ * These rules keep Cursor-native tools disabled. The request-context response
330
+ * advertises only the active Pi tools through the MCP protocol bridge.
331
+ */
332
+ export function buildCursorRequestContextRules(
333
+ systemPrompt: Context["systemPrompt"],
334
+ hasTools = false,
335
+ ): CursorRule[] {
336
+ const rules: CursorRule[] = systemPrompt?.trim()
337
+ ? [
338
+ create(CursorRuleSchema, {
339
+ fullPath: "/pi/system-prompt.mdc",
340
+ content: systemPrompt,
341
+ source: 2,
342
+ type: create(CursorRuleTypeSchema, {
343
+ type: {
344
+ case: "global",
345
+ value: create(CursorRuleTypeGlobalSchema, {}),
346
+ },
347
+ }),
348
+ }),
349
+ ]
350
+ : [];
351
+ rules.push(
352
+ create(CursorRuleSchema, {
353
+ fullPath: hasTools ? "/pi/cursor-tools.mdc" : "/pi/cursor-chat-only.mdc",
354
+ content: hasTools
355
+ ? CURSOR_PI_TOOLS_SYSTEM_PROMPT
356
+ : CURSOR_CHAT_ONLY_SYSTEM_PROMPT,
357
+ source: 2,
358
+ type: create(CursorRuleTypeSchema, {
359
+ type: { case: "global", value: create(CursorRuleTypeGlobalSchema, {}) },
360
+ }),
361
+ }),
362
+ );
363
+ return rules;
364
+ }
365
+
366
+ function buildHistoryTurns(
367
+ messages: Message[],
368
+ store: CursorBlobStore,
369
+ activeUserIndex: number,
370
+ ): Uint8Array[] {
371
+ const turns: Uint8Array[] = [];
372
+ const results = pairedToolResults(messages, activeUserIndex);
373
+ const end = activeUserIndex >= 0 ? activeUserIndex : messages.length;
374
+ let index = 0;
375
+ while (index < end) {
376
+ const user = messages[index];
377
+ if (user.role !== "user") {
378
+ index++;
379
+ continue;
380
+ }
381
+ const userMessage = storeBlob(
382
+ store,
383
+ toBinary(UserMessageSchema, userMessageFromContent(user.content)),
384
+ );
385
+ const steps: Uint8Array[] = [];
386
+ index++;
387
+ while (index < end && messages[index]?.role !== "user") {
388
+ const message = messages[index];
389
+ if (message.role === "assistant") {
390
+ for (const item of message.content) {
391
+ if (item.type === "text" && item.text) {
392
+ steps.push(
393
+ storeBlob(
394
+ store,
395
+ toBinary(
396
+ ConversationStepSchema,
397
+ create(ConversationStepSchema, {
398
+ message: {
399
+ case: "assistantMessage",
400
+ value: create(AssistantMessageSchema, {
401
+ text: item.text,
402
+ }),
403
+ },
404
+ }),
405
+ ),
406
+ ),
407
+ );
408
+ } else if (item.type === "toolCall") {
409
+ const result = results.get(item.id);
410
+ if (!result || result.toolName !== item.name) continue;
411
+ const args = Object.fromEntries(
412
+ Object.entries(item.arguments).map(([key, value]) => [
413
+ key,
414
+ encodeJsonValue(JSON.parse(JSON.stringify(value))),
415
+ ]),
416
+ );
417
+ const tool = create(CursorToolCallSchema, {
418
+ toolCallId: item.id,
419
+ tool: {
420
+ case: "mcpToolCall",
421
+ value: create(McpToolCallSchema, {
422
+ args: create(McpArgsSchema, {
423
+ name: item.name,
424
+ toolName: item.name,
425
+ providerIdentifier: CURSOR_PI_PROVIDER,
426
+ toolCallId: item.id,
427
+ args,
428
+ }),
429
+ result: create(McpToolResultSchema, {
430
+ result: {
431
+ case: "success",
432
+ value: create(McpSuccessSchema, {
433
+ isError: result.isError,
434
+ content: result.content.map((part) =>
435
+ create(McpToolResultContentItemSchema, {
436
+ content:
437
+ part.type === "text"
438
+ ? {
439
+ case: "text",
440
+ value: create(McpTextContentSchema, {
441
+ text: part.text,
442
+ }),
443
+ }
444
+ : {
445
+ case: "image",
446
+ value: create(McpImageContentSchema, {
447
+ data: Buffer.from(part.data, "base64"),
448
+ mimeType: part.mimeType,
449
+ }),
450
+ },
451
+ }),
452
+ ),
453
+ }),
454
+ },
455
+ }),
456
+ }),
457
+ },
458
+ });
459
+ steps.push(
460
+ storeBlob(
461
+ store,
462
+ toBinary(
463
+ ConversationStepSchema,
464
+ create(ConversationStepSchema, {
465
+ message: { case: "toolCall", value: tool },
466
+ }),
467
+ ),
468
+ ),
469
+ );
470
+ }
471
+ }
472
+ }
473
+ index++;
474
+ }
475
+ const turn = create(ConversationTurnStructureSchema, {
476
+ turn: {
477
+ case: "agentConversationTurn",
478
+ value: create(AgentConversationTurnStructureSchema, {
479
+ userMessage,
480
+ steps,
481
+ }),
482
+ },
483
+ });
484
+ turns.push(
485
+ storeBlob(store, toBinary(ConversationTurnStructureSchema, turn)),
486
+ );
487
+ }
488
+ return turns;
489
+ }
490
+
491
+ function lastUserIndex(messages: Message[]): number {
492
+ for (let index = messages.length - 1; index >= 0; index--) {
493
+ const role = messages[index]?.role;
494
+ if (role === "user") return index;
495
+ }
496
+ return -1;
497
+ }
498
+
499
+ type CursorModelWithOptions = Model<Api> & { cursorMaxMode?: boolean };
500
+
501
+ function hasCursorMaxMode(model: Model<Api>): model is CursorModelWithOptions {
502
+ return Object.hasOwn(model, "cursorMaxMode");
503
+ }
504
+
505
+ function cursorMaxMode(model: Model<Api>): boolean {
506
+ return hasCursorMaxMode(model) && model.cursorMaxMode === true;
507
+ }
508
+
509
+ function resolveWireModel(model: Model<Api>): {
510
+ modelId: string;
511
+ parameters: RequestedModel_ModelParameterbytes[];
512
+ } {
513
+ const id = model.id;
514
+ // Cursor resolves the bare Composer 2.5 id to its Fast lane unless the
515
+ // Standard tier is requested explicitly.
516
+ if (id === "composer-2.5") {
517
+ return {
518
+ modelId: id,
519
+ parameters: [
520
+ create(RequestedModel_ModelParameterbytesSchema, {
521
+ id: "fast",
522
+ value: "false",
523
+ }),
524
+ ],
525
+ };
526
+ }
527
+ const match = /^(.*)-(minimal|low|medium|high|xhigh|max)(-fast)?$/.exec(id);
528
+ if (!match?.[1] || !/(?:gpt|codex|o\d)/i.test(match[1])) {
529
+ return { modelId: id, parameters: [] };
530
+ }
531
+ return {
532
+ modelId: `${match[1]}${match[3] ?? ""}`,
533
+ parameters: [
534
+ create(RequestedModel_ModelParameterbytesSchema, {
535
+ id: "reasoning",
536
+ value: match[2]!,
537
+ }),
538
+ ],
539
+ };
540
+ }
541
+
542
+ /** Build the protobuf Run request and retain blobs for the same Connect stream. */
543
+ export async function buildCursorRequest(
544
+ model: Model<Api>,
545
+ context: Context,
546
+ options?: SimpleStreamOptions,
547
+ ): Promise<CursorRequestBuild> {
548
+ const store: CursorBlobStore = new Map();
549
+ const activeIndex =
550
+ context.messages.at(-1)?.role === "user"
551
+ ? lastUserIndex(context.messages)
552
+ : -1;
553
+ const active = activeIndex >= 0 ? context.messages[activeIndex] : undefined;
554
+ const activeContent = active?.role === "user" ? active.content : undefined;
555
+ const rootPromptMessagesJson = [
556
+ ...buildSystemPrompt(context.systemPrompt, store, !!context.tools?.length),
557
+ ...buildHistoryRootPrompt(context.messages, store, activeIndex),
558
+ ];
559
+ const state = create(ConversationStateStructureSchema, {
560
+ rootPromptMessagesJson,
561
+ turns: buildHistoryTurns(context.messages, store, activeIndex),
562
+ pendingToolCalls: [],
563
+ });
564
+ const conversationId = options?.sessionId ?? randomUUID();
565
+ const action = create(ConversationActionSchema, {
566
+ action:
567
+ activeContent !== undefined &&
568
+ (textFromContent(activeContent).length > 0 ||
569
+ imagesFromContent(activeContent).length > 0)
570
+ ? {
571
+ case: "userMessageAction",
572
+ value: create(UserMessageActionSchema, {
573
+ userMessage: userMessageFromContent(activeContent),
574
+ }),
575
+ }
576
+ : { case: "resumeAction", value: create(ResumeActionSchema, {}) },
577
+ });
578
+ const wire = resolveWireModel(model);
579
+ let request = create(AgentRunRequestSchema, {
580
+ conversationState: state,
581
+ action,
582
+ modelDetails: create(ModelDetailsSchema, {
583
+ modelId: wire.modelId,
584
+ displayModelId: model.id,
585
+ displayName: model.name,
586
+ displayNameShort: model.name,
587
+ aliases: [],
588
+ ...(cursorMaxMode(model) ? { maxMode: true } : {}),
589
+ }),
590
+ requestedModel: create(RequestedModelSchema, {
591
+ modelId: wire.modelId,
592
+ maxMode: cursorMaxMode(model),
593
+ parameters: wire.parameters,
594
+ }),
595
+ conversationId,
596
+ });
597
+ const replacement = await options?.onPayload?.(request, model);
598
+ if (replacement !== undefined) request = replacement as AgentRunRequest;
599
+ const clientMessage = create(AgentClientMessageSchema, {
600
+ message: { case: "runRequest", value: request },
601
+ });
602
+ return {
603
+ request,
604
+ requestBytes: toBinary(AgentClientMessageSchema, clientMessage),
605
+ blobStore: store,
606
+ conversationState: state,
607
+ };
608
+ }
609
+
610
+ function sanitizeCallerHeaders(
611
+ headers: SimpleStreamOptions["headers"],
612
+ ): Record<string, string> {
613
+ const result: Record<string, string> = {};
614
+ for (const [name, value] of Object.entries(headers ?? {})) {
615
+ if (value === null) continue;
616
+ const field = name.toLowerCase();
617
+ if (field.startsWith(":")) continue;
618
+ if (
619
+ HTTP2_FORBIDDEN_HEADERS.has(field) ||
620
+ CURSOR_RESERVED_HEADERS.has(field)
621
+ )
622
+ continue;
623
+ result[field] = value;
624
+ }
625
+ return result;
626
+ }
627
+
628
+ function cursorHeaders(
629
+ apiKey: string,
630
+ options: SimpleStreamOptions | undefined,
631
+ ): Record<string, string> {
632
+ return {
633
+ ...sanitizeCallerHeaders(options?.headers),
634
+ ":method": "POST",
635
+ ":path": CURSOR_RUN_PATH,
636
+ "content-type": "application/connect+proto",
637
+ "connect-protocol-version": "1",
638
+ te: "trailers",
639
+ authorization: `Bearer ${apiKey}`,
640
+ "x-ghost-mode": "true",
641
+ "x-cursor-client-version": CURSOR_CLIENT_VERSION,
642
+ "x-cursor-client-type": "cli",
643
+ "x-request-id": randomUUID(),
644
+ };
645
+ }
646
+
647
+ function headerRecord(
648
+ headers: http2.IncomingHttpHeaders,
649
+ ): Record<string, string> {
650
+ const result: Record<string, string> = {};
651
+ for (const [key, value] of Object.entries(headers)) {
652
+ if (typeof value === "string") result[key] = value;
653
+ else if (Array.isArray(value)) result[key] = value.join(", ");
654
+ }
655
+ return result;
656
+ }
657
+
658
+ function errorFromEndStream(data: Uint8Array): Error | undefined {
659
+ try {
660
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(data));
661
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
662
+ const error = parsed.error;
663
+ if (error && typeof error === "object") {
664
+ const message =
665
+ "message" in error && typeof error.message === "string"
666
+ ? error.message
667
+ : "Cursor Connect error";
668
+ const code =
669
+ "code" in error && typeof error.code === "string"
670
+ ? error.code
671
+ : "unknown";
672
+ return new Error(`Connect error ${code}: ${message}`);
673
+ }
674
+ }
675
+ return undefined;
676
+ } catch {
677
+ return new Error("Failed to parse Cursor Connect end-stream envelope");
678
+ }
679
+ }
680
+
681
+ function isAbortError(
682
+ error: unknown,
683
+ signal: AbortSignal | undefined,
684
+ ): boolean {
685
+ return (
686
+ Boolean(signal?.aborted) ||
687
+ (error instanceof Error &&
688
+ /aborted|cancelled|canceled/i.test(error.message))
689
+ );
690
+ }
691
+
692
+ /** Cursor AgentService/Run with Pi-owned tool execution across provider turns. */
693
+ export function streamCursor(
694
+ model: Model<Api>,
695
+ context: Context,
696
+ options?: SimpleStreamOptions,
697
+ ): AssistantMessageEventStream {
698
+ const stream = createAssistantMessageEventStream();
699
+ (async () => {
700
+ const output: AssistantMessage = {
701
+ role: "assistant",
702
+ content: [],
703
+ api: model.api,
704
+ provider: model.provider,
705
+ model: model.id,
706
+ usage: emptyUsage(),
707
+ stopReason: "pending",
708
+ timestamp: Date.now(),
709
+ };
710
+ let h2Client: http2.ClientHttp2Session | undefined;
711
+ let h2Request: http2.ClientHttp2Stream | undefined;
712
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
713
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
714
+ let removeAbortListener: (() => void) | undefined;
715
+ let currentText:
716
+ | Extract<AssistantMessage["content"][number], { type: "text" }>
717
+ | undefined;
718
+ let currentThinking:
719
+ | Extract<AssistantMessage["content"][number], { type: "thinking" }>
720
+ | undefined;
721
+ let turnEnded = false;
722
+ let terminalError: Error | undefined;
723
+ let finished = false;
724
+ const pendingCalls = new Map<string, ToolCall>();
725
+ let handingOffTools = false;
726
+
727
+ const closeBlocks = () => {
728
+ if (currentText) {
729
+ const index = output.content.indexOf(currentText);
730
+ stream.push({
731
+ type: "text_end",
732
+ contentIndex: index,
733
+ content: currentText.text,
734
+ partial: output,
735
+ });
736
+ currentText = undefined;
737
+ }
738
+ if (currentThinking) {
739
+ const index = output.content.indexOf(currentThinking);
740
+ stream.push({
741
+ type: "thinking_end",
742
+ contentIndex: index,
743
+ content: currentThinking.thinking,
744
+ partial: output,
745
+ });
746
+ currentThinking = undefined;
747
+ }
748
+ };
749
+
750
+ const finishError = (error: unknown) => {
751
+ if (finished) return;
752
+ finished = true;
753
+ closeBlocks();
754
+ output.stopReason = isAbortError(error, options?.signal)
755
+ ? "aborted"
756
+ : "error";
757
+ output.errorMessage =
758
+ error instanceof Error ? error.message : String(error);
759
+ stream.push({
760
+ type: "error",
761
+ reason: output.stopReason,
762
+ error: output,
763
+ });
764
+ stream.end();
765
+ };
766
+
767
+ try {
768
+ const apiKey = options?.apiKey?.trim();
769
+ if (!apiKey)
770
+ throw new Error("Cursor API key is required — run /login cursor");
771
+ if (options?.fetch) {
772
+ throw new Error(
773
+ "Cursor uses an HTTP/2 transport and does not support options.fetch",
774
+ );
775
+ }
776
+ if (options?.signal?.aborted) throw new Error("Cursor request aborted");
777
+ const timeoutMs = options?.timeoutMs;
778
+ if (
779
+ timeoutMs !== undefined &&
780
+ (!Number.isFinite(timeoutMs) || timeoutMs < 0)
781
+ ) {
782
+ throw new Error(`Invalid timeoutMs: ${String(timeoutMs)}`);
783
+ }
784
+ const requestTimeoutMs =
785
+ timeoutMs === undefined || timeoutMs === 0
786
+ ? undefined
787
+ : Math.max(1, Math.floor(timeoutMs));
788
+ const built = await buildCursorRequest(model, context, options);
789
+ const baseUrl = model.baseUrl || CURSOR_API_URL;
790
+ const completion = Promise.withResolvers<void>();
791
+ let completionSettled = false;
792
+ const settle = (error?: unknown) => {
793
+ if (completionSettled) return;
794
+ completionSettled = true;
795
+ if (error !== undefined) completion.reject(error);
796
+ else if (terminalError) completion.reject(terminalError);
797
+ else if (!turnEnded)
798
+ completion.reject(new Error("Cursor stream ended before turnEnded"));
799
+ else completion.resolve();
800
+ };
801
+ // Abort can reject completion while we are still awaiting response
802
+ // headers; keep the rejection observed so Node does not report it as
803
+ // unhandled when the catch path never reaches `await completion.promise`.
804
+ void completion.promise.catch(() => {});
805
+ const responseReady = Promise.withResolvers<void>();
806
+ let responseSeen = false;
807
+ let responseStatus = 0;
808
+ let responseHeaders: Record<string, string> = {};
809
+ let responseReadySettled = false;
810
+ const rejectResponseReady = (error: unknown) => {
811
+ if (responseReadySettled) return;
812
+ responseReadySettled = true;
813
+ responseReady.reject(error);
814
+ };
815
+ const resolveResponseReady = () => {
816
+ if (responseReadySettled) return;
817
+ responseReadySettled = true;
818
+ responseReady.resolve();
819
+ };
820
+ const clearIdleTimer = () => {
821
+ if (idleTimer) clearTimeout(idleTimer);
822
+ idleTimer = undefined;
823
+ };
824
+ const armIdleTimer = () => {
825
+ clearIdleTimer();
826
+ if (requestTimeoutMs === undefined) return;
827
+ idleTimer = setTimeout(() => {
828
+ const error = new Error(
829
+ `Cursor request idle timeout after ${requestTimeoutMs}ms`,
830
+ );
831
+ rejectResponseReady(error);
832
+ settle(error);
833
+ h2Request?.close(http2.constants.NGHTTP2_CANCEL);
834
+ }, requestTimeoutMs);
835
+ };
836
+ let frameBuffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
837
+ const processFrame = (flags: number, bytes: Uint8Array) => {
838
+ if (handingOffTools) return;
839
+ if ((flags & CONNECT_COMPRESSED_FLAG) !== 0) {
840
+ throw new Error("Compressed Cursor Connect frames are unsupported");
841
+ }
842
+ if ((flags & CONNECT_END_STREAM_FLAG) !== 0) {
843
+ terminalError = errorFromEndStream(bytes);
844
+ if (terminalError) h2Request?.close();
845
+ return;
846
+ }
847
+ const message = fromBinary(AgentServerMessageSchema, bytes);
848
+ if (message.message.case === "execServerMessage") {
849
+ const exec = message.message.value;
850
+ if (exec.message.case === "requestContextArgs") {
851
+ const result = create(RequestContextResultSchema, {
852
+ result: {
853
+ case: "success",
854
+ value: create(RequestContextSuccessSchema, {
855
+ requestContext: create(RequestContextSchema, {
856
+ rules: buildCursorRequestContextRules(
857
+ context.systemPrompt,
858
+ !!context.tools?.length,
859
+ ),
860
+ tools: buildCursorTools(context.tools),
861
+ }),
862
+ }),
863
+ },
864
+ });
865
+ const response = create(ExecClientMessageSchema, {
866
+ id: exec.id,
867
+ execId: exec.execId,
868
+ message: { case: "requestContextResult", value: result },
869
+ });
870
+ const envelope = create(AgentClientMessageSchema, {
871
+ message: { case: "execClientMessage", value: response },
872
+ });
873
+ h2Request?.write(
874
+ frameConnectMessage(toBinary(AgentClientMessageSchema, envelope)),
875
+ );
876
+ return;
877
+ }
878
+ if (exec.message.case === "mcpArgs") {
879
+ const args = exec.message.value;
880
+ if (args.smartModeApprovalOnly) {
881
+ // A probe must not become a tool call or preauthorize Pi execution.
882
+ const reply = create(AgentClientMessageSchema, {
883
+ message: {
884
+ case: "execClientMessage",
885
+ value: create(ExecClientMessageSchema, {
886
+ id: exec.id,
887
+ execId: exec.execId,
888
+ message: {
889
+ case: "mcpResult",
890
+ value: create(McpResultSchema, {
891
+ result: {
892
+ case: "rejected",
893
+ value: create(McpRejectedSchema, {
894
+ reason:
895
+ "Pi must evaluate permissions when executing the tool; approval-only probes cannot authorize execution.",
896
+ }),
897
+ },
898
+ }),
899
+ },
900
+ }),
901
+ },
902
+ });
903
+ h2Request?.write(
904
+ frameConnectMessage(toBinary(AgentClientMessageSchema, reply)),
905
+ );
906
+ return;
907
+ }
908
+ const call = decodeCursorTool(args, context.tools);
909
+ if (
910
+ context.messages.some((message) =>
911
+ message.role === "toolResult"
912
+ ? message.toolCallId === call.id
913
+ : message.role === "assistant" &&
914
+ message.content.some(
915
+ (part) => part.type === "toolCall" && part.id === call.id,
916
+ ),
917
+ )
918
+ ) {
919
+ throw new Error(
920
+ "Cursor attempted to replay a tool call identity already present in Pi history",
921
+ );
922
+ }
923
+ const previous = pendingCalls.get(call.id);
924
+ if (previous && JSON.stringify(previous) !== JSON.stringify(call)) {
925
+ throw new Error(
926
+ "Cursor repeated a tool call identity with different arguments",
927
+ );
928
+ }
929
+ pendingCalls.set(call.id, call);
930
+ return;
931
+ }
932
+ const throwReply = create(AgentClientMessageSchema, {
933
+ message: {
934
+ case: "execClientControlMessage",
935
+ value: create(ExecClientControlMessageSchema, {
936
+ message: {
937
+ case: "throw",
938
+ value: create(ExecClientThrowSchema, {
939
+ id: exec.id,
940
+ error: context.tools?.length
941
+ ? "Cursor-native execution is unavailable; use advertised Pi MCP tools"
942
+ : "Cursor tools are not available in this chat-only provider",
943
+ errorCode: "UNIMPLEMENTED",
944
+ }),
945
+ },
946
+ }),
947
+ },
948
+ });
949
+ const closeReply = create(AgentClientMessageSchema, {
950
+ message: {
951
+ case: "execClientControlMessage",
952
+ value: create(ExecClientControlMessageSchema, {
953
+ message: {
954
+ case: "streamClose",
955
+ value: create(ExecClientStreamCloseSchema, { id: exec.id }),
956
+ },
957
+ }),
958
+ },
959
+ });
960
+ const error = new Error(
961
+ context.tools?.length
962
+ ? "Cursor requested unsupported native execution outside Pi"
963
+ : "Cursor requested a tool that is unavailable in chat-only mode",
964
+ );
965
+ terminalError = error;
966
+ if (!h2Request) {
967
+ settle(error);
968
+ return;
969
+ }
970
+ h2Request.write(
971
+ frameConnectMessage(toBinary(AgentClientMessageSchema, throwReply)),
972
+ );
973
+ h2Request.write(
974
+ frameConnectMessage(toBinary(AgentClientMessageSchema, closeReply)),
975
+ () => settle(error),
976
+ );
977
+ return;
978
+ }
979
+ if (message.message.case === "kvServerMessage") {
980
+ sendKvReply(message.message.value, built.blobStore, h2Request);
981
+ return;
982
+ }
983
+ if (message.message.case === "interactionQuery") {
984
+ throw new Error(
985
+ `Cursor interaction query ${message.message.value.query.case ?? "unknown"} is unavailable ${context.tools?.length ? "outside Pi's interaction lifecycle" : "in chat-only mode"}`,
986
+ );
987
+ }
988
+ if (message.message.case !== "interactionUpdate") return;
989
+ const update = message.message.value;
990
+ if (
991
+ context.tools?.length &&
992
+ (update.message.case === "partialToolCall" ||
993
+ update.message.case === "toolCallStarted" ||
994
+ update.message.case === "toolCallCompleted")
995
+ ) {
996
+ const preview = update.message.value.toolCall;
997
+ if (preview && preview.tool.case !== "mcpToolCall") {
998
+ throw new Error(
999
+ "Cursor-native tools are unavailable; use the advertised Pi MCP tools",
1000
+ );
1001
+ }
1002
+ // Only exec mcpArgs is an invocation. UI previews may be partial,
1003
+ // duplicated, or emitted for approval probes, and never execute.
1004
+ return;
1005
+ }
1006
+ if (context.tools?.length && update.message.case === "toolCallDelta") {
1007
+ throw new Error(
1008
+ "Cursor-native tool deltas are unavailable; use the advertised Pi MCP tools",
1009
+ );
1010
+ }
1011
+ processInteraction(
1012
+ message.message.value,
1013
+ output,
1014
+ stream,
1015
+ () => {
1016
+ turnEnded = true;
1017
+ },
1018
+ {
1019
+ setText(value) {
1020
+ currentText = value;
1021
+ },
1022
+ getText() {
1023
+ return currentText;
1024
+ },
1025
+ setThinking(value) {
1026
+ currentThinking = value;
1027
+ },
1028
+ getThinking() {
1029
+ return currentThinking;
1030
+ },
1031
+ closeBlocks,
1032
+ },
1033
+ );
1034
+ };
1035
+ const processData = (chunk: Buffer) => {
1036
+ frameBuffer =
1037
+ frameBuffer.length === 0
1038
+ ? chunk
1039
+ : Buffer.concat([frameBuffer, chunk]);
1040
+ while (frameBuffer.length >= 5) {
1041
+ const size = frameBuffer.readUInt32BE(1);
1042
+ if (size > MAX_CONNECT_FRAME_BYTES) {
1043
+ throw new Error(
1044
+ `Cursor Connect frame exceeds ${MAX_CONNECT_FRAME_BYTES} bytes`,
1045
+ );
1046
+ }
1047
+ if (frameBuffer.length < size + 5) return;
1048
+ const flags = frameBuffer[0]!;
1049
+ const data = frameBuffer.subarray(5, size + 5);
1050
+ frameBuffer = frameBuffer.subarray(size + 5);
1051
+ processFrame(flags, data);
1052
+ }
1053
+ if (pendingCalls.size > 0 && !handingOffTools) {
1054
+ if (terminalError) throw terminalError;
1055
+ if (options?.signal?.aborted)
1056
+ throw new Error("Cursor request aborted");
1057
+ closeBlocks();
1058
+ for (const call of pendingCalls.values()) {
1059
+ const contentIndex = output.content.length;
1060
+ output.content.push(call);
1061
+ stream.push({
1062
+ type: "toolcall_start",
1063
+ contentIndex,
1064
+ partial: output,
1065
+ });
1066
+ stream.push({
1067
+ type: "toolcall_delta",
1068
+ contentIndex,
1069
+ delta: JSON.stringify(call.arguments),
1070
+ partial: output,
1071
+ });
1072
+ stream.push({
1073
+ type: "toolcall_end",
1074
+ contentIndex,
1075
+ toolCall: call,
1076
+ partial: output,
1077
+ });
1078
+ }
1079
+ output.stopReason = "toolUse";
1080
+ handingOffTools = true;
1081
+ turnEnded = true;
1082
+ settle();
1083
+ }
1084
+ };
1085
+
1086
+ h2Client = await connectCursorHttp2(baseUrl, {
1087
+ signal: options?.signal,
1088
+ timeoutMs: Math.min(
1089
+ requestTimeoutMs ?? PROXY_TUNNEL_TIMEOUT_MS,
1090
+ PROXY_TUNNEL_TIMEOUT_MS,
1091
+ ),
1092
+ });
1093
+ h2Client.once("error", (error) => {
1094
+ rejectResponseReady(error);
1095
+ settle(error);
1096
+ });
1097
+ h2Request = h2Client.request(cursorHeaders(apiKey, options));
1098
+ h2Request.once("response", (headers) => {
1099
+ armIdleTimer();
1100
+ responseSeen = true;
1101
+ responseStatus = Number(headers[":status"] ?? 0);
1102
+ responseHeaders = headerRecord(headers);
1103
+ resolveResponseReady();
1104
+ });
1105
+ h2Request.on("trailers", (trailers) => {
1106
+ const status = String(trailers["grpc-status"] ?? "0");
1107
+ if (status !== "0") {
1108
+ const encodedMessage = String(trailers["grpc-message"] ?? "");
1109
+ try {
1110
+ terminalError = new Error(
1111
+ `Cursor gRPC error ${status}: ${decodeURIComponent(encodedMessage)}`,
1112
+ );
1113
+ } catch (cause) {
1114
+ const error = new Error(
1115
+ `Cursor gRPC error ${status} contains a malformed grpc-message trailer`,
1116
+ { cause },
1117
+ );
1118
+ terminalError = error;
1119
+ settle(error);
1120
+ }
1121
+ }
1122
+ });
1123
+ const responseCallback = responseReady.promise.then(async () => {
1124
+ await options?.onResponse?.(
1125
+ { status: responseStatus, headers: responseHeaders },
1126
+ model,
1127
+ );
1128
+ if (responseStatus < 200 || responseStatus >= 300) {
1129
+ throw new Error(
1130
+ `Cursor AgentService request failed with HTTP ${responseStatus}`,
1131
+ );
1132
+ }
1133
+ stream.push({ type: "start", partial: output });
1134
+ });
1135
+ // Keep rejected transport/callback promises observed even when the peer
1136
+ // closes immediately after a malformed or unsupported interaction.
1137
+ void responseReady.promise.catch(() => {});
1138
+ void responseCallback.catch(() => {});
1139
+ let dataChain = Promise.resolve();
1140
+ h2Request.on("data", (chunk: Buffer) => {
1141
+ armIdleTimer();
1142
+ dataChain = dataChain
1143
+ .then(() => responseCallback)
1144
+ .then(() => processData(chunk))
1145
+ .catch((error) => {
1146
+ settle(error);
1147
+ });
1148
+ });
1149
+ h2Request.once("end", () => {
1150
+ clearIdleTimer();
1151
+ if (!responseSeen) {
1152
+ rejectResponseReady(
1153
+ new Error("Cursor response headers were not received"),
1154
+ );
1155
+ }
1156
+ void dataChain
1157
+ .then(() => responseCallback)
1158
+ .then(() => {
1159
+ if (!responseSeen)
1160
+ throw new Error("Cursor response headers were not received");
1161
+ if (frameBuffer.length !== 0)
1162
+ throw new Error("Incomplete Cursor Connect frame");
1163
+ settle();
1164
+ })
1165
+ .catch((error) => settle(error));
1166
+ });
1167
+ h2Request.once("error", (error) => {
1168
+ rejectResponseReady(error);
1169
+ settle(error);
1170
+ });
1171
+ h2Request.once("aborted", () => {
1172
+ const error = new Error("Cursor response aborted");
1173
+ rejectResponseReady(error);
1174
+ settle(error);
1175
+ });
1176
+ const sendHeartbeat = () => {
1177
+ if (!h2Request || h2Request.closed || h2Request.destroyed) return;
1178
+ const message = create(AgentClientMessageSchema, {
1179
+ message: {
1180
+ case: "clientHeartbeat",
1181
+ value: create(ClientHeartbeatSchema, {}),
1182
+ },
1183
+ });
1184
+ try {
1185
+ h2Request.write(
1186
+ frameConnectMessage(toBinary(AgentClientMessageSchema, message)),
1187
+ );
1188
+ } catch {
1189
+ // The terminal request/error handler owns stream completion.
1190
+ }
1191
+ };
1192
+ heartbeat = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
1193
+ if (options?.signal) {
1194
+ const onAbort = () => {
1195
+ const error = new Error("Cursor request aborted");
1196
+ rejectResponseReady(error);
1197
+ h2Request?.close(http2.constants.NGHTTP2_CANCEL);
1198
+ settle(error);
1199
+ };
1200
+ if (options.signal.aborted) onAbort();
1201
+ else {
1202
+ options.signal.addEventListener("abort", onAbort, { once: true });
1203
+ removeAbortListener = () =>
1204
+ options.signal?.removeEventListener("abort", onAbort);
1205
+ }
1206
+ }
1207
+ armIdleTimer();
1208
+ h2Request.write(frameConnectMessage(built.requestBytes));
1209
+ await responseCallback;
1210
+ await completion.promise;
1211
+ if (heartbeat) clearInterval(heartbeat);
1212
+ clearIdleTimer();
1213
+ removeAbortListener?.();
1214
+ h2Request.close();
1215
+ h2Client.close();
1216
+ closeBlocks();
1217
+ if (output.stopReason === "pending") output.stopReason = "stop";
1218
+ output.usage.totalTokens = output.usage.input + output.usage.output;
1219
+ stream.push({
1220
+ type: "done",
1221
+ reason:
1222
+ output.stopReason === "toolUse"
1223
+ ? "toolUse"
1224
+ : output.stopReason === "length"
1225
+ ? "length"
1226
+ : "stop",
1227
+ message: output,
1228
+ });
1229
+ stream.end();
1230
+ } catch (error) {
1231
+ if (heartbeat) clearInterval(heartbeat);
1232
+ if (idleTimer) clearTimeout(idleTimer);
1233
+ removeAbortListener?.();
1234
+ h2Request?.close();
1235
+ h2Client?.close();
1236
+ finishError(error);
1237
+ }
1238
+ })().catch((error) => {
1239
+ // The body above handles all expected failures; this guard also protects
1240
+ // the event stream from an unexpected asynchronous callback rejection.
1241
+ stream.push({
1242
+ type: "error",
1243
+ reason: "error",
1244
+ error: {
1245
+ role: "assistant",
1246
+ content: [],
1247
+ api: model.api,
1248
+ provider: model.provider,
1249
+ model: model.id,
1250
+ usage: emptyUsage(),
1251
+ stopReason: "error",
1252
+ errorMessage: error instanceof Error ? error.message : String(error),
1253
+ timestamp: Date.now(),
1254
+ },
1255
+ });
1256
+ stream.end();
1257
+ });
1258
+ return stream;
1259
+ }
1260
+
1261
+ interface InteractionState {
1262
+ setText(
1263
+ value:
1264
+ | Extract<AssistantMessage["content"][number], { type: "text" }>
1265
+ | undefined,
1266
+ ): void;
1267
+ getText():
1268
+ | Extract<AssistantMessage["content"][number], { type: "text" }>
1269
+ | undefined;
1270
+ setThinking(
1271
+ value:
1272
+ | Extract<AssistantMessage["content"][number], { type: "thinking" }>
1273
+ | undefined,
1274
+ ): void;
1275
+ getThinking():
1276
+ | Extract<AssistantMessage["content"][number], { type: "thinking" }>
1277
+ | undefined;
1278
+ closeBlocks(): void;
1279
+ }
1280
+
1281
+ function processInteraction(
1282
+ update: InteractionUpdate,
1283
+ output: AssistantMessage,
1284
+ stream: AssistantMessageEventStream,
1285
+ onTurnEnded: () => void,
1286
+ state: InteractionState,
1287
+ ): void {
1288
+ switch (update.message.case) {
1289
+ case "textDelta": {
1290
+ const thinking = state.getThinking();
1291
+ if (thinking) {
1292
+ const index = output.content.indexOf(thinking);
1293
+ stream.push({
1294
+ type: "thinking_end",
1295
+ contentIndex: index,
1296
+ content: thinking.thinking,
1297
+ partial: output,
1298
+ });
1299
+ state.setThinking(undefined);
1300
+ }
1301
+ const delta = update.message.value.text;
1302
+ if (!delta) return;
1303
+ let block = state.getText();
1304
+ if (!block) {
1305
+ block = { type: "text", text: "" };
1306
+ output.content.push(block);
1307
+ state.setText(block);
1308
+ stream.push({
1309
+ type: "text_start",
1310
+ contentIndex: output.content.length - 1,
1311
+ partial: output,
1312
+ });
1313
+ }
1314
+ block.text += delta;
1315
+ stream.push({
1316
+ type: "text_delta",
1317
+ contentIndex: output.content.indexOf(block),
1318
+ delta,
1319
+ partial: output,
1320
+ });
1321
+ break;
1322
+ }
1323
+ case "thinkingDelta": {
1324
+ const delta = update.message.value.text;
1325
+ if (!delta) return;
1326
+ const text = state.getText();
1327
+ if (text) {
1328
+ const index = output.content.indexOf(text);
1329
+ stream.push({
1330
+ type: "text_end",
1331
+ contentIndex: index,
1332
+ content: text.text,
1333
+ partial: output,
1334
+ });
1335
+ state.setText(undefined);
1336
+ }
1337
+ let block = state.getThinking();
1338
+ if (!block) {
1339
+ block = { type: "thinking", thinking: "" };
1340
+ output.content.push(block);
1341
+ state.setThinking(block);
1342
+ stream.push({
1343
+ type: "thinking_start",
1344
+ contentIndex: output.content.length - 1,
1345
+ partial: output,
1346
+ });
1347
+ }
1348
+ block.thinking += delta;
1349
+ stream.push({
1350
+ type: "thinking_delta",
1351
+ contentIndex: output.content.indexOf(block),
1352
+ delta,
1353
+ partial: output,
1354
+ });
1355
+ break;
1356
+ }
1357
+ case "thinkingCompleted": {
1358
+ const block = state.getThinking();
1359
+ if (!block) return;
1360
+ const index = output.content.indexOf(block);
1361
+ stream.push({
1362
+ type: "thinking_end",
1363
+ contentIndex: index,
1364
+ content: block.thinking,
1365
+ partial: output,
1366
+ });
1367
+ state.setThinking(undefined);
1368
+ break;
1369
+ }
1370
+ case "partialToolCall":
1371
+ case "toolCallDelta":
1372
+ case "toolCallStarted":
1373
+ case "toolCallCompleted":
1374
+ throw new Error(
1375
+ `Cursor ${update.message.case} is unavailable in chat-only mode`,
1376
+ );
1377
+ case "tokenDelta": {
1378
+ // Cursor only reports generated tokens here, not the complete context
1379
+ // usage Pi needs for context accounting. Keep the usage block empty;
1380
+ // Pi 0.84.3+ estimates the full history for threshold compaction.
1381
+ break;
1382
+ }
1383
+ case "turnEnded":
1384
+ onTurnEnded();
1385
+ break;
1386
+ case "heartbeat":
1387
+ case undefined:
1388
+ break;
1389
+ }
1390
+ }
1391
+
1392
+ function sendKvReply(
1393
+ message: KvServerMessage,
1394
+ store: CursorBlobStore,
1395
+ request: http2.ClientHttp2Stream | undefined,
1396
+ ): void {
1397
+ if (!request || request.closed || request.destroyed) return;
1398
+ let reply;
1399
+ if (message.message.case === "getBlobArgs") {
1400
+ const key = Buffer.from(message.message.value.blobId).toString("hex");
1401
+ reply = create(KvClientMessageSchema, {
1402
+ id: message.id,
1403
+ message: {
1404
+ case: "getBlobResult",
1405
+ value: create(GetBlobResultSchema, { blobData: store.get(key) }),
1406
+ },
1407
+ });
1408
+ } else if (message.message.case === "setBlobArgs") {
1409
+ const args = message.message.value;
1410
+ store.set(Buffer.from(args.blobId).toString("hex"), args.blobData);
1411
+ reply = create(KvClientMessageSchema, {
1412
+ id: message.id,
1413
+ message: {
1414
+ case: "setBlobResult",
1415
+ value: create(SetBlobResultSchema, {}),
1416
+ },
1417
+ });
1418
+ } else {
1419
+ return;
1420
+ }
1421
+ const envelope = create(AgentClientMessageSchema, {
1422
+ message: { case: "kvClientMessage", value: reply },
1423
+ });
1424
+ try {
1425
+ request.write(
1426
+ frameConnectMessage(toBinary(AgentClientMessageSchema, envelope)),
1427
+ );
1428
+ } catch {
1429
+ // The owning stream listener reports the transport failure.
1430
+ }
1431
+ }