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,777 @@
1
+ /**
2
+ * Dispatch for everything Cursor sends back on the bidirectional stream.
3
+ *
4
+ * Three families arrive interleaved with assistant output and each needs a reply
5
+ * on the same stream or the server parks waiting:
6
+ * - `kvServerMessage` blob get/set against the local blob store
7
+ * - `execServerMessage` tool execution — MCP calls are handed to the caller,
8
+ * Cursor's own native tools (shell/read/write/...) get an explicit reject so
9
+ * the model re-plans instead of stalling
10
+ * - `interactionQuery` permission prompts, answered by ./interaction-query.ts
11
+ *
12
+ * Every handler returns whether it made forward progress, which is what feeds
13
+ * the idle watchdog — see `processServerMessage` for the exact contract.
14
+ */
15
+ import { create, toBinary } from "@bufbuild/protobuf";
16
+
17
+ import {
18
+ AgentClientMessageSchema,
19
+ BackgroundShellSpawnResultSchema,
20
+ ComputerUseErrorSchema,
21
+ ComputerUseResultSchema,
22
+ ConversationStateStructureSchema,
23
+ DeleteRejectedSchema,
24
+ DeleteResultSchema,
25
+ DiagnosticsResultSchema,
26
+ ExecClientControlMessageSchema,
27
+ ExecClientMessageSchema,
28
+ ExecClientThrowSchema,
29
+ FetchErrorSchema,
30
+ FetchResultSchema,
31
+ GetBlobResultSchema,
32
+ GrepErrorSchema,
33
+ GrepResultSchema,
34
+ KvClientMessageSchema,
35
+ ListMcpResourcesExecResultSchema,
36
+ ListMcpResourcesRejectedSchema,
37
+ LsRejectedSchema,
38
+ LsResultSchema,
39
+ McpResultSchema,
40
+ McpToolNotFoundSchema,
41
+ ReadMcpResourceExecResultSchema,
42
+ ReadMcpResourceRejectedSchema,
43
+ ReadRejectedSchema,
44
+ ReadResultSchema,
45
+ RecordScreenFailureSchema,
46
+ RecordScreenResultSchema,
47
+ RequestContextResultSchema,
48
+ RequestContextSchema,
49
+ RequestContextSuccessSchema,
50
+ SetBlobResultSchema,
51
+ ShellRejectedSchema,
52
+ ShellResultSchema,
53
+ ShellStreamSchema,
54
+ WriteRejectedSchema,
55
+ WriteResultSchema,
56
+ WriteShellStdinErrorSchema,
57
+ WriteShellStdinResultSchema,
58
+ type AgentServerMessage,
59
+ type ConversationStateStructure,
60
+ type ExecServerMessage,
61
+ type InteractionQuery,
62
+ type KvServerMessage,
63
+ type McpToolDefinition,
64
+ } from "../proto/agent_pb.js";
65
+ import { frameConnectMessage } from "../client/bridge.js";
66
+ import { debugLog, lifecycleLog, reportCursorAnomaly } from "./debug-log.js";
67
+ import { recordDriftSignal, recordUnknownFields } from "./drift.js";
68
+ import { handleInteractionQuery } from "./interaction-query.js";
69
+ import { decodeMcpArgsMap } from "./request-build.js";
70
+ import {
71
+ interactionUpdateProgress,
72
+ MAX_ACTIVE_BLOB_BYTES,
73
+ MAX_ACTIVE_BLOB_ENTRIES,
74
+ MAX_INDIVIDUAL_BLOB_BYTES,
75
+ type StreamProgress,
76
+ } from "./tuning.js";
77
+ import { markBlobMiss, trimBlobStore } from "./session-state.js";
78
+ import { billedUsageFromTurnEnded } from "./pi-adapter.js";
79
+ import { recordRunReceipt } from "./run-usage.js";
80
+ import type { PendingExec, StreamState } from "./types.js";
81
+ import { setLastStreamEvent } from "../diagnostics/diagnostics.js";
82
+
83
+ /**
84
+ * Classifies a server message for the stream idle watchdog.
85
+ *
86
+ * `work` — the run is moving: non-empty `textDelta` / `thinkingDelta`,
87
+ * `tokenDelta` (long reasoning often emits only these for minutes), tool-call
88
+ * events, any answered `execServerMessage` (MCP exec **or** native-tool reject),
89
+ * answered interaction queries, KV blob round-trips, checkpoints.
90
+ *
91
+ * `liveness` — a heartbeat. The socket is healthy; the turn may still be parked.
92
+ *
93
+ * `none` — empty deltas, unanswered exec/KV/interaction cases, other noise.
94
+ */
95
+ export function processServerMessage(
96
+ msg: AgentServerMessage,
97
+ blobStore: Map<string, Uint8Array>,
98
+ mcpTools: McpToolDefinition[],
99
+ sendFrame: (data: Uint8Array) => void,
100
+ state: StreamState,
101
+ onText: (text: string, isThinking?: boolean) => void,
102
+ onMcpExec: (exec: PendingExec) => void,
103
+ onCheckpoint?: (checkpointBytes: Uint8Array, contextTokens?: number) => void,
104
+ onExecUnanswerable?: (execCase: string | undefined) => void,
105
+ convKey?: string,
106
+ ): StreamProgress {
107
+ const msgCase = msg.message.case;
108
+ debugLog("server_message", { msgCase, msg });
109
+ recordUnknownFields(`AgentServerMessage.${msgCase ?? "none"}`, msg);
110
+ recordUnknownFields(`${msgCase ?? "none"}.payload`, msg.message.value);
111
+
112
+ if (msgCase === "interactionUpdate") {
113
+ const update = msg.message.value as any;
114
+ const updateCase = update.message?.case;
115
+ if (updateCase === "textDelta") {
116
+ const delta = update.message.value.text || "";
117
+ if (delta) {
118
+ onText(delta, false);
119
+ return interactionUpdateProgress(updateCase, true);
120
+ }
121
+ return "none";
122
+ }
123
+ if (updateCase === "thinkingDelta") {
124
+ const delta = update.message.value.text || "";
125
+ if (delta) {
126
+ onText(delta, true);
127
+ return interactionUpdateProgress(updateCase, true);
128
+ }
129
+ return "none";
130
+ }
131
+ if (updateCase === "tokenDelta") {
132
+ state.outputTokens += update.message.value.tokens ?? 0;
133
+ return interactionUpdateProgress(updateCase);
134
+ }
135
+ if (updateCase === "toolCallCompleted") {
136
+ const completed = update.message.value as any;
137
+ const mcpToolCall =
138
+ completed.toolCall?.tool?.case === "mcpToolCall"
139
+ ? completed.toolCall.tool.value
140
+ : undefined;
141
+ const result = mcpToolCall?.result?.result;
142
+ if (result?.case && result.case !== "success") {
143
+ const args = mcpToolCall.args;
144
+ const value = result.value as any;
145
+ debugLog("native.stream.mcp_tool_error", {
146
+ callId: completed.callId,
147
+ modelCallId: completed.modelCallId,
148
+ resultCase: result.case,
149
+ toolName: args?.toolName,
150
+ mcpName: args?.name,
151
+ providerIdentifier: args?.providerIdentifier,
152
+ error: value?.error ?? value?.reason,
153
+ errorUnknown: value?.$unknown,
154
+ });
155
+ }
156
+ return interactionUpdateProgress(updateCase);
157
+ }
158
+ if (updateCase === "turnEnded") {
159
+ // Cursor closes the HTTP/2 connection right after this. Recorded so the close is
160
+ // finalized as a completed turn instead of retried as a failure (upstream #3).
161
+ state.turnEnded = true;
162
+ const billed = billedUsageFromTurnEnded(update.message.value ?? {});
163
+ if (billed) {
164
+ if (billed.cacheRead + billed.cacheWrite > billed.input) {
165
+ debugLog("native.stream.turn_ended_cache_exceeds_input", {
166
+ input: billed.input,
167
+ cacheRead: billed.cacheRead,
168
+ cacheWrite: billed.cacheWrite,
169
+ });
170
+ }
171
+ recordRunReceipt(state, billed, update.message.value ?? {});
172
+ }
173
+ return "work";
174
+ }
175
+ // Remaining cases (heartbeat, toolCallStarted, partialToolCall, ...) are already
176
+ // classified by interactionUpdateProgress; reuse it rather than keeping a second list.
177
+ const progress = interactionUpdateProgress(updateCase);
178
+ if (progress !== "none") return progress;
179
+ // Unrecognized update cases are informational rather than stranding — the
180
+ // stream keeps flowing — but they are the first sign our schema is behind.
181
+ recordDriftSignal("interaction_update", updateCase);
182
+ return "none";
183
+ }
184
+ if (msgCase === "kvServerMessage") {
185
+ return handleKvMessage(msg.message.value as KvServerMessage, blobStore, sendFrame, convKey)
186
+ ? "work"
187
+ : "none";
188
+ }
189
+ if (msgCase === "execServerMessage") {
190
+ const execMsg = msg.message.value as ExecServerMessage;
191
+ const execCase = (execMsg as { message?: { case?: string } }).message?.case;
192
+ const handled = handleExecMessage(execMsg, mcpTools, sendFrame, onMcpExec);
193
+ // execServerMessage was previously invisible in the lifecycle log — the exact
194
+ // blind spot behind unexplained mid-run stalls. Record the exec case and whether
195
+ // we answered it, so a parked stream can be diagnosed from the sanitized log
196
+ // alone. mcpArgs is the normal tool-call path; anything unhandled here means the
197
+ // upstream run may park waiting for a result we never sent.
198
+ if (execCase !== "mcpArgs") {
199
+ lifecycleLog("exec_server", { execCase: execCase ?? "unknown", handled });
200
+ }
201
+ if (!handled) {
202
+ setLastStreamEvent(`exec_unanswered:${String(execCase ?? "unknown")}`);
203
+ recordDriftSignal("exec_message", execCase);
204
+ onExecUnanswerable?.(execCase);
205
+ }
206
+ return handled ? "work" : "none";
207
+ }
208
+ if (msgCase === "interactionQuery") {
209
+ const query = msg.message.value as InteractionQuery;
210
+ const result = handleInteractionQuery(query, sendFrame);
211
+ lifecycleLog("interaction_query", {
212
+ id: query.id,
213
+ queryCase: result.queryCase,
214
+ action: result.action,
215
+ handled: result.handled,
216
+ });
217
+ debugLog(
218
+ result.handled ? "native.interaction_query.handled" : "native.interaction_query.unhandled",
219
+ {
220
+ id: query.id,
221
+ queryCase: result.queryCase,
222
+ action: result.action,
223
+ clientVersion: process.env.PI_CURSOR_CLIENT_VERSION || "default",
224
+ },
225
+ );
226
+ setLastStreamEvent(
227
+ result.handled
228
+ ? `interaction_query:${result.action}`
229
+ : `interaction_query_unhandled:${result.queryCase ?? "unknown"}`,
230
+ );
231
+ if (!result.handled) {
232
+ recordDriftSignal("interaction_query", result.queryCase);
233
+ throw new Error(
234
+ `Unsupported Cursor interaction query ${result.queryCase ?? "unknown"} was rejected`,
235
+ );
236
+ }
237
+ return "work";
238
+ }
239
+ if (msgCase === "execServerControlMessage") {
240
+ const control = msg.message.value as { message?: { case?: string } };
241
+ const controlCase = control.message?.case;
242
+ debugLog("native.exec_server_control", { controlCase });
243
+ lifecycleLog("exec_server_control", { controlCase });
244
+ // Abort notices are informational; the stream may continue or end separately.
245
+ return controlCase === "abort" ? "work" : "none";
246
+ }
247
+ if (msgCase === "conversationCheckpointUpdate") {
248
+ const stateStructure = msg.message.value as ConversationStateStructure;
249
+ const usedTokens = stateStructure.tokenDetails?.usedTokens;
250
+ const contextTokens =
251
+ typeof usedTokens === "number" && Number.isFinite(usedTokens) && usedTokens > 0
252
+ ? usedTokens
253
+ : undefined;
254
+ // Pending tool checkpoints can carry 0/0 placeholders. Preserve the last positive observation,
255
+ // but accept genuine smaller positive values after an upstream context reduction.
256
+ if (contextTokens !== undefined) state.totalTokens = contextTokens;
257
+ if (onCheckpoint) {
258
+ onCheckpoint(toBinary(ConversationStateStructureSchema, stateStructure), contextTokens);
259
+ return "work";
260
+ }
261
+ return "none";
262
+ }
263
+
264
+ // Nothing matched: Cursor sent a server message this build has no branch for.
265
+ // Nobody answers it, so if the run was waiting on it the turn will park until
266
+ // the idle watchdog fires — record it so the timeout is explainable.
267
+ recordDriftSignal("server_message", msgCase);
268
+ return "none";
269
+ }
270
+
271
+ function sendKvResponse(
272
+ kvMsg: KvServerMessage,
273
+ messageCase: string,
274
+ value: unknown,
275
+ sendFrame: (data: Uint8Array) => void,
276
+ ): void {
277
+ const response = create(KvClientMessageSchema, {
278
+ id: (kvMsg as any).id,
279
+ message: { case: messageCase as any, value: value as any },
280
+ });
281
+ const clientMsg = create(AgentClientMessageSchema, {
282
+ message: { case: "kvClientMessage", value: response },
283
+ });
284
+ sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMsg)));
285
+ }
286
+
287
+ /** Returns true when a recognized KV branch fired (real round-trip with cursor). */
288
+ function handleKvMessage(
289
+ kvMsg: KvServerMessage,
290
+ blobStore: Map<string, Uint8Array>,
291
+ sendFrame: (data: Uint8Array) => void,
292
+ convKey?: string,
293
+ ): boolean {
294
+ const kvCase = (kvMsg as any).message.case;
295
+ if (kvCase === "getBlobArgs") {
296
+ const blobId = (kvMsg as any).message.value.blobId;
297
+ const blobIdKey = Buffer.from(blobId).toString("hex");
298
+ const blobData = blobStore.get(blobIdKey);
299
+ if (!blobData) {
300
+ // An empty result is valid history to Cursor, not a missing-blob error.
301
+ // Refuse the round-trip and fail this generation so the next turn rebuilds
302
+ // from Pi. Invalidate by conversation key: the live blob store is a clone.
303
+ lifecycleLog("kv_blob_miss", { blobId: blobIdKey.slice(0, 16), storeSize: blobStore.size });
304
+ setLastStreamEvent("kv_blob_miss");
305
+ if (convKey) markBlobMiss(convKey);
306
+ throw new Error(
307
+ `Cursor asked for blob ${blobIdKey.slice(0, 16)} that is not in the local store (${blobStore.size} entries). Refusing to answer empty. Retry to rebuild from Pi history.`,
308
+ );
309
+ }
310
+ sendKvResponse(kvMsg, "getBlobResult", create(GetBlobResultSchema, { blobData }), sendFrame);
311
+ return true;
312
+ }
313
+ if (kvCase === "setBlobArgs") {
314
+ const { blobId, blobData } = (kvMsg as any).message.value;
315
+ const blobIdKey = Buffer.from(blobId).toString("hex");
316
+ if (!(blobData instanceof Uint8Array)) throw new Error("Cursor sent invalid blob data");
317
+ if (blobData.byteLength > MAX_INDIVIDUAL_BLOB_BYTES) {
318
+ throw new Error(`Cursor blob exceeds the ${MAX_INDIVIDUAL_BLOB_BYTES} byte per-blob limit`);
319
+ }
320
+ // Reject blobs that cannot fit even in an empty store before any eviction, so a
321
+ // failed write cannot punch holes in history Cursor still references.
322
+ if (blobData.byteLength > MAX_ACTIVE_BLOB_BYTES) {
323
+ throw new Error(`Cursor blob store exceeds the ${MAX_ACTIVE_BLOB_BYTES} byte limit`);
324
+ }
325
+ if (!blobStore.has(blobIdKey)) {
326
+ const evicted = trimBlobStore(
327
+ blobStore,
328
+ MAX_ACTIVE_BLOB_BYTES - blobData.byteLength,
329
+ MAX_ACTIVE_BLOB_ENTRIES - 1,
330
+ );
331
+ if (evicted.removed > 0) {
332
+ debugLog("kv.blob_store_evicted", {
333
+ removed: evicted.removed,
334
+ totalBytes: evicted.totalBytes,
335
+ entries: blobStore.size,
336
+ maxEntries: MAX_ACTIVE_BLOB_ENTRIES,
337
+ });
338
+ }
339
+ }
340
+ blobStore.set(blobIdKey, blobData);
341
+ sendKvResponse(kvMsg, "setBlobResult", create(SetBlobResultSchema, {}), sendFrame);
342
+ return true;
343
+ }
344
+ recordDriftSignal("kv_message", kvCase);
345
+ return false;
346
+ }
347
+
348
+ /**
349
+ * Returns true when this `execServerMessage` was handled (MCP exec **or** a
350
+ * native-tool reject/response). Handled round-trips count as idle-watchdog
351
+ * progress so Cursor-native tool reject loops cannot stall for minutes and
352
+ * then trip the idle timer.
353
+ */
354
+ function handleExecMessage(
355
+ execMsg: ExecServerMessage,
356
+ mcpTools: McpToolDefinition[],
357
+ sendFrame: (data: Uint8Array) => void,
358
+ onMcpExec: (exec: PendingExec) => void,
359
+ ): boolean {
360
+ return handleExecMessageInner(execMsg, mcpTools, sendFrame, onMcpExec);
361
+ }
362
+
363
+ // mcpTools is fixed for the life of a stream but `mcpArgs` exec messages can arrive many times
364
+ // per turn; cache the derived name list by array identity instead of rebuilding it every call.
365
+ const availableToolNamesCache = new WeakMap<McpToolDefinition[], string[]>();
366
+
367
+ function availableToolNamesFor(mcpTools: McpToolDefinition[]): string[] {
368
+ const cached = availableToolNamesCache.get(mcpTools);
369
+ if (cached) return cached;
370
+ const names = [...new Set(mcpTools.flatMap((tool) => [tool.toolName, tool.name]))].filter(
371
+ Boolean,
372
+ ) as string[];
373
+ availableToolNamesCache.set(mcpTools, names);
374
+ return names;
375
+ }
376
+
377
+ const NATIVE_EXEC_MCP_HINTS: Record<string, string[]> = {
378
+ readArgs: ["read", "Read"],
379
+ lsArgs: ["ls", "LS"],
380
+ grepArgs: ["grep", "Grep"],
381
+ writeArgs: ["write", "edit", "Edit"],
382
+ deleteArgs: ["bash", "edit", "Edit"],
383
+ shellArgs: ["bash"],
384
+ shellStreamArgs: ["bash"],
385
+ backgroundShellSpawnArgs: ["bash"],
386
+ writeShellStdinArgs: ["bash"],
387
+ fetchArgs: ["web_search", "fetch"],
388
+ };
389
+
390
+ function nativeToolRejectReason(execCase: string, mcpTools: McpToolDefinition[]): string {
391
+ const available = availableToolNamesFor(mcpTools);
392
+ const candidates = (NATIVE_EXEC_MCP_HINTS[execCase] ?? []).filter((name) =>
393
+ available.includes(name),
394
+ );
395
+ if (candidates.length > 0) {
396
+ return (
397
+ `This native Cursor tool is not available in Pi. ` +
398
+ `Call the MCP tool "${candidates[0]}" with the same arguments instead.`
399
+ );
400
+ }
401
+ return "This native Cursor tool is not available in Pi. Use the MCP tools provided instead.";
402
+ }
403
+
404
+ function handleExecMessageInner(
405
+ execMsg: ExecServerMessage,
406
+ mcpTools: McpToolDefinition[],
407
+ sendFrame: (data: Uint8Array) => void,
408
+ onMcpExec: (exec: PendingExec) => void,
409
+ ): boolean {
410
+ const execCase = (execMsg as any).message.case;
411
+ const REJECT_REASON = nativeToolRejectReason(execCase ?? "", mcpTools);
412
+
413
+ if (execCase === "requestContextArgs") {
414
+ const requestContext = create(RequestContextSchema, {
415
+ rules: [],
416
+ repositoryInfo: [],
417
+ tools: mcpTools,
418
+ gitRepos: [],
419
+ projectLayouts: [],
420
+ mcpInstructions: [],
421
+ fileContents: {},
422
+ customSubagents: [],
423
+ });
424
+ const result = create(RequestContextResultSchema, {
425
+ result: { case: "success", value: create(RequestContextSuccessSchema, { requestContext }) },
426
+ });
427
+ sendExecResult(execMsg, "requestContextResult", result, sendFrame);
428
+ return true;
429
+ }
430
+
431
+ if (execCase === "mcpArgs") {
432
+ const mcpArgs = (execMsg as any).message.value;
433
+ const toolName =
434
+ typeof mcpArgs.toolName === "string" && mcpArgs.toolName
435
+ ? mcpArgs.toolName
436
+ : typeof mcpArgs.name === "string"
437
+ ? mcpArgs.name
438
+ : "";
439
+ const availableTools = availableToolNamesFor(mcpTools);
440
+ if (!toolName || !availableTools.includes(toolName)) {
441
+ const notFound = create(McpResultSchema, {
442
+ result: {
443
+ case: "toolNotFound",
444
+ value: create(McpToolNotFoundSchema, { name: toolName, availableTools }),
445
+ },
446
+ });
447
+ sendExecResult(execMsg, "mcpResult", notFound, sendFrame);
448
+ return true;
449
+ }
450
+ const decoded = decodeMcpArgsMap(mcpArgs.args ?? {});
451
+ onMcpExec({
452
+ execId: (execMsg as any).execId,
453
+ execMsgId: (execMsg as any).id,
454
+ toolCallId: mcpArgs.toolCallId || crypto.randomUUID(),
455
+ toolName,
456
+ decodedArgs: JSON.stringify(decoded),
457
+ });
458
+ return true;
459
+ }
460
+
461
+ // Reject native Cursor tools so model falls back to MCP tools
462
+ if (execCase === "readArgs") {
463
+ const args = (execMsg as any).message.value;
464
+ sendExecResult(
465
+ execMsg,
466
+ "readResult",
467
+ create(ReadResultSchema, {
468
+ result: {
469
+ case: "rejected",
470
+ value: create(ReadRejectedSchema, { path: args.path, reason: REJECT_REASON }),
471
+ },
472
+ }),
473
+ sendFrame,
474
+ );
475
+ return true;
476
+ }
477
+ if (execCase === "lsArgs") {
478
+ const args = (execMsg as any).message.value;
479
+ sendExecResult(
480
+ execMsg,
481
+ "lsResult",
482
+ create(LsResultSchema, {
483
+ result: {
484
+ case: "rejected",
485
+ value: create(LsRejectedSchema, { path: args.path, reason: REJECT_REASON }),
486
+ },
487
+ }),
488
+ sendFrame,
489
+ );
490
+ return true;
491
+ }
492
+ if (execCase === "grepArgs") {
493
+ sendExecResult(
494
+ execMsg,
495
+ "grepResult",
496
+ create(GrepResultSchema, {
497
+ result: { case: "error", value: create(GrepErrorSchema, { error: REJECT_REASON }) },
498
+ }),
499
+ sendFrame,
500
+ );
501
+ return true;
502
+ }
503
+ if (execCase === "writeArgs") {
504
+ const args = (execMsg as any).message.value;
505
+ sendExecResult(
506
+ execMsg,
507
+ "writeResult",
508
+ create(WriteResultSchema, {
509
+ result: {
510
+ case: "rejected",
511
+ value: create(WriteRejectedSchema, { path: args.path, reason: REJECT_REASON }),
512
+ },
513
+ }),
514
+ sendFrame,
515
+ );
516
+ return true;
517
+ }
518
+ if (execCase === "deleteArgs") {
519
+ const args = (execMsg as any).message.value;
520
+ sendExecResult(
521
+ execMsg,
522
+ "deleteResult",
523
+ create(DeleteResultSchema, {
524
+ result: {
525
+ case: "rejected",
526
+ value: create(DeleteRejectedSchema, { path: args.path, reason: REJECT_REASON }),
527
+ },
528
+ }),
529
+ sendFrame,
530
+ );
531
+ return true;
532
+ }
533
+ if (execCase === "shellArgs") {
534
+ const args = (execMsg as any).message.value;
535
+ sendExecResult(
536
+ execMsg,
537
+ "shellResult",
538
+ create(ShellResultSchema, {
539
+ result: {
540
+ case: "rejected",
541
+ value: create(ShellRejectedSchema, {
542
+ command: args.command ?? "",
543
+ workingDirectory: args.workingDirectory ?? "",
544
+ reason: REJECT_REASON,
545
+ isReadonly: false,
546
+ }),
547
+ },
548
+ }),
549
+ sendFrame,
550
+ );
551
+ return true;
552
+ }
553
+ if (execCase === "shellStreamArgs") {
554
+ const args = (execMsg as any).message.value;
555
+ sendExecResult(
556
+ execMsg,
557
+ "shellStream",
558
+ create(ShellStreamSchema, {
559
+ event: {
560
+ case: "rejected",
561
+ value: create(ShellRejectedSchema, {
562
+ command: args.command ?? "",
563
+ workingDirectory: args.workingDirectory ?? "",
564
+ reason: REJECT_REASON,
565
+ isReadonly: false,
566
+ }),
567
+ },
568
+ }),
569
+ sendFrame,
570
+ );
571
+ return true;
572
+ }
573
+ if (execCase === "backgroundShellSpawnArgs") {
574
+ const args = (execMsg as any).message.value;
575
+ sendExecResult(
576
+ execMsg,
577
+ "backgroundShellSpawnResult",
578
+ create(BackgroundShellSpawnResultSchema, {
579
+ result: {
580
+ case: "rejected",
581
+ value: create(ShellRejectedSchema, {
582
+ command: args.command ?? "",
583
+ workingDirectory: args.workingDirectory ?? "",
584
+ reason: REJECT_REASON,
585
+ isReadonly: false,
586
+ }),
587
+ },
588
+ }),
589
+ sendFrame,
590
+ );
591
+ return true;
592
+ }
593
+ if (execCase === "writeShellStdinArgs") {
594
+ sendExecResult(
595
+ execMsg,
596
+ "writeShellStdinResult",
597
+ create(WriteShellStdinResultSchema, {
598
+ result: {
599
+ case: "error",
600
+ value: create(WriteShellStdinErrorSchema, { error: REJECT_REASON }),
601
+ },
602
+ }),
603
+ sendFrame,
604
+ );
605
+ return true;
606
+ }
607
+ if (execCase === "fetchArgs") {
608
+ const args = (execMsg as any).message.value;
609
+ sendExecResult(
610
+ execMsg,
611
+ "fetchResult",
612
+ create(FetchResultSchema, {
613
+ result: {
614
+ case: "error",
615
+ value: create(FetchErrorSchema, { url: args.url ?? "", error: REJECT_REASON }),
616
+ },
617
+ }),
618
+ sendFrame,
619
+ );
620
+ return true;
621
+ }
622
+ if (execCase === "diagnosticsArgs") {
623
+ sendExecResult(execMsg, "diagnosticsResult", create(DiagnosticsResultSchema, {}), sendFrame);
624
+ return true;
625
+ }
626
+
627
+ if (execCase === "listMcpResourcesExecArgs") {
628
+ sendExecResult(
629
+ execMsg,
630
+ "listMcpResourcesExecResult",
631
+ create(ListMcpResourcesExecResultSchema, {
632
+ result: {
633
+ case: "rejected",
634
+ value: create(ListMcpResourcesRejectedSchema, { reason: REJECT_REASON }),
635
+ },
636
+ }),
637
+ sendFrame,
638
+ );
639
+ return true;
640
+ }
641
+ if (execCase === "readMcpResourceExecArgs") {
642
+ const args = (execMsg as any).message.value;
643
+ sendExecResult(
644
+ execMsg,
645
+ "readMcpResourceExecResult",
646
+ create(ReadMcpResourceExecResultSchema, {
647
+ result: {
648
+ case: "rejected",
649
+ value: create(ReadMcpResourceRejectedSchema, {
650
+ uri: args.uri ?? "",
651
+ reason: REJECT_REASON,
652
+ }),
653
+ },
654
+ }),
655
+ sendFrame,
656
+ );
657
+ return true;
658
+ }
659
+ if (execCase === "recordScreenArgs") {
660
+ sendExecResult(
661
+ execMsg,
662
+ "recordScreenResult",
663
+ create(RecordScreenResultSchema, {
664
+ result: {
665
+ case: "failure",
666
+ value: create(RecordScreenFailureSchema, { error: REJECT_REASON }),
667
+ },
668
+ }),
669
+ sendFrame,
670
+ );
671
+ return true;
672
+ }
673
+ if (execCase === "computerUseArgs") {
674
+ const args = (execMsg as any).message.value;
675
+ sendExecResult(
676
+ execMsg,
677
+ "computerUseResult",
678
+ create(ComputerUseResultSchema, {
679
+ result: {
680
+ case: "error",
681
+ value: create(ComputerUseErrorSchema, {
682
+ error: REJECT_REASON,
683
+ actionCount: Array.isArray(args.actions) ? args.actions.length : 0,
684
+ durationMs: 0,
685
+ }),
686
+ },
687
+ }),
688
+ sendFrame,
689
+ );
690
+ return true;
691
+ }
692
+
693
+ // No result shape is known for an exec case this build has no branch for, and
694
+ // guessing one is unsafe: a fabricated success is indistinguishable from having
695
+ // actually performed a destructive operation. But silence is not the alternative
696
+ // — Cursor parks the run on the unanswered exec id and heartbeats forever.
697
+ // ExecClientThrow answers any exec by id without claiming a result, so the model
698
+ // sees a failed tool instead of a dead stream.
699
+ const unhandledCase = String(execCase ?? "unknown");
700
+ reportCursorAnomaly(
701
+ "unhandled_exec",
702
+ `Cursor unhandled exec answered with a throw (${unhandledCase})`,
703
+ { execCase: unhandledCase },
704
+ { level: "warning", stderrIfNoSink: true },
705
+ );
706
+ lifecycleLog("exec_unknown_shape", {
707
+ execCase: unhandledCase,
708
+ unknownFields: describeUnknownFields(execMsg),
709
+ });
710
+ setLastStreamEvent(`unhandled_exec:${unhandledCase}`);
711
+ sendExecThrow(
712
+ execMsg,
713
+ `Pi's Cursor provider has no handler for exec case "${unhandledCase}" ` +
714
+ `(wire drift: this build's agent.proto is behind Cursor). ${REJECT_REASON}`,
715
+ sendFrame,
716
+ );
717
+ return false;
718
+ }
719
+
720
+ /**
721
+ * Field numbers, wire types and sizes of protobuf fields our schema lacks — the
722
+ * only evidence available for identifying a new Cursor exec case, and safe to
723
+ * keep in the always-on lifecycle log because it carries no payload content.
724
+ */
725
+ function describeUnknownFields(message: unknown): string {
726
+ const unknown = (
727
+ message as { $unknown?: readonly { no: number; wireType: number; data: Uint8Array }[] }
728
+ ).$unknown;
729
+ if (!unknown || unknown.length === 0) return "";
730
+ return unknown
731
+ .map((field) => `${field.no}:wt${field.wireType}:${field.data?.byteLength ?? 0}b`)
732
+ .join(",");
733
+ }
734
+
735
+ /**
736
+ * The one reply that fits every exec: `ExecClientThrow` is keyed by exec id, not
737
+ * by exec case, so it releases a parked run whose request we cannot understand.
738
+ */
739
+ function sendExecThrow(
740
+ execMsg: ExecServerMessage,
741
+ error: string,
742
+ sendFrame: (data: Uint8Array) => void,
743
+ ): void {
744
+ const control = create(ExecClientControlMessageSchema, {
745
+ message: {
746
+ case: "throw",
747
+ value: create(ExecClientThrowSchema, { id: (execMsg as any).id, error }),
748
+ },
749
+ });
750
+ const clientMessage = create(AgentClientMessageSchema, {
751
+ message: { case: "execClientControlMessage", value: control },
752
+ });
753
+ sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
754
+ }
755
+
756
+ function sendExecResult(
757
+ execMsg: ExecServerMessage,
758
+ messageCase: string,
759
+ value: unknown,
760
+ sendFrame: (data: Uint8Array) => void,
761
+ ): void {
762
+ const execClientMessage = create(ExecClientMessageSchema, {
763
+ id: (execMsg as any).id,
764
+ execId: (execMsg as any).execId,
765
+ message: { case: messageCase as any, value: value as any },
766
+ });
767
+ const clientMessage = create(AgentClientMessageSchema, {
768
+ message: { case: "execClientMessage", value: execClientMessage },
769
+ });
770
+ sendFrame(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
771
+ }
772
+
773
+ export const __testInternals = {
774
+ nativeToolRejectReason,
775
+ handleExecMessageInner,
776
+ describeUnknownFields,
777
+ };