pi-provider-cursor-ask 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.
Files changed (75) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +87 -0
  4. package/README.zh-CN.md +87 -0
  5. package/UPSTREAM_CHANGELOG.md +368 -0
  6. package/UPSTREAM_SOURCE.md +23 -0
  7. package/dist/index.js +54 -0
  8. package/package.json +97 -0
  9. package/src/auth/cli-credentials.ts +275 -0
  10. package/src/auth/consent.ts +25 -0
  11. package/src/auth/index.ts +23 -0
  12. package/src/auth/oauth.ts +282 -0
  13. package/src/auth/refresh-guard.ts +93 -0
  14. package/src/client/bridge.ts +673 -0
  15. package/src/client/cursor-wire.ts +213 -0
  16. package/src/client/h2-unary.ts +142 -0
  17. package/src/client/index.ts +18 -0
  18. package/src/config/index.ts +69 -0
  19. package/src/diagnostics/diagnostics.ts +116 -0
  20. package/src/diagnostics/index.ts +1 -0
  21. package/src/extension/auth.ts +99 -0
  22. package/src/extension/commands.ts +163 -0
  23. package/src/extension/compaction-guard.ts +86 -0
  24. package/src/extension/debug-hooks.ts +359 -0
  25. package/src/extension/index.ts +8 -0
  26. package/src/extension/provider.ts +277 -0
  27. package/src/extension/quota-adapter.ts +175 -0
  28. package/src/extension/report-dashboard.ts +133 -0
  29. package/src/identity.ts +16 -0
  30. package/src/index.ts +186 -0
  31. package/src/models/ask-catalog.ts +384 -0
  32. package/src/models/catalog.json +1163 -0
  33. package/src/models/cost.ts +126 -0
  34. package/src/models/index.ts +6 -0
  35. package/src/models/limits.ts +36 -0
  36. package/src/models/parameterized.ts +416 -0
  37. package/src/models/processing.ts +313 -0
  38. package/src/proto/agent_pb.ts +14577 -0
  39. package/src/stream/bridge-session.ts +215 -0
  40. package/src/stream/client-transcript.ts +51 -0
  41. package/src/stream/config.ts +5 -0
  42. package/src/stream/context-normalize.ts +308 -0
  43. package/src/stream/context-usage.ts +168 -0
  44. package/src/stream/debug-log.ts +316 -0
  45. package/src/stream/drift.ts +122 -0
  46. package/src/stream/images.ts +201 -0
  47. package/src/stream/index.ts +68 -0
  48. package/src/stream/interaction-query.ts +369 -0
  49. package/src/stream/message-parsing.ts +402 -0
  50. package/src/stream/model-cache.ts +100 -0
  51. package/src/stream/model-discovery.ts +242 -0
  52. package/src/stream/model-routing.ts +100 -0
  53. package/src/stream/native-core.ts +2121 -0
  54. package/src/stream/pi-adapter.ts +414 -0
  55. package/src/stream/protocol.ts +63 -0
  56. package/src/stream/recovery.ts +494 -0
  57. package/src/stream/request-build.ts +668 -0
  58. package/src/stream/root-prompt.ts +184 -0
  59. package/src/stream/run-journal.ts +474 -0
  60. package/src/stream/run-usage.ts +107 -0
  61. package/src/stream/server-messages.ts +777 -0
  62. package/src/stream/session-state.ts +499 -0
  63. package/src/stream/stream-writer.ts +211 -0
  64. package/src/stream/thinking-filter.ts +63 -0
  65. package/src/stream/tool-schema.ts +185 -0
  66. package/src/stream/transport-errors.ts +150 -0
  67. package/src/stream/tuning.ts +250 -0
  68. package/src/stream/types.ts +330 -0
  69. package/src/types/enums.ts +103 -0
  70. package/src/types/index.ts +4 -0
  71. package/src/usage.ts +262 -0
  72. package/src/utils/cache-dir.ts +39 -0
  73. package/src/utils/index.ts +2 -0
  74. package/src/utils/security.ts +68 -0
  75. package/src/utils/util.ts +43 -0
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Image decoding and validation for the Cursor wire format.
3
+ *
4
+ * Cursor CLI's local-image path scales/compresses images to <= 5 MiB and
5
+ * accepts only jpeg/png/gif/webp by magic bytes, so anything we forward has to
6
+ * clear the same bar. Pure helpers with no logging, so any module may import it.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+
10
+ import type { ParsedImageContent } from "./types.js";
11
+
12
+ // Cursor CLI's local-image path scales/compresses images to <= 5 MiB
13
+ // and accepts only jpeg/png/gif/webp by magic bytes.
14
+ export const CURSOR_CLI_MAX_IMAGE_BYTES = 5_242_880;
15
+ const MAX_INLINE_IMAGE_BASE64_CHARS = Math.ceil((CURSOR_CLI_MAX_IMAGE_BYTES * 4) / 3) + 1024;
16
+
17
+ export const CURSOR_SUPPORTED_IMAGE_MIME_TYPES = new Set([
18
+ "image/jpeg",
19
+ "image/png",
20
+ "image/gif",
21
+ "image/webp",
22
+ ]);
23
+
24
+ export interface ImageDecodeOptions {
25
+ enforceCursorCliLimits?: boolean;
26
+ /**
27
+ * Return `undefined` instead of throwing when an image fails Cursor's limits.
28
+ *
29
+ * Rejecting loudly is right for the image a caller is attaching right now — they can resize it.
30
+ * It is wrong for images already sitting in the transcript: those are unfixable, and since the
31
+ * whole history is re-parsed on every request, one bad image would otherwise fail not just its
32
+ * own turn but every turn after it, permanently.
33
+ */
34
+ dropInvalid?: boolean;
35
+ }
36
+
37
+ export function normalizeImageMimeType(mimeType: string): string {
38
+ const normalized = mimeType.trim().toLowerCase();
39
+ return normalized === "image/jpg" ? "image/jpeg" : normalized;
40
+ }
41
+
42
+ export function sniffCursorImageMimeType(bytes: Uint8Array): string | undefined {
43
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
44
+ return "image/jpeg";
45
+ if (
46
+ bytes.length >= 4 &&
47
+ bytes[0] === 0x89 &&
48
+ bytes[1] === 0x50 &&
49
+ bytes[2] === 0x4e &&
50
+ bytes[3] === 0x47
51
+ )
52
+ return "image/png";
53
+ if (bytes.length >= 3 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46)
54
+ return "image/gif";
55
+ if (
56
+ bytes.length >= 12 &&
57
+ bytes[0] === 0x52 &&
58
+ bytes[1] === 0x49 &&
59
+ bytes[2] === 0x46 &&
60
+ bytes[3] === 0x46 &&
61
+ bytes[8] === 0x57 &&
62
+ bytes[9] === 0x45 &&
63
+ bytes[10] === 0x42 &&
64
+ bytes[11] === 0x50
65
+ )
66
+ return "image/webp";
67
+ return undefined;
68
+ }
69
+
70
+ export function validateCursorCliImageLimits(bytes: Uint8Array): string {
71
+ if (bytes.length > CURSOR_CLI_MAX_IMAGE_BYTES) {
72
+ throw new Error(
73
+ `Image exceeds Cursor CLI's ${CURSOR_CLI_MAX_IMAGE_BYTES} byte limit after processing.`,
74
+ );
75
+ }
76
+ const sniffedMimeType = sniffCursorImageMimeType(bytes);
77
+ if (!sniffedMimeType || !CURSOR_SUPPORTED_IMAGE_MIME_TYPES.has(sniffedMimeType)) {
78
+ throw new Error("Unsupported image type: supported formats are jpeg, png, gif, or webp.");
79
+ }
80
+ return sniffedMimeType;
81
+ }
82
+
83
+ export function decodeBase64Image(
84
+ data: string,
85
+ mimeType: string,
86
+ options: ImageDecodeOptions = {},
87
+ ): ParsedImageContent | undefined {
88
+ const normalizedMimeType = normalizeImageMimeType(mimeType);
89
+ if (!normalizedMimeType.startsWith("image/")) return undefined;
90
+ if (options.enforceCursorCliLimits && data.length > MAX_INLINE_IMAGE_BASE64_CHARS) {
91
+ const error = new Error(
92
+ `Inline image exceeds Cursor CLI's ${MAX_INLINE_IMAGE_BASE64_CHARS} character encoded limit.`,
93
+ );
94
+ if (!options.dropInvalid) throw error;
95
+ return undefined;
96
+ }
97
+ const base64 = data.replace(/\s/g, "");
98
+ if (!base64) return undefined;
99
+ const bytes = new Uint8Array(Buffer.from(base64, "base64"));
100
+ if (bytes.length === 0) return undefined;
101
+ let finalMimeType = normalizedMimeType;
102
+ if (options.enforceCursorCliLimits) {
103
+ try {
104
+ finalMimeType = validateCursorCliImageLimits(bytes);
105
+ } catch (error) {
106
+ if (!options.dropInvalid) throw error;
107
+ return undefined;
108
+ }
109
+ }
110
+ return { data: bytes, mimeType: finalMimeType };
111
+ }
112
+
113
+ export function parseImageDataUrl(
114
+ url: string,
115
+ options: ImageDecodeOptions = {},
116
+ ): ParsedImageContent | undefined {
117
+ if (options.dropInvalid) {
118
+ try {
119
+ return parseImageDataUrlStrict(url, options);
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ return parseImageDataUrlStrict(url, options);
125
+ }
126
+
127
+ function parseImageDataUrlStrict(
128
+ url: string,
129
+ options: ImageDecodeOptions,
130
+ ): ParsedImageContent | undefined {
131
+ const trimmed = url.trim();
132
+ if (/^https?:\/\//i.test(trimmed)) {
133
+ throw new Error(
134
+ "Remote image URLs are not supported by pi-cursor-provider. Attach the image or send an inline data:image/...;base64,... URL.",
135
+ );
136
+ }
137
+ if (!trimmed.startsWith("data:")) {
138
+ throw new Error(
139
+ "Only inline data:image/...;base64,... image_url values are supported by pi-cursor-provider.",
140
+ );
141
+ }
142
+ const match = trimmed.match(/^data:([^;,]+)(?:;[^,]*)?;base64,(.*)$/is);
143
+ if (!match) {
144
+ throw new Error("Unsupported image_url format. Expected data:image/...;base64,...");
145
+ }
146
+ const image = decodeBase64Image(match[2]!, match[1]!, options);
147
+ if (!image) {
148
+ throw new Error("Unsupported image_url MIME type. Expected data:image/...;base64,...");
149
+ }
150
+ return image;
151
+ }
152
+
153
+ // The whole transcript is re-parsed on every request, so the same image buffers are hashed
154
+ // again and again across turns. Image bytes are treated as immutable once decoded, so a digest
155
+ // keyed by buffer identity stays valid for the buffer's lifetime.
156
+ const contentDigestCache = new WeakMap<Uint8Array, string>();
157
+
158
+ function contentDigest(data: Uint8Array): string {
159
+ let digest = contentDigestCache.get(data);
160
+ if (digest === undefined) {
161
+ digest = createHash("sha256").update(data).digest("hex");
162
+ contentDigestCache.set(data, digest);
163
+ }
164
+ return digest;
165
+ }
166
+
167
+ export function imageKey(image: ParsedImageContent): string {
168
+ return `${image.mimeType}:${contentDigest(image.data)}`;
169
+ }
170
+
171
+ export function mergeImages(
172
+ ...groups: Array<ParsedImageContent[] | undefined>
173
+ ): ParsedImageContent[] | undefined {
174
+ const merged: ParsedImageContent[] = [];
175
+ // Two images can only be duplicates if they agree on MIME type and byte length, and that is
176
+ // cheap to check. Bucketing on it first means the common all-distinct case never hashes a
177
+ // payload at all; only images that collide on shape are digested to settle the tie.
178
+ const byShape = new Map<string, { first: ParsedImageContent; digests?: Set<string> }>();
179
+ for (const group of groups) {
180
+ for (const image of group ?? []) {
181
+ const shape = `${image.mimeType}:${image.data.byteLength}`;
182
+ const bucket = byShape.get(shape);
183
+ if (!bucket) {
184
+ byShape.set(shape, { first: image });
185
+ merged.push(image);
186
+ continue;
187
+ }
188
+ // Second image of this shape: the bucket's digest set is worth materializing now.
189
+ const digests = (bucket.digests ??= new Set([contentDigest(bucket.first.data)]));
190
+ const digest = contentDigest(image.data);
191
+ if (digests.has(digest)) continue;
192
+ digests.add(digest);
193
+ merged.push(image);
194
+ }
195
+ }
196
+ return merged.length > 0 ? merged : undefined;
197
+ }
198
+
199
+ export function cloneParsedImage(image: ParsedImageContent): ParsedImageContent {
200
+ return { data: new Uint8Array(image.data), mimeType: image.mimeType };
201
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Public stream surface for the Cursor provider.
3
+ *
4
+ * All chat traffic goes through the native `streamSimple` path. The legacy
5
+ * OpenAI-compatible local proxy that used to live alongside it in native-core
6
+ * was removed in favour of a single code path.
7
+ */
8
+ export {
9
+ createCursorNativeStream,
10
+ getCursorModels,
11
+ getCursorParameterizedModels,
12
+ cleanupSessionState,
13
+ cleanupAllSessionState,
14
+ type CursorModel,
15
+ type CursorNativeStreamConfig,
16
+ } from "./native-core.js";
17
+
18
+ export { getCursorAgentUrl, getCursorClientVersion } from "./config.js";
19
+ export {
20
+ resolveModelId,
21
+ resolveRequestedModelId,
22
+ type CursorNativeModelRouting,
23
+ } from "./model-routing.js";
24
+ export {
25
+ isContextModeSideChannelText,
26
+ normalizeMessagesForCursor,
27
+ frameContextModeSideChannel,
28
+ systemPromptHasSessionMemory,
29
+ } from "./context-normalize.js";
30
+ export {
31
+ planRecovery,
32
+ fingerprintCompletedTurns,
33
+ wrapRecoveredToolResults,
34
+ lostToolContinuationErrorBody,
35
+ formatLostToolContinuationDiagnostic,
36
+ type RecoveryDecision,
37
+ type PlanRecoveryInput,
38
+ type StoredConversation,
39
+ } from "./recovery.js";
40
+ export {
41
+ appendDriftDiagnostic,
42
+ enhanceCursorStreamError,
43
+ isAuthErrorMessage,
44
+ isProtocolMismatchMessage,
45
+ } from "./protocol.js";
46
+ export {
47
+ formatDriftSummary,
48
+ getDriftSignals,
49
+ hasStrandingDrift,
50
+ recordDriftSignal,
51
+ recordUnknownFields,
52
+ type DriftKind,
53
+ type DriftSignal,
54
+ } from "./drift.js";
55
+ export { handleInteractionQuery } from "./interaction-query.js";
56
+ export { canRecoverAfterTransportLoss, canBlindIdleRestart } from "./tuning.js";
57
+ export {
58
+ CHECKPOINT_CONTINUATION_PROMPT,
59
+ classifyBridgeExit,
60
+ formatTransportFailure,
61
+ type TransportFailure,
62
+ } from "./transport-errors.js";
63
+ export {
64
+ readConversationJournal,
65
+ writeConversationJournal,
66
+ serializeConversationJournal,
67
+ deserializeConversationJournal,
68
+ } from "./run-journal.js";
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Handle Cursor InteractionQuery messages so the AgentService stream never
3
+ * stalls waiting for a permission / interaction reply that Pi never sends.
4
+ *
5
+ * Unanswered interaction queries are a primary cause of "model stops after a
6
+ * few minutes" — Cursor parks the run until InteractionResponse arrives.
7
+ */
8
+ import { create, toBinary } from "@bufbuild/protobuf";
9
+ import {
10
+ AgentClientMessageSchema,
11
+ AskQuestionErrorSchema,
12
+ AskQuestionInteractionResponseSchema,
13
+ AskQuestionResultSchema,
14
+ CreatePlanErrorSchema,
15
+ CreatePlanRequestResponseSchema,
16
+ CreatePlanResultSchema,
17
+ ExaFetchRequestResponseSchema,
18
+ ExaFetchRequestResponse_ApprovedSchema,
19
+ ExaFetchRequestResponse_RejectedSchema,
20
+ ExaSearchRequestResponseSchema,
21
+ ExaSearchRequestResponse_ApprovedSchema,
22
+ ExaSearchRequestResponse_RejectedSchema,
23
+ InteractionResponseSchema,
24
+ SetupVmEnvironmentResultSchema,
25
+ SetupVmEnvironmentSuccessSchema,
26
+ SwitchModeRequestResponseSchema,
27
+ SwitchModeRequestResponse_RejectedSchema,
28
+ WebSearchRequestResponseSchema,
29
+ WebSearchRequestResponse_ApprovedSchema,
30
+ WebSearchRequestResponse_RejectedSchema,
31
+ type InteractionQuery,
32
+ type InteractionResponse,
33
+ } from "../proto/agent_pb.js";
34
+ import { frameConnectMessage } from "../client/bridge.js";
35
+
36
+ const CURSOR_WEB_FETCH_INTERACTION_FIELD = 9;
37
+
38
+ const PI_REJECT_REASON =
39
+ "Not available through the Pi Cursor provider. Use Pi tools (web_search, fetch, bash, etc.) instead.";
40
+
41
+ function encodeVarint(value: number): number[] {
42
+ const bytes: number[] = [];
43
+ let remaining = value >>> 0;
44
+ while (remaining >= 0x80) {
45
+ bytes.push((remaining & 0x7f) | 0x80);
46
+ remaining >>>= 7;
47
+ }
48
+ bytes.push(remaining);
49
+ return bytes;
50
+ }
51
+
52
+ function encodeLengthDelimitedField(fieldNo: number, data: Uint8Array): number[] {
53
+ return [(fieldNo << 3) | 2, ...encodeVarint(data.length), ...data];
54
+ }
55
+
56
+ /**
57
+ * Field #9 is unnamed in the generated proto (web-fetch shaped). Approving it is
58
+ * indistinguishable from granting a future destructive capability if Cursor reuses
59
+ * the number, so we always reject. We still have to *answer* — `handled: false`
60
+ * throws and kills the in-flight turn (#10).
61
+ *
62
+ * Wire shape mirrors ExaFetch/WebSearch: response oneof field 2 = rejected,
63
+ * rejected.reason = 1.
64
+ */
65
+ function buildCursorWebFetchInteractionRejectionBytes(id: number): Uint8Array {
66
+ const reason = new TextEncoder().encode(PI_REJECT_REASON);
67
+ const rejected = new Uint8Array(encodeLengthDelimitedField(1, reason));
68
+ const result = new Uint8Array(encodeLengthDelimitedField(2, rejected));
69
+ const interactionResponse = new Uint8Array([
70
+ 0x08,
71
+ ...encodeVarint(id),
72
+ ...encodeLengthDelimitedField(CURSOR_WEB_FETCH_INTERACTION_FIELD, result),
73
+ ]);
74
+ return new Uint8Array(encodeLengthDelimitedField(6, interactionResponse));
75
+ }
76
+
77
+ function hasUnknownInteractionField(query: InteractionQuery, fieldNo: number): boolean {
78
+ return ((query as unknown as { $unknown?: Array<{ no: number }> }).$unknown ?? []).some(
79
+ (field) => field.no === fieldNo,
80
+ );
81
+ }
82
+
83
+ function sendInteractionResponse(
84
+ response: InteractionResponse,
85
+ sendFrame: (data: Uint8Array) => void,
86
+ ): void {
87
+ const clientMsg = create(AgentClientMessageSchema, {
88
+ message: { case: "interactionResponse", value: response },
89
+ });
90
+ sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMsg)));
91
+ }
92
+
93
+ function approveWebSearch(id: number, sendFrame: (data: Uint8Array) => void): void {
94
+ sendInteractionResponse(
95
+ create(InteractionResponseSchema, {
96
+ id,
97
+ result: {
98
+ case: "webSearchRequestResponse",
99
+ value: create(WebSearchRequestResponseSchema, {
100
+ result: {
101
+ case: "approved",
102
+ value: create(WebSearchRequestResponse_ApprovedSchema, {}),
103
+ },
104
+ }),
105
+ },
106
+ }),
107
+ sendFrame,
108
+ );
109
+ }
110
+
111
+ function rejectWebSearch(id: number, sendFrame: (data: Uint8Array) => void): void {
112
+ sendInteractionResponse(
113
+ create(InteractionResponseSchema, {
114
+ id,
115
+ result: {
116
+ case: "webSearchRequestResponse",
117
+ value: create(WebSearchRequestResponseSchema, {
118
+ result: {
119
+ case: "rejected",
120
+ value: create(WebSearchRequestResponse_RejectedSchema, { reason: PI_REJECT_REASON }),
121
+ },
122
+ }),
123
+ },
124
+ }),
125
+ sendFrame,
126
+ );
127
+ }
128
+
129
+ function approveExaSearch(id: number, sendFrame: (data: Uint8Array) => void): void {
130
+ sendInteractionResponse(
131
+ create(InteractionResponseSchema, {
132
+ id,
133
+ result: {
134
+ case: "exaSearchRequestResponse",
135
+ value: create(ExaSearchRequestResponseSchema, {
136
+ result: {
137
+ case: "approved",
138
+ value: create(ExaSearchRequestResponse_ApprovedSchema, {}),
139
+ },
140
+ }),
141
+ },
142
+ }),
143
+ sendFrame,
144
+ );
145
+ }
146
+
147
+ function rejectExaSearch(id: number, sendFrame: (data: Uint8Array) => void): void {
148
+ sendInteractionResponse(
149
+ create(InteractionResponseSchema, {
150
+ id,
151
+ result: {
152
+ case: "exaSearchRequestResponse",
153
+ value: create(ExaSearchRequestResponseSchema, {
154
+ result: {
155
+ case: "rejected",
156
+ value: create(ExaSearchRequestResponse_RejectedSchema, { reason: PI_REJECT_REASON }),
157
+ },
158
+ }),
159
+ },
160
+ }),
161
+ sendFrame,
162
+ );
163
+ }
164
+
165
+ function approveExaFetch(id: number, sendFrame: (data: Uint8Array) => void): void {
166
+ sendInteractionResponse(
167
+ create(InteractionResponseSchema, {
168
+ id,
169
+ result: {
170
+ case: "exaFetchRequestResponse",
171
+ value: create(ExaFetchRequestResponseSchema, {
172
+ result: {
173
+ case: "approved",
174
+ value: create(ExaFetchRequestResponse_ApprovedSchema, {}),
175
+ },
176
+ }),
177
+ },
178
+ }),
179
+ sendFrame,
180
+ );
181
+ }
182
+
183
+ function rejectExaFetch(id: number, sendFrame: (data: Uint8Array) => void): void {
184
+ sendInteractionResponse(
185
+ create(InteractionResponseSchema, {
186
+ id,
187
+ result: {
188
+ case: "exaFetchRequestResponse",
189
+ value: create(ExaFetchRequestResponseSchema, {
190
+ result: {
191
+ case: "rejected",
192
+ value: create(ExaFetchRequestResponse_RejectedSchema, { reason: PI_REJECT_REASON }),
193
+ },
194
+ }),
195
+ },
196
+ }),
197
+ sendFrame,
198
+ );
199
+ }
200
+
201
+ function rejectSwitchMode(id: number, sendFrame: (data: Uint8Array) => void): void {
202
+ sendInteractionResponse(
203
+ create(InteractionResponseSchema, {
204
+ id,
205
+ result: {
206
+ case: "switchModeRequestResponse",
207
+ value: create(SwitchModeRequestResponseSchema, {
208
+ result: {
209
+ case: "rejected",
210
+ value: create(SwitchModeRequestResponse_RejectedSchema, { reason: PI_REJECT_REASON }),
211
+ },
212
+ }),
213
+ },
214
+ }),
215
+ sendFrame,
216
+ );
217
+ }
218
+
219
+ function skipAskQuestion(id: number, sendFrame: (data: Uint8Array) => void): void {
220
+ sendInteractionResponse(
221
+ create(InteractionResponseSchema, {
222
+ id,
223
+ result: {
224
+ case: "askQuestionInteractionResponse",
225
+ value: create(AskQuestionInteractionResponseSchema, {
226
+ result: create(AskQuestionResultSchema, {
227
+ result: {
228
+ case: "error",
229
+ value: create(AskQuestionErrorSchema, {
230
+ errorMessage:
231
+ "Interactive questions are not available in Pi. Continue with a reasonable default or ask the user in chat.",
232
+ }),
233
+ },
234
+ }),
235
+ }),
236
+ },
237
+ }),
238
+ sendFrame,
239
+ );
240
+ }
241
+
242
+ function skipCreatePlan(id: number, sendFrame: (data: Uint8Array) => void): void {
243
+ sendInteractionResponse(
244
+ create(InteractionResponseSchema, {
245
+ id,
246
+ result: {
247
+ case: "createPlanRequestResponse",
248
+ value: create(CreatePlanRequestResponseSchema, {
249
+ result: create(CreatePlanResultSchema, {
250
+ planUri: "",
251
+ result: {
252
+ case: "error",
253
+ value: create(CreatePlanErrorSchema, {
254
+ error: "Create-plan UI is not available in Pi. Write the plan with Pi file tools.",
255
+ }),
256
+ },
257
+ }),
258
+ }),
259
+ },
260
+ }),
261
+ sendFrame,
262
+ );
263
+ }
264
+
265
+ function skipSetupVm(id: number, sendFrame: (data: Uint8Array) => void): void {
266
+ sendInteractionResponse(
267
+ create(InteractionResponseSchema, {
268
+ id,
269
+ result: {
270
+ case: "setupVmEnvironmentResult",
271
+ value: create(SetupVmEnvironmentResultSchema, {
272
+ result: {
273
+ case: "success",
274
+ value: create(SetupVmEnvironmentSuccessSchema, {}),
275
+ },
276
+ }),
277
+ },
278
+ }),
279
+ sendFrame,
280
+ );
281
+ }
282
+
283
+ export type InteractionQueryHandleResult = {
284
+ handled: boolean;
285
+ action: string;
286
+ queryCase: string | undefined;
287
+ };
288
+
289
+ /**
290
+ * Always attempt to answer InteractionQuery so the upstream run does not park.
291
+ * Web/search is rejected by default so Cursor-side fetches do not run under the
292
+ * user's subscription; pass `{ approveWeb: true }` only in tests or explicit opt-in.
293
+ */
294
+ export function handleInteractionQuery(
295
+ query: InteractionQuery,
296
+ sendFrame: (data: Uint8Array) => void,
297
+ options?: { approveWeb?: boolean },
298
+ ): InteractionQueryHandleResult {
299
+ const approveWeb = options?.approveWeb === true;
300
+ const queryCase = query.query.case;
301
+
302
+ // Field #9 is unnamed in the generated proto. Approving it is indistinguishable
303
+ // from granting a future destructive capability if Cursor reuses the number.
304
+ // Reject with a real InteractionResponse so Cursor unblocks instead of parking,
305
+ // and so processServerMessage does not throw and kill the turn (#10).
306
+ if (hasUnknownInteractionField(query, CURSOR_WEB_FETCH_INTERACTION_FIELD)) {
307
+ sendFrame(frameConnectMessage(buildCursorWebFetchInteractionRejectionBytes(query.id)));
308
+ return {
309
+ handled: true,
310
+ action: "unknown_field_9_rejected",
311
+ queryCase: queryCase ?? "unknown_field_9",
312
+ };
313
+ }
314
+
315
+ switch (queryCase) {
316
+ case "webSearchRequestQuery":
317
+ if (approveWeb) approveWebSearch(query.id, sendFrame);
318
+ else rejectWebSearch(query.id, sendFrame);
319
+ return {
320
+ handled: true,
321
+ action: approveWeb ? "web_search_approved" : "web_search_rejected",
322
+ queryCase,
323
+ };
324
+ case "exaSearchRequestQuery":
325
+ if (approveWeb) approveExaSearch(query.id, sendFrame);
326
+ else rejectExaSearch(query.id, sendFrame);
327
+ return {
328
+ handled: true,
329
+ action: approveWeb ? "exa_search_approved" : "exa_search_rejected",
330
+ queryCase,
331
+ };
332
+ case "exaFetchRequestQuery":
333
+ if (approveWeb) approveExaFetch(query.id, sendFrame);
334
+ else rejectExaFetch(query.id, sendFrame);
335
+ return {
336
+ handled: true,
337
+ action: approveWeb ? "exa_fetch_approved" : "exa_fetch_rejected",
338
+ queryCase,
339
+ };
340
+ case "switchModeRequestQuery":
341
+ rejectSwitchMode(query.id, sendFrame);
342
+ return { handled: true, action: "switch_mode_rejected", queryCase };
343
+ case "askQuestionInteractionQuery":
344
+ skipAskQuestion(query.id, sendFrame);
345
+ return { handled: true, action: "ask_question_skipped", queryCase };
346
+ case "createPlanRequestQuery":
347
+ skipCreatePlan(query.id, sendFrame);
348
+ return { handled: true, action: "create_plan_skipped", queryCase };
349
+ case "setupVmEnvironmentArgs":
350
+ skipSetupVm(query.id, sendFrame);
351
+ return { handled: true, action: "setup_vm_acked", queryCase };
352
+ default: {
353
+ // Protocol drift must fail closed. An empty result for an unknown field is
354
+ // indistinguishable from approval and could grant a future destructive capability.
355
+ const unknown = (query as unknown as { $unknown?: Array<{ no: number }> }).$unknown ?? [];
356
+ if (unknown.length > 0) {
357
+ const fieldNo = unknown[0]!.no;
358
+ return {
359
+ handled: false,
360
+ action: `unknown_field_${fieldNo}_rejected`,
361
+ queryCase: queryCase ?? "unknown",
362
+ };
363
+ }
364
+ // No case and no unknown fields — still send a switch-mode-style reject is impossible.
365
+ // Best effort: skip ask-question style is wrong. Log as unhandled.
366
+ return { handled: false, action: "unhandled", queryCase: queryCase ?? "undefined" };
367
+ }
368
+ }
369
+ }