@oh-my-pi/pi-ai 17.1.7 → 17.2.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 (62) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/types/auth-broker/server.d.ts +5 -0
  3. package/dist/types/auth-broker/types.d.ts +4 -0
  4. package/dist/types/auth-storage.d.ts +26 -1
  5. package/dist/types/error/classes.d.ts +5 -1
  6. package/dist/types/error/rate-limit.d.ts +5 -4
  7. package/dist/types/providers/anthropic-client.d.ts +13 -1
  8. package/dist/types/providers/anthropic.d.ts +9 -1
  9. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  10. package/dist/types/providers/cursor-pi-args.d.ts +99 -0
  11. package/dist/types/providers/cursor.d.ts +53 -3
  12. package/dist/types/providers/error-message.d.ts +2 -4
  13. package/dist/types/providers/openai-codex-responses.d.ts +10 -0
  14. package/dist/types/providers/openai-responses-wire.d.ts +8 -0
  15. package/dist/types/providers/openai-shared.d.ts +13 -1
  16. package/dist/types/registry/exa.d.ts +8 -0
  17. package/dist/types/registry/registry.d.ts +7 -1
  18. package/dist/types/registry/xai.d.ts +4 -1
  19. package/dist/types/stream.d.ts +2 -0
  20. package/dist/types/types.d.ts +101 -1
  21. package/dist/types/usage/umans.d.ts +2 -0
  22. package/dist/types/utils/block-symbols.d.ts +11 -0
  23. package/dist/types/utils/harmony-leak.d.ts +15 -7
  24. package/dist/types/utils/schema/normalize.d.ts +1 -0
  25. package/dist/types/utils.d.ts +8 -0
  26. package/package.json +4 -4
  27. package/src/auth-broker/client.ts +5 -1
  28. package/src/auth-broker/server.ts +103 -9
  29. package/src/auth-broker/snapshot-cache.ts +12 -3
  30. package/src/auth-broker/types.ts +6 -0
  31. package/src/auth-storage.ts +447 -28
  32. package/src/error/classes.ts +98 -3
  33. package/src/error/flags.ts +6 -1
  34. package/src/error/rate-limit.ts +18 -12
  35. package/src/providers/amazon-bedrock.ts +3 -3
  36. package/src/providers/anthropic-client.ts +24 -2
  37. package/src/providers/anthropic.ts +31 -6
  38. package/src/providers/cursor/exec-modern.ts +495 -0
  39. package/src/providers/cursor/proto/agent.proto +1007 -0
  40. package/src/providers/cursor-pi-args.ts +135 -0
  41. package/src/providers/cursor.ts +1138 -66
  42. package/src/providers/devin.ts +2 -1
  43. package/src/providers/error-message.ts +3 -1
  44. package/src/providers/google-vertex.ts +3 -5
  45. package/src/providers/openai-codex-responses.ts +109 -27
  46. package/src/providers/openai-completions.ts +19 -4
  47. package/src/providers/openai-responses-wire.ts +8 -0
  48. package/src/providers/openai-responses.ts +12 -1
  49. package/src/providers/openai-shared.ts +83 -14
  50. package/src/registry/api-key-validation.ts +18 -34
  51. package/src/registry/exa.ts +19 -0
  52. package/src/registry/novita.ts +6 -3
  53. package/src/registry/registry.ts +3 -1
  54. package/src/registry/xai.ts +17 -1
  55. package/src/stream.ts +3 -4
  56. package/src/types.ts +115 -0
  57. package/src/usage/umans.ts +192 -0
  58. package/src/utils/block-symbols.ts +12 -0
  59. package/src/utils/harmony-leak.ts +32 -0
  60. package/src/utils/idle-iterator.ts +2 -2
  61. package/src/utils/schema/normalize.ts +140 -38
  62. package/src/utils.ts +173 -3
@@ -10,11 +10,18 @@ import {
10
10
  AgentRunRequestSchema,
11
11
  type AgentServerMessage,
12
12
  AgentServerMessageSchema,
13
+ AgentStoreConflictErrorSchema,
14
+ AgentStoreConflictResultSchema,
13
15
  AssistantMessageSchema,
14
16
  BackgroundShellSpawnResultSchema,
17
+ CanvasDiagnosticsErrorSchema,
18
+ CanvasDiagnosticsResultSchema,
15
19
  ClientHeartbeatSchema,
20
+ ComputerUseErrorSchema,
16
21
  ComputerUseResultSchema,
17
22
  ConversationActionSchema,
23
+ ConversationSearchErrorSchema,
24
+ ConversationSearchResultSchema,
18
25
  type ConversationStateStructure,
19
26
  ConversationStateStructureSchema,
20
27
  ConversationStepSchema,
@@ -31,9 +38,14 @@ import {
31
38
  type ExecClientMessage,
32
39
  ExecClientMessageSchema,
33
40
  ExecClientStreamCloseSchema,
41
+ ExecClientThrowSchema,
34
42
  type ExecServerMessage,
35
43
  FetchErrorSchema,
36
44
  FetchResultSchema,
45
+ ForceBackgroundShellResultSchema,
46
+ ForceBackgroundShellStatus,
47
+ ForceBackgroundSubagentResultSchema,
48
+ ForceBackgroundSubagentStatus,
37
49
  GetBlobResultSchema,
38
50
  GrepContentMatchSchema,
39
51
  GrepContentResultSchema,
@@ -49,7 +61,11 @@ import {
49
61
  GrepUnionResultSchema,
50
62
  KvClientMessageSchema,
51
63
  type KvServerMessage,
64
+ ListMcpResourcesErrorSchema,
65
+ type ListMcpResourcesExecResult,
66
+ ListMcpResourcesExecResult_McpResourceSchema,
52
67
  ListMcpResourcesExecResultSchema,
68
+ ListMcpResourcesSuccessSchema,
53
69
  type LsDirectoryTreeNode,
54
70
  type LsDirectoryTreeNode_File,
55
71
  LsDirectoryTreeNode_FileSchema,
@@ -58,8 +74,11 @@ import {
58
74
  LsRejectedSchema,
59
75
  LsResultSchema,
60
76
  LsSuccessSchema,
77
+ McpAllowlistPrecheckResultSchema,
78
+ McpApprovedSchema,
61
79
  McpErrorSchema,
62
80
  McpImageContentSchema,
81
+ McpRejectedSchema,
63
82
  McpResultSchema,
64
83
  McpSuccessSchema,
65
84
  McpTextContentSchema,
@@ -68,10 +87,15 @@ import {
68
87
  McpToolResultContentItemSchema,
69
88
  ModelDetailsSchema,
70
89
  ReadErrorSchema,
90
+ ReadMcpResourceErrorSchema,
91
+ type ReadMcpResourceExecResult,
71
92
  ReadMcpResourceExecResultSchema,
93
+ ReadMcpResourceNotFoundSchema,
94
+ ReadMcpResourceSuccessSchema,
72
95
  ReadRejectedSchema,
73
96
  ReadResultSchema,
74
97
  ReadSuccessSchema,
98
+ RecordScreenFailureSchema,
75
99
  RecordScreenResultSchema,
76
100
  RequestContextResultSchema,
77
101
  RequestContextSchema,
@@ -81,6 +105,7 @@ import {
81
105
  SelectedContextSchema,
82
106
  SelectedImageSchema,
83
107
  SetBlobResultSchema,
108
+ ShellAllowlistPrecheckResultSchema,
84
109
  type ShellArgs,
85
110
  ShellFailureSchema,
86
111
  ShellRejectedSchema,
@@ -93,8 +118,15 @@ import {
93
118
  ShellStreamStderrSchema,
94
119
  ShellStreamStdoutSchema,
95
120
  ShellSuccessSchema,
121
+ SmartModeClassifierErrorSchema,
122
+ SmartModeClassifierResultSchema,
123
+ SubagentAwaitNotFoundSchema,
124
+ SubagentAwaitResultSchema,
125
+ SubagentErrorSchema,
126
+ SubagentResultSchema,
96
127
  UserMessageActionSchema,
97
128
  UserMessageSchema,
129
+ WebFetchAllowlistPrecheckResultSchema,
98
130
  WriteErrorSchema,
99
131
  WriteRejectedSchema,
100
132
  WriteResultSchema,
@@ -142,6 +174,7 @@ import {
142
174
  kCursorExecResolved,
143
175
  kStreamingBlockIndex,
144
176
  kStreamingBlockKind,
177
+ kStreamingEnvelopeId,
145
178
  kStreamingLastParseLen,
146
179
  kStreamingPartialJson,
147
180
  } from "../utils/block-symbols";
@@ -150,12 +183,47 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
150
183
  import { connectProxiedSocket, getProxyForProvider, shouldBypassProxy } from "../utils/proxy";
151
184
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
152
185
  import { toolWireSchema } from "../utils/schema/wire";
186
+ import {
187
+ buildMcpStateResult,
188
+ buildNeutralHookResult,
189
+ buildPiBashError,
190
+ buildPiBashResult,
191
+ buildPiEditError,
192
+ buildPiEditRejected,
193
+ buildPiEditResult,
194
+ buildPiFindError,
195
+ buildPiFindResult,
196
+ buildPiGrepError,
197
+ buildPiGrepResult,
198
+ buildPiLsError,
199
+ buildPiLsResult,
200
+ buildPiReadError,
201
+ buildPiReadResult,
202
+ buildPiWriteError,
203
+ buildPiWriteRejected,
204
+ buildPiWriteResult,
205
+ piEscapeRegexLiteral,
206
+ piGrepSkip,
207
+ piJoinPath,
208
+ piLimit,
209
+ piLsPath,
210
+ piReadDisplayPath,
211
+ piTimeout,
212
+ } from "./cursor/exec-modern";
153
213
 
154
214
  export const CURSOR_API_URL = "https://api2.cursor.sh";
155
- export const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
215
+ export const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b";
156
216
 
157
217
  const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
158
218
 
219
+ /**
220
+ * Text for a recognised frame this client answers with its own typed error
221
+ * variant. Phrased as a client capability statement, not a tool failure: the
222
+ * model reads it and should route around the capability, not retry the call.
223
+ */
224
+ const NOT_IMPLEMENTED_SUFFIX = "not implemented by this client";
225
+ const NOT_IMPLEMENTED = `Not implemented by this client`;
226
+
159
227
  const conversationStateCache = new Map<string, ConversationStateStructure>();
160
228
  const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
161
229
 
@@ -421,6 +489,10 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
421
489
  let h2Settled = false;
422
490
  let sawTurnEnded = false;
423
491
  let endStreamError: Error | null = null;
492
+ // Reachable from the catch: a stream that dies mid-turn must still close
493
+ // and pair the blocks it left open, and `state` itself is scoped to the
494
+ // try below.
495
+ let openBlockState: BlockState | undefined;
424
496
  const settleH2 = (error?: unknown): void => {
425
497
  if (h2Settled) return;
426
498
  h2Settled = true;
@@ -520,6 +592,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
520
592
  get currentToolCall() {
521
593
  return currentToolCall;
522
594
  },
595
+ openToolCalls: new Map<string, ToolCallState>(),
523
596
  resolvedMcpToolCallIds,
524
597
  get firstTokenTime() {
525
598
  return firstTokenTime;
@@ -539,6 +612,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
539
612
  onTodoSnapshot: options?.execHandlers?.todoSync?.bind(options.execHandlers),
540
613
  onToolResult: options?.onToolResult,
541
614
  };
615
+ openBlockState = state;
542
616
 
543
617
  const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => {
544
618
  conversationStateCache.set(conversationId, checkpoint);
@@ -677,17 +751,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
677
751
 
678
752
  endCurrentTextBlock(output, stream, state);
679
753
  endCurrentThinkingBlock(output, stream, state);
680
- if (state.currentToolCall) {
681
- const idx = output.content.indexOf(state.currentToolCall);
682
- state.currentToolCall.arguments = parseStreamingJson(state.currentToolCall[kStreamingPartialJson]);
683
- clearStreamingPartialJson(state.currentToolCall);
684
- stream.push({
685
- type: "toolcall_end",
686
- contentIndex: idx,
687
- toolCall: state.currentToolCall,
688
- partial: output,
689
- });
690
- }
754
+ flushOpenToolCalls(output, stream, state);
691
755
 
692
756
  calculateCost(model, output.usage);
693
757
 
@@ -708,6 +772,19 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
708
772
  // (handlers have no cancellation contract and must not delay the
709
773
  // terminal error the user asked for).
710
774
  await drainInFlightDispatches();
775
+ // A stream that dies mid-turn leaves blocks open, and this is the path
776
+ // it takes: `settleH2` rejects when the transport closes without
777
+ // `turnEnded`, so the success-path flush above never runs. Closing
778
+ // them here settles their live cards and pairs the server-owned calls
779
+ // (`connect_scm`, native todo) that nothing else answers — an
780
+ // unpaired call is stripped from every rebuilt transcript.
781
+ // Undefined only when the failure predates the state's construction,
782
+ // in which case no block was ever opened.
783
+ if (openBlockState) {
784
+ endCurrentTextBlock(output, stream, openBlockState);
785
+ endCurrentThinkingBlock(output, stream, openBlockState);
786
+ flushOpenToolCalls(output, stream, openBlockState);
787
+ }
711
788
  const result = await AIError.finalize(error, { api: model.api, signal: options?.signal });
712
789
  output.stopReason = result.stopReason;
713
790
  output.errorStatus = result.status;
@@ -736,7 +813,8 @@ export type ToolCallState = ToolCall & {
736
813
  [kStreamingBlockIndex]: number;
737
814
  [kStreamingPartialJson]?: string;
738
815
  [kStreamingLastParseLen]?: number;
739
- [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec";
816
+ [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec" | "connect-scm";
817
+ [kStreamingEnvelopeId]?: string;
740
818
  [kCursorExecResolved]?: true;
741
819
  };
742
820
 
@@ -744,6 +822,17 @@ export interface BlockState {
744
822
  currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null;
745
823
  currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null;
746
824
  currentToolCall: ToolCallState | null;
825
+ /**
826
+ * Open streamed tool-call blocks, keyed by the interaction envelope's
827
+ * `call_id`.
828
+ *
829
+ * Cursor interleaves calls: two `toolCallStarted` frames can arrive before
830
+ * either completes. A single "current" slot would let the second overwrite
831
+ * the first, orphaning a block that nothing then settles. Every keyed block
832
+ * stays reachable until its own completion, and `currentToolCall` remains
833
+ * only as the fallback for frames that carry no `call_id`.
834
+ */
835
+ openToolCalls: Map<string, ToolCallState>;
747
836
  /** MCP call IDs synthesized from exec frames before their redundant streamed block arrives. */
748
837
  resolvedMcpToolCallIds: Set<string>;
749
838
  firstTokenTime: number | undefined;
@@ -1195,6 +1284,14 @@ async function handleExecServerMessage(
1195
1284
  }
1196
1285
 
1197
1286
  if (!execCase) {
1287
+ // A frame carrying a oneof number this build's `agent.proto` does not
1288
+ // model at all: protobuf decodes it into unknown fields and leaves
1289
+ // `message.case` unset, so the client cannot even name what was asked.
1290
+ // Returning silently strands the exec id — the server waits on a reply
1291
+ // that never comes. Distinct from the `default:` branch below, which
1292
+ // names a frame it recognises but cannot serve.
1293
+ log("warn", "unknownExecVariant", { id: execMsg.id, execId: execMsg.execId });
1294
+ sendExecClientThrow(h2Request, execMsg, "Unknown exec message variant", "unknown_exec_variant");
1198
1295
  return;
1199
1296
  }
1200
1297
 
@@ -1202,12 +1299,22 @@ async function handleExecServerMessage(
1202
1299
  case "readArgs": {
1203
1300
  const args = execMsg.message.value;
1204
1301
  if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1205
- synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
1302
+ // The same composed selector the bridge executes: showing a bare path
1303
+ // for a ranged read makes the returned slice look like the whole
1304
+ // file in every rebuilt transcript.
1305
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", {
1306
+ path: piReadDisplayPath(args.path, args.offset, args.limit),
1307
+ });
1206
1308
  const { execResult } = await resolveExecHandler(
1207
1309
  args,
1208
1310
  execHandlers?.read?.bind(execHandlers),
1209
1311
  onToolResult,
1210
- toolResult => buildReadResultFromToolResult(args.path, toolResult),
1312
+ toolResult =>
1313
+ buildReadResultFromToolResult(
1314
+ args.path,
1315
+ toolResult,
1316
+ args.offset !== undefined || args.limit !== undefined,
1317
+ ),
1211
1318
  reason => buildReadRejectedResult(args.path, reason),
1212
1319
  error => buildReadErrorResult(args.path, error),
1213
1320
  { toolCallId: args.toolCallId, toolName: "read" },
@@ -1256,6 +1363,7 @@ async function handleExecServerMessage(
1256
1363
  pattern: args.pattern,
1257
1364
  path: searchPath,
1258
1365
  case: args.caseInsensitive === true ? false : undefined,
1366
+ skip: piGrepSkip(args.offset),
1259
1367
  });
1260
1368
  const { execResult } = await resolveExecHandler(
1261
1369
  args,
@@ -1418,6 +1526,36 @@ async function handleExecServerMessage(
1418
1526
  case "mcpArgs": {
1419
1527
  const args = execMsg.message.value;
1420
1528
  const mcpCall = decodeMcpCall(args);
1529
+ // An approval probe, not an invocation: the frame asks whether the
1530
+ // call would be permitted. Running the tool to find out fires a side
1531
+ // effect the user has not been asked about, and fires it again when
1532
+ // the real frame follows — so this must answer without executing.
1533
+ //
1534
+ // The host resolves it against the same policy the wrapper applies at
1535
+ // execution time. Only a definite allow is approved: a pending prompt
1536
+ // cannot be asked through this frame, and answering yes on its behalf
1537
+ // would pre-authorize a call the user never saw. Without a handler
1538
+ // there is nothing to decide with, so it is refused. Either way no
1539
+ // block is synthesized — nothing ran.
1540
+ if (mcpCall.approvalOnly) {
1541
+ const approved = (await execHandlers?.mcpApprovalPreflight?.(mcpCall)) === true;
1542
+ sendExecClientMessage(
1543
+ h2Request,
1544
+ execMsg,
1545
+ "mcpResult",
1546
+ create(McpResultSchema, {
1547
+ result: approved
1548
+ ? { case: "approved", value: create(McpApprovedSchema, {}) }
1549
+ : {
1550
+ case: "rejected",
1551
+ value: create(McpRejectedSchema, {
1552
+ reason: `Tool "${mcpCall.toolName || mcpCall.name}" is not approved to run without asking.`,
1553
+ }),
1554
+ },
1555
+ }),
1556
+ );
1557
+ return;
1558
+ }
1421
1559
  if (execHandlers?.mcp) {
1422
1560
  const existingBlock = output.content.find(
1423
1561
  block => block.type === "toolCall" && block.id === mcpCall.toolCallId,
@@ -1449,54 +1587,571 @@ async function handleExecServerMessage(
1449
1587
  return;
1450
1588
  }
1451
1589
  case "listMcpResourcesExecArgs": {
1452
- const execResult = create(ListMcpResourcesExecResultSchema, {});
1590
+ // A host holding live MCP connections answers from them; without a
1591
+ // handler the honest answer is an explicit empty success. An
1592
+ // unset-oneof result would read as "the call produced nothing".
1593
+ const args = execMsg.message.value;
1594
+ let execResult: ListMcpResourcesExecResult;
1595
+ // The model consumes this catalog, so it needs a block and a paired
1596
+ // result or the listing is invisible in the UI and gone from every
1597
+ // rebuilt history. Only synthesized when a handler exists: without
1598
+ // one the frame is a fixed empty answer that executed nothing.
1599
+ const toolCallId = execHandlers?.listMcpResources ? crypto.randomUUID() : undefined;
1600
+ if (toolCallId) {
1601
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "list_mcp_resources", {
1602
+ server: args.server,
1603
+ });
1604
+ }
1605
+ try {
1606
+ const resources = (await execHandlers?.listMcpResources?.({ server: args.server })) ?? [];
1607
+ execResult = create(ListMcpResourcesExecResultSchema, {
1608
+ result: {
1609
+ case: "success",
1610
+ value: create(ListMcpResourcesSuccessSchema, {
1611
+ resources: resources.map(resource =>
1612
+ create(ListMcpResourcesExecResult_McpResourceSchema, {
1613
+ uri: resource.uri,
1614
+ name: resource.name,
1615
+ description: resource.description,
1616
+ mimeType: resource.mimeType,
1617
+ server: resource.server,
1618
+ }),
1619
+ ),
1620
+ }),
1621
+ },
1622
+ });
1623
+ } catch (error) {
1624
+ execResult = create(ListMcpResourcesExecResultSchema, {
1625
+ result: {
1626
+ case: "error",
1627
+ value: create(ListMcpResourcesErrorSchema, {
1628
+ error: error instanceof Error ? error.message : String(error),
1629
+ }),
1630
+ },
1631
+ });
1632
+ }
1633
+ if (toolCallId) {
1634
+ // Derived from the answer that goes on the wire, so the block can
1635
+ // never disagree with what the model was told.
1636
+ const settled = execResult.result;
1637
+ const text =
1638
+ settled.case === "success"
1639
+ ? formatListedMcpResources(settled.value.resources)
1640
+ : settled.case === "error"
1641
+ ? settled.value.error || "Failed to list MCP resources"
1642
+ : (settled.value?.reason ?? "Failed to list MCP resources");
1643
+ await pairSynthesizedExecResult(
1644
+ state,
1645
+ onToolResult,
1646
+ toolCallId,
1647
+ "list_mcp_resources",
1648
+ text,
1649
+ settled.case !== "success",
1650
+ );
1651
+ }
1453
1652
  sendExecClientMessage(h2Request, execMsg, "listMcpResourcesExecResult", execResult);
1454
1653
  return;
1455
1654
  }
1456
1655
  case "readMcpResourceExecArgs": {
1457
- const execResult = create(ReadMcpResourceExecResultSchema, {});
1656
+ const args = execMsg.message.value;
1657
+ let execResult: ReadMcpResourceExecResult;
1658
+ // The read runs locally, and in download mode it writes a workspace
1659
+ // file — an operation with no transcript block is invisible in the UI
1660
+ // and absent from every rebuilt history. Only synthesized when a
1661
+ // handler exists: without one the frame is a fixed `not_found` that
1662
+ // executed nothing, and a block would claim work that never happened.
1663
+ const toolCallId = execHandlers?.readMcpResource ? crypto.randomUUID() : undefined;
1664
+ if (toolCallId) {
1665
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "read_mcp_resource", {
1666
+ server: args.server,
1667
+ uri: args.uri,
1668
+ download_path: args.downloadPath,
1669
+ });
1670
+ }
1671
+ try {
1672
+ // `null` is the handler's "no such server or uri", which is exactly
1673
+ // `not_found`; a throw is a real failure and must not masquerade as
1674
+ // a missing resource.
1675
+ const content = await execHandlers?.readMcpResource?.({
1676
+ server: args.server,
1677
+ uri: args.uri,
1678
+ downloadPath: args.downloadPath,
1679
+ });
1680
+ execResult = content
1681
+ ? create(ReadMcpResourceExecResultSchema, {
1682
+ result: {
1683
+ case: "success",
1684
+ value: create(ReadMcpResourceSuccessSchema, {
1685
+ uri: content.uri,
1686
+ name: content.name,
1687
+ description: content.description,
1688
+ mimeType: content.mimeType,
1689
+ downloadPath: content.downloadPath,
1690
+ // A download returns no content to the model: the file is
1691
+ // on disk and the path is the answer. Otherwise the wire's
1692
+ // content oneof carries one of the two, text winning when
1693
+ // a host supplies both.
1694
+ content:
1695
+ content.downloadPath !== undefined
1696
+ ? { case: undefined }
1697
+ : content.text !== undefined
1698
+ ? { case: "text", value: content.text }
1699
+ : content.blob !== undefined
1700
+ ? { case: "blob", value: content.blob }
1701
+ : { case: undefined },
1702
+ }),
1703
+ },
1704
+ })
1705
+ : create(ReadMcpResourceExecResultSchema, {
1706
+ result: { case: "notFound", value: create(ReadMcpResourceNotFoundSchema, { uri: args.uri }) },
1707
+ });
1708
+ } catch (error) {
1709
+ execResult = create(ReadMcpResourceExecResultSchema, {
1710
+ result: {
1711
+ case: "error",
1712
+ value: create(ReadMcpResourceErrorSchema, {
1713
+ uri: args.uri,
1714
+ error: error instanceof Error ? error.message : String(error),
1715
+ }),
1716
+ },
1717
+ });
1718
+ }
1719
+ if (toolCallId) {
1720
+ // Derived from the answer that actually goes on the wire, so no exit
1721
+ // can drift out of sync with what the model was told.
1722
+ const settled = execResult.result;
1723
+ let text: string;
1724
+ switch (settled.case) {
1725
+ case "success":
1726
+ text = settled.value.downloadPath
1727
+ ? `Downloaded ${args.uri} to ${settled.value.downloadPath}`
1728
+ : `Read ${args.uri}`;
1729
+ break;
1730
+ case "notFound":
1731
+ text = `No such resource: ${args.uri}`;
1732
+ break;
1733
+ // The wire union carries a refusal variant this client never
1734
+ // builds today — the handler answers content or `null`. Handled
1735
+ // anyway so the switch stays total: it holds a `reason`, not an
1736
+ // `error`, so a collapsed default would have read `undefined`.
1737
+ case "rejected":
1738
+ text = `Refused: ${settled.value.reason}`;
1739
+ break;
1740
+ default:
1741
+ text = settled.value?.error ?? `Failed to read ${args.uri}`;
1742
+ break;
1743
+ }
1744
+ await pairSynthesizedExecResult(
1745
+ state,
1746
+ onToolResult,
1747
+ toolCallId,
1748
+ "read_mcp_resource",
1749
+ text,
1750
+ settled.case !== "success",
1751
+ );
1752
+ }
1458
1753
  sendExecClientMessage(h2Request, execMsg, "readMcpResourceExecResult", execResult);
1459
1754
  return;
1460
1755
  }
1461
1756
  case "recordScreenArgs": {
1462
- const execResult = create(RecordScreenResultSchema, {});
1757
+ const execResult = create(RecordScreenResultSchema, {
1758
+ result: { case: "failure", value: create(RecordScreenFailureSchema, { error: NOT_IMPLEMENTED }) },
1759
+ });
1463
1760
  sendExecClientMessage(h2Request, execMsg, "recordScreenResult", execResult);
1464
1761
  return;
1465
1762
  }
1466
1763
  case "computerUseArgs": {
1467
- const execResult = create(ComputerUseResultSchema, {});
1764
+ const execResult = create(ComputerUseResultSchema, {
1765
+ result: { case: "error", value: create(ComputerUseErrorSchema, { error: NOT_IMPLEMENTED }) },
1766
+ });
1468
1767
  sendExecClientMessage(h2Request, execMsg, "computerUseResult", execResult);
1469
1768
  return;
1470
1769
  }
1471
- default: {
1472
- log("warn", "unhandledExecMessage", { execCase });
1473
- // Send a bare ExecClientMessage (id + execId only, no typed result) so the
1474
- // server gets an acknowledgement and doesn't hang waiting forever.
1475
- const ack = create(ExecClientMessageSchema, {
1476
- id: execMsg.id,
1477
- execId: execMsg.execId,
1770
+ case "piReadArgs": {
1771
+ const args = execMsg.message.value;
1772
+ const toolCallId = crypto.randomUUID();
1773
+ // The displayed block must show the operation that actually runs: the
1774
+ // bridge composes the same range selector onto the path.
1775
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "read", {
1776
+ path: piReadDisplayPath(args.path, args.offset, args.limit),
1777
+ });
1778
+ const { execResult } = await resolveExecHandler(
1779
+ { args, toolCallId },
1780
+ execHandlers?.piRead?.bind(execHandlers),
1781
+ onToolResult,
1782
+ buildPiReadResult,
1783
+ buildPiReadError,
1784
+ buildPiReadError,
1785
+ { toolCallId, toolName: "read" },
1786
+ );
1787
+ sendExecClientMessage(h2Request, execMsg, "piReadResult", execResult);
1788
+ return;
1789
+ }
1790
+ case "piBashArgs": {
1791
+ const args = execMsg.message.value;
1792
+ const toolCallId = crypto.randomUUID();
1793
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "bash", {
1794
+ command: args.command,
1795
+ timeout: piTimeout(args.timeout),
1796
+ });
1797
+ const { execResult } = await resolveExecHandler(
1798
+ { args, toolCallId },
1799
+ execHandlers?.piBash?.bind(execHandlers),
1800
+ onToolResult,
1801
+ buildPiBashResult,
1802
+ buildPiBashError,
1803
+ buildPiBashError,
1804
+ { toolCallId, toolName: "bash" },
1805
+ );
1806
+ sendExecClientMessage(h2Request, execMsg, "piBashResult", execResult);
1807
+ return;
1808
+ }
1809
+ case "piEditArgs": {
1810
+ const args = execMsg.message.value;
1811
+ const toolCallId = crypto.randomUUID();
1812
+ // `PiEditReplacement` is the local `edit` tool's replace mode verbatim:
1813
+ // snake_case `old_text`/`new_text` entries against one path.
1814
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "edit", {
1815
+ path: args.path,
1816
+ edits: args.edits.map(edit => ({ old_text: edit.oldText, new_text: edit.newText })),
1817
+ });
1818
+ const { execResult } = await resolveExecHandler(
1819
+ { args, toolCallId },
1820
+ execHandlers?.piEdit?.bind(execHandlers),
1821
+ onToolResult,
1822
+ buildPiEditResult,
1823
+ buildPiEditRejected,
1824
+ buildPiEditError,
1825
+ { toolCallId, toolName: "edit" },
1826
+ );
1827
+ sendExecClientMessage(h2Request, execMsg, "piEditResult", execResult);
1828
+ return;
1829
+ }
1830
+ case "piWriteArgs": {
1831
+ const args = execMsg.message.value;
1832
+ const toolCallId = crypto.randomUUID();
1833
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "write", {
1834
+ path: args.path,
1835
+ content: args.content,
1836
+ });
1837
+ const { execResult } = await resolveExecHandler(
1838
+ { args, toolCallId },
1839
+ execHandlers?.piWrite?.bind(execHandlers),
1840
+ onToolResult,
1841
+ buildPiWriteResult,
1842
+ buildPiWriteRejected,
1843
+ buildPiWriteError,
1844
+ { toolCallId, toolName: "write" },
1845
+ );
1846
+ sendExecClientMessage(h2Request, execMsg, "piWriteResult", execResult);
1847
+ return;
1848
+ }
1849
+ case "piGrepArgs": {
1850
+ const args = execMsg.message.value;
1851
+ const toolCallId = crypto.randomUUID();
1852
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "grep", {
1853
+ pattern: args.literal === true ? piEscapeRegexLiteral(args.pattern) : args.pattern,
1854
+ path: args.glob ? piJoinPath(args.path, args.glob) : args.path || ".",
1855
+ case: args.ignoreCase === true ? false : undefined,
1856
+ // Neither field exists in the model-facing `grep` schema — the bridge
1857
+ // serves them by building a scoped tool instead. Recorded anyway, for
1858
+ // the same reason `pi_read` renders its range into the displayed path:
1859
+ // a capped or context-widened search is otherwise replayed as an
1860
+ // ordinary grep sitting next to output no ordinary grep produces.
1861
+ context: args.context,
1862
+ limit: piLimit(args.limit),
1863
+ });
1864
+ const { execResult } = await resolveExecHandler(
1865
+ { args, toolCallId },
1866
+ execHandlers?.piGrep?.bind(execHandlers),
1867
+ onToolResult,
1868
+ buildPiGrepResult,
1869
+ buildPiGrepError,
1870
+ buildPiGrepError,
1871
+ { toolCallId, toolName: "grep" },
1872
+ );
1873
+ sendExecClientMessage(h2Request, execMsg, "piGrepResult", execResult);
1874
+ return;
1875
+ }
1876
+ case "piFindArgs": {
1877
+ const args = execMsg.message.value;
1878
+ const toolCallId = crypto.randomUUID();
1879
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "glob", {
1880
+ path: piJoinPath(args.path, args.pattern),
1881
+ limit: piLimit(args.limit),
1882
+ });
1883
+ const { execResult } = await resolveExecHandler(
1884
+ { args, toolCallId },
1885
+ execHandlers?.piFind?.bind(execHandlers),
1886
+ onToolResult,
1887
+ buildPiFindResult,
1888
+ buildPiFindError,
1889
+ buildPiFindError,
1890
+ { toolCallId, toolName: "glob" },
1891
+ );
1892
+ sendExecClientMessage(h2Request, execMsg, "piFindResult", execResult);
1893
+ return;
1894
+ }
1895
+ case "piLsArgs": {
1896
+ const args = execMsg.message.value;
1897
+ const toolCallId = crypto.randomUUID();
1898
+ // Same mapping as the legacy `lsArgs` frame: the local `read` tool lists
1899
+ // directories, so the synthesized block must name `read` to match the
1900
+ // bridge's own `toolResult`.
1901
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "read", { path: piLsPath(args.path) });
1902
+ const { execResult } = await resolveExecHandler(
1903
+ { args, toolCallId },
1904
+ execHandlers?.piLs?.bind(execHandlers),
1905
+ onToolResult,
1906
+ buildPiLsResult,
1907
+ buildPiLsError,
1908
+ buildPiLsError,
1909
+ { toolCallId, toolName: "read" },
1910
+ );
1911
+ sendExecClientMessage(h2Request, execMsg, "piLsResult", execResult);
1912
+ return;
1913
+ }
1914
+ case "miniSweAgentBashArgs": {
1915
+ // Same `ShellArgs`/`ShellResult` pair as `shellArgs`, under its own frame
1916
+ // number, so the existing shell handler answers it unchanged.
1917
+ const args = execMsg.message.value;
1918
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1919
+ const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() };
1920
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1921
+ command: args.command,
1922
+ cwd: args.workingDirectory || undefined,
1923
+ timeout: args.timeout && args.timeout > 0 ? args.timeout : undefined,
1924
+ });
1925
+ const { execResult } = await resolveExecHandler(
1926
+ normalizedArgs,
1927
+ execHandlers?.shell?.bind(execHandlers),
1928
+ onToolResult,
1929
+ toolResult => buildShellResultFromToolResult(normalizedArgs, toolResult),
1930
+ reason => buildShellRejectedResult(normalizedArgs.command, normalizedArgs.workingDirectory, reason),
1931
+ error => buildShellFailureResult(normalizedArgs.command, normalizedArgs.workingDirectory, error),
1932
+ { toolCallId: args.toolCallId, toolName: "bash" },
1933
+ );
1934
+ sendExecClientMessage(h2Request, execMsg, "miniSweAgentBashResult", sanitizeShellExecResult(execResult));
1935
+ return;
1936
+ }
1937
+ case "redactedReadArgs": {
1938
+ // Same `ReadArgs`/`ReadResult` pair as `readArgs`, but the server expects
1939
+ // the client to strip secrets from the content first. No redaction is
1940
+ // implemented here, and serving a plain read would hand back exactly the
1941
+ // unredacted bytes the frame exists to withhold.
1942
+ const args = execMsg.message.value;
1943
+ sendExecClientMessage(
1944
+ h2Request,
1945
+ execMsg,
1946
+ "redactedReadResult",
1947
+ buildReadErrorResult(args.path, "Secret redaction is not implemented by this client"),
1948
+ );
1949
+ return;
1950
+ }
1951
+ case "mcpStateExecArgs": {
1952
+ const args = execMsg.message.value;
1953
+ sendExecClientMessage(
1954
+ h2Request,
1955
+ execMsg,
1956
+ "mcpStateExecResult",
1957
+ buildMcpStateResult(requestContextTools, args.serverIdentifiers),
1958
+ );
1959
+ return;
1960
+ }
1961
+ case "executeHookArgs": {
1962
+ const args = execMsg.message.value;
1963
+ const execResult = buildNeutralHookResult(args.request);
1964
+ if (!execResult) {
1965
+ sendExecClientThrow(
1966
+ h2Request,
1967
+ execMsg,
1968
+ `Unsupported hook request: ${args.request?.request.case ?? "unset"}`,
1969
+ "unknown_hook_request",
1970
+ );
1971
+ return;
1972
+ }
1973
+ sendExecClientMessage(h2Request, execMsg, "executeHookResult", execResult);
1974
+ return;
1975
+ }
1976
+ case "subagentArgs": {
1977
+ const args = execMsg.message.value;
1978
+ const execResult = create(SubagentResultSchema, {
1979
+ result: {
1980
+ case: "error",
1981
+ value: create(SubagentErrorSchema, { error: `Subagents are ${NOT_IMPLEMENTED_SUFFIX}` }),
1982
+ },
1983
+ });
1984
+ log("exec", "subagentRejected", { subagentType: args.subagentType });
1985
+ sendExecClientMessage(h2Request, execMsg, "subagentResult", execResult);
1986
+ return;
1987
+ }
1988
+ case "subagentAwaitArgs": {
1989
+ // No subagent was ever spawned, so every awaited id is genuinely unknown.
1990
+ const args = execMsg.message.value;
1991
+ const execResult = create(SubagentAwaitResultSchema, {
1992
+ result: {
1993
+ case: "notFound",
1994
+ value: create(SubagentAwaitNotFoundSchema, { agentId: args.agentId }),
1995
+ },
1996
+ });
1997
+ sendExecClientMessage(h2Request, execMsg, "subagentAwaitResult", execResult);
1998
+ return;
1999
+ }
2000
+ case "forceBackgroundShellArgs": {
2001
+ // Backgrounding targets a running tool call by id. This client runs every
2002
+ // shell to completion in band, so there is never one to move.
2003
+ const execResult = create(ForceBackgroundShellResultSchema, {
2004
+ status: ForceBackgroundShellStatus.NOT_FOUND,
2005
+ });
2006
+ sendExecClientMessage(h2Request, execMsg, "forceBackgroundShellResult", execResult);
2007
+ return;
2008
+ }
2009
+ case "forceBackgroundSubagentArgs": {
2010
+ const execResult = create(ForceBackgroundSubagentResultSchema, {
2011
+ status: ForceBackgroundSubagentStatus.NOT_FOUND,
2012
+ });
2013
+ sendExecClientMessage(h2Request, execMsg, "forceBackgroundSubagentResult", execResult);
2014
+ return;
2015
+ }
2016
+ case "smartModeClassifierArgs": {
2017
+ // The classifier decides whether a risky action needs approval. Answering
2018
+ // `ALLOW` would silently wave through actions the server asked us to
2019
+ // judge, so the honest answer is that no classifier exists here.
2020
+ const execResult = create(SmartModeClassifierResultSchema, {
2021
+ result: {
2022
+ case: "error",
2023
+ value: create(SmartModeClassifierErrorSchema, {
2024
+ error: `Smart-mode classification is ${NOT_IMPLEMENTED_SUFFIX}`,
2025
+ }),
2026
+ },
2027
+ });
2028
+ sendExecClientMessage(h2Request, execMsg, "smartModeClassifierResult", execResult);
2029
+ return;
2030
+ }
2031
+ case "canvasDiagnosticsArgs": {
2032
+ const args = execMsg.message.value;
2033
+ const execResult = create(CanvasDiagnosticsResultSchema, {
2034
+ result: {
2035
+ case: "error",
2036
+ value: create(CanvasDiagnosticsErrorSchema, {
2037
+ path: args.path,
2038
+ error: `Canvas diagnostics are ${NOT_IMPLEMENTED_SUFFIX}`,
2039
+ }),
2040
+ },
2041
+ });
2042
+ sendExecClientMessage(h2Request, execMsg, "canvasDiagnosticsResult", execResult);
2043
+ return;
2044
+ }
2045
+ case "shellAllowlistPrecheckArgs": {
2046
+ // The prechecks ask "is this pre-approved, so may it skip the approval
2047
+ // prompt?". This client keeps no allowlist, so the answer is always no:
2048
+ // `false` costs an approval round-trip, `true` would grant one that was
2049
+ // never configured.
2050
+ sendExecClientMessage(
2051
+ h2Request,
2052
+ execMsg,
2053
+ "shellAllowlistPrecheckResult",
2054
+ create(ShellAllowlistPrecheckResultSchema, { allowlisted: false }),
2055
+ );
2056
+ return;
2057
+ }
2058
+ case "mcpAllowlistPrecheckArgs": {
2059
+ sendExecClientMessage(
2060
+ h2Request,
2061
+ execMsg,
2062
+ "mcpAllowlistPrecheckResult",
2063
+ create(McpAllowlistPrecheckResultSchema, { allowlisted: false }),
2064
+ );
2065
+ return;
2066
+ }
2067
+ case "webFetchAllowlistPrecheckArgs": {
2068
+ sendExecClientMessage(
2069
+ h2Request,
2070
+ execMsg,
2071
+ "webFetchAllowlistPrecheckResult",
2072
+ create(WebFetchAllowlistPrecheckResultSchema, { allowlisted: false }),
2073
+ );
2074
+ return;
2075
+ }
2076
+ case "conversationSearchArgs": {
2077
+ // Cursor conversation history lives server-side; this client keeps no
2078
+ // local index of it to search.
2079
+ //
2080
+ // The streamed `search_conversations_tool_call` envelope announces this
2081
+ // call but the interaction decoder builds no block for it, so the block
2082
+ // and its paired result are synthesized here — exactly like every other
2083
+ // exec frame. Without the pair, `buildSessionContext` strips the whole
2084
+ // interaction on replay. The frame carries its own `tool_call_id`, so
2085
+ // the streamed announcement and this block agree on the key.
2086
+ const args = execMsg.message.value;
2087
+ const toolCallId = args.toolCallId || crypto.randomUUID();
2088
+ const error = `Conversation search is ${NOT_IMPLEMENTED_SUFFIX}`;
2089
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "search_conversations", {
2090
+ query: args.query,
2091
+ limit: args.limit,
2092
+ });
2093
+ await pairSynthesizedExecResult(state, onToolResult, toolCallId, "search_conversations", error);
2094
+ const execResult = create(ConversationSearchResultSchema, {
2095
+ result: { case: "error", value: create(ConversationSearchErrorSchema, { error }) },
1478
2096
  });
1479
- const clientMessage = create(AgentClientMessageSchema, {
1480
- message: { case: "execClientMessage", value: ack },
2097
+ sendExecClientMessage(h2Request, execMsg, "conversationSearchResult", execResult);
2098
+ return;
2099
+ }
2100
+ case "agentStoreConflictArgs": {
2101
+ // The agent store is Cursor's own on-disk journal; this client never
2102
+ // writes one, so it has no conflict events to replay.
2103
+ const execResult = create(AgentStoreConflictResultSchema, {
2104
+ result: {
2105
+ case: "error",
2106
+ value: create(AgentStoreConflictErrorSchema, {
2107
+ error: `Agent store conflicts are ${NOT_IMPLEMENTED_SUFFIX}`,
2108
+ }),
2109
+ },
1481
2110
  });
1482
- h2Request.write(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
2111
+ sendExecClientMessage(h2Request, execMsg, "agentStoreConflictResult", execResult);
2112
+ return;
2113
+ }
2114
+ case "gitDiffRequest": {
2115
+ // `GetDiffResponse` has no error variant: it models five output formats
2116
+ // plus before/after file contents and nothing else. Any in-band answer is
2117
+ // therefore a claim that a diff was computed, so a `throw` is the only
2118
+ // truthful reply.
2119
+ sendExecClientThrow(h2Request, execMsg, `Git diff is ${NOT_IMPLEMENTED_SUFFIX}`, "exec_variant_unsupported");
2120
+ return;
2121
+ }
2122
+ default: {
2123
+ // A frame number this build recognises structurally but has no answer
2124
+ // for. Distinct from the unset-case path above: there the client cannot
2125
+ // even name the frame.
2126
+ log("warn", "unhandledExecMessage", { execCase });
2127
+ sendExecClientThrow(
2128
+ h2Request,
2129
+ execMsg,
2130
+ `No handler for exec message of type ${execCase}`,
2131
+ "exec_variant_unsupported",
2132
+ );
1483
2133
  }
1484
2134
  }
1485
2135
  }
1486
2136
 
1487
- function sendExecClientMessage<T>(
2137
+ /**
2138
+ * Send one typed answer on the exec channel.
2139
+ *
2140
+ * `ExecClientMessage["message"]` is a discriminated union pairing each case
2141
+ * with its own result type, so the generic is keyed on the case: passing a
2142
+ * `ReadResult` under `"shellResult"` is a compile error rather than a wire
2143
+ * message the server rejects at runtime.
2144
+ */
2145
+ function sendExecClientMessage<TCase extends NonNullable<ExecClientMessage["message"]["case"]>>(
1488
2146
  h2Request: http2.ClientHttp2Stream,
1489
2147
  execMsg: ExecServerMessage,
1490
- messageCase: ExecClientMessage["message"]["case"],
1491
- value: T,
2148
+ messageCase: TCase,
2149
+ value: Extract<ExecClientMessage["message"], { case: TCase }>["value"],
1492
2150
  ): void {
1493
2151
  const execClientMessage = create(ExecClientMessageSchema, {
1494
2152
  id: execMsg.id,
1495
2153
  execId: execMsg.execId,
1496
- message: {
1497
- case: messageCase,
1498
- value: value as any,
1499
- },
2154
+ message: { case: messageCase, value } as ExecClientMessage["message"],
1500
2155
  });
1501
2156
 
1502
2157
  const clientMessage = create(AgentClientMessageSchema, {
@@ -1509,6 +2164,42 @@ function sendExecClientMessage<T>(
1509
2164
  log("execClientMessage", messageCase, value);
1510
2165
  }
1511
2166
 
2167
+ /**
2168
+ * Fail one exec frame in band.
2169
+ *
2170
+ * `ExecClientThrow` is the protocol's failure channel for a frame that cannot
2171
+ * be answered at all — as opposed to a frame answered with its own typed error
2172
+ * variant, which means "the tool ran and failed". Cursor's own executor sends
2173
+ * exactly this for a frame no handler claims
2174
+ * (`agent-exec/dist/index.js`: `No handler found for server message of type …`
2175
+ * → `case: 'throw'` then `streamClose`), so the server already knows how to
2176
+ * recover from it: it surfaces the error to the model instead of blocking on a
2177
+ * reply that never comes.
2178
+ *
2179
+ * The alternative this replaces — writing an `ExecClientMessage` whose `message`
2180
+ * oneof is unset — is not a valid answer: the server sees a reply carrying no
2181
+ * result and cannot tell it apart from a malformed frame.
2182
+ */
2183
+ function sendExecClientThrow(
2184
+ h2Request: http2.ClientHttp2Stream,
2185
+ execMsg: ExecServerMessage,
2186
+ error: string,
2187
+ errorCode?: string,
2188
+ ): void {
2189
+ const controlMessage = create(ExecClientControlMessageSchema, {
2190
+ message: {
2191
+ case: "throw",
2192
+ value: create(ExecClientThrowSchema, { id: execMsg.id, error, errorCode }),
2193
+ },
2194
+ });
2195
+ const clientMessage = create(AgentClientMessageSchema, {
2196
+ message: { case: "execClientControlMessage", value: controlMessage },
2197
+ });
2198
+ h2Request.write(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
2199
+ log("execClientControl", "throw", { id: execMsg.id, execId: execMsg.execId, error, errorCode });
2200
+ sendExecClientStreamClose(h2Request, execMsg);
2201
+ }
2202
+
1512
2203
  function sendExecClientStreamClose(h2Request: http2.ClientHttp2Stream, execMsg: ExecServerMessage): void {
1513
2204
  const closeMessage = create(ExecClientControlMessageSchema, {
1514
2205
  message: {
@@ -1696,6 +2387,27 @@ function toolResultToText(toolResult: ToolResultMessage): string {
1696
2387
  return toolResult.content.map(item => (item.type === "text" ? item.text : `[${item.mimeType} image]`)).join("\n");
1697
2388
  }
1698
2389
 
2390
+ /**
2391
+ * The catalog as the paired transcript result records it.
2392
+ *
2393
+ * Cursor receives every resource's identity on the wire, but rebuilt history is
2394
+ * serialized from this local result — so recording only a count leaves the
2395
+ * model, one reload later, aware that it once saw N resources and unable to
2396
+ * name any of them. The URI is what a follow-up `read_mcp_resource` needs, so
2397
+ * it leads; name and mime type follow only when the server supplied them.
2398
+ */
2399
+ function formatListedMcpResources(
2400
+ resources: { uri: string; name?: string; mimeType?: string; server?: string }[],
2401
+ ): string {
2402
+ if (resources.length === 0) return "No MCP resources available";
2403
+ const lines = resources.map(resource => {
2404
+ const qualifiers = [resource.name, resource.mimeType].filter(part => !!part).join(", ");
2405
+ const server = resource.server ? `[${resource.server}] ` : "";
2406
+ return qualifiers ? `- ${server}${resource.uri} (${qualifiers})` : `- ${server}${resource.uri}`;
2407
+ });
2408
+ return [`Listed ${resources.length} MCP resource(s):`, ...lines].join("\n");
2409
+ }
2410
+
1699
2411
  function toolResultWasTruncated(toolResult: ToolResultMessage): boolean {
1700
2412
  if (!toolResult.details || typeof toolResult.details !== "object") {
1701
2413
  return false;
@@ -1712,21 +2424,52 @@ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): bo
1712
2424
  return typeof value === "boolean" ? value : false;
1713
2425
  }
1714
2426
 
1715
- function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessage) {
2427
+ /**
2428
+ * The file's own line count, when the tool recorded one.
2429
+ *
2430
+ * `details.meta.truncation.totalLines` is the whole file; the flat
2431
+ * `details.truncation.totalLines` counts from the window's start line and is
2432
+ * deliberately not consulted here. Absent for a read that returned the file
2433
+ * whole, where the payload IS the file and counting it is exact.
2434
+ */
2435
+ function readTotalLinesFromDetails(toolResult: ToolResultMessage): number | undefined {
2436
+ if (!toolResult.details || typeof toolResult.details !== "object") return undefined;
2437
+ const meta = (toolResult.details as { meta?: { truncation?: { totalLines?: unknown } } }).meta;
2438
+ const totalLines = meta?.truncation?.totalLines;
2439
+ return typeof totalLines === "number" && Number.isFinite(totalLines) ? totalLines : undefined;
2440
+ }
2441
+
2442
+ function readFileSizeFromDetails(toolResult: ToolResultMessage): number | undefined {
2443
+ const details = toolResult.details;
2444
+ if (!details || typeof details !== "object" || !("fileSize" in details)) return undefined;
2445
+ const { fileSize } = details;
2446
+ return typeof fileSize === "number" && Number.isSafeInteger(fileSize) && fileSize >= 0 ? fileSize : undefined;
2447
+ }
2448
+
2449
+ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessage, rangeApplied = false) {
1716
2450
  const text = toolResultToText(toolResult);
1717
2451
  if (toolResult.isError) {
1718
2452
  return buildReadErrorResult(path, text || "Read failed");
1719
2453
  }
1720
- const totalLines = text ? text.split("\n").length : 0;
2454
+ // Counting the payload is only the file's length when the payload is the
2455
+ // whole file. Under a composed window it is the window's, and answering a
2456
+ // 20-line page of a 100-line file with `total_lines: 20` tells a paginating
2457
+ // server it has reached the end.
2458
+ const totalLines = readTotalLinesFromDetails(toolResult) ?? (text ? text.split("\n").length : 0);
1721
2459
  return create(ReadResultSchema, {
1722
2460
  result: {
1723
2461
  case: "success",
1724
2462
  value: create(ReadSuccessSchema, {
1725
2463
  path,
1726
2464
  totalLines,
1727
- fileSize: BigInt(Buffer.byteLength(text, "utf-8")),
2465
+ fileSize: BigInt(readFileSizeFromDetails(toolResult) ?? Buffer.byteLength(text, "utf-8")),
1728
2466
  truncated: toolResultWasTruncated(toolResult),
1729
2467
  output: { case: "content", value: text },
2468
+ // Set when this client composed the frame's window onto the read,
2469
+ // left false when it read the file whole. The proto names the
2470
+ // field but nothing here pins the server's use of it, so the only
2471
+ // safe contract is that it describes what we actually did.
2472
+ rangeApplied,
1730
2473
  }),
1731
2474
  },
1732
2475
  });
@@ -1952,7 +2695,7 @@ function buildLsRejectedResult(path: string, reason: string) {
1952
2695
  }
1953
2696
 
1954
2697
  function buildGrepResultFromToolResult(
1955
- args: { pattern: string; path?: string; outputMode?: string },
2698
+ args: { pattern: string; path?: string; outputMode?: string; offset?: number },
1956
2699
  toolResult: ToolResultMessage,
1957
2700
  ) {
1958
2701
  const text = toolResultToText(toolResult);
@@ -1980,6 +2723,11 @@ function buildGrepResultFromToolResult(
1980
2723
  totalFiles: files.length,
1981
2724
  clientTruncated,
1982
2725
  ripgrepTruncated: false,
2726
+ // Echoes the offset this client actually applied; absent when
2727
+ // the frame requested none. The proto names the field but
2728
+ // nothing here pins the server's use of it, so it reports what
2729
+ // we did rather than asserting a pagination protocol.
2730
+ offsetApplied: args.offset,
1983
2731
  }),
1984
2732
  },
1985
2733
  });
@@ -2008,6 +2756,7 @@ function buildGrepResultFromToolResult(
2008
2756
  totalMatches,
2009
2757
  clientTruncated,
2010
2758
  ripgrepTruncated: false,
2759
+ offsetApplied: args.offset,
2011
2760
  }),
2012
2761
  },
2013
2762
  });
@@ -2055,6 +2804,7 @@ function buildGrepResultFromToolResult(
2055
2804
  totalMatchedLines,
2056
2805
  clientTruncated,
2057
2806
  ripgrepTruncated: false,
2807
+ offsetApplied: args.offset,
2058
2808
  }),
2059
2809
  },
2060
2810
  });
@@ -2184,6 +2934,7 @@ function decodeMcpCall(args: {
2184
2934
  toolCallId: string;
2185
2935
  providerIdentifier: string;
2186
2936
  toolName: string;
2937
+ smartModeApprovalOnly?: boolean;
2187
2938
  }): CursorMcpCall {
2188
2939
  const decodedArgs: Record<string, unknown> = {};
2189
2940
  for (const [key, value] of Object.entries(args.args ?? {})) {
@@ -2196,6 +2947,7 @@ function decodeMcpCall(args: {
2196
2947
  toolCallId: args.toolCallId,
2197
2948
  args: decodedArgs,
2198
2949
  rawArgs: args.args ?? {},
2950
+ approvalOnly: args.smartModeApprovalOnly === true,
2199
2951
  };
2200
2952
  }
2201
2953
 
@@ -2304,6 +3056,205 @@ function selectMcpCall(toolCall: CursorMcpToolCallCarrier | undefined): CursorMc
2304
3056
  return toolCall?.mcpToolCall;
2305
3057
  }
2306
3058
 
3059
+ /**
3060
+ * The streamed `ToolCall` variants whose block the exec channel owns.
3061
+ *
3062
+ * Each of these is announced on the interaction stream AND dispatched as its
3063
+ * own `ExecServerMessage` frame — the Pi family (45-51), plus the two MCP
3064
+ * resource frames — so the block is synthesized once, by the exec handler,
3065
+ * which is the side that has the result.
3066
+ *
3067
+ * `connect_scm` is deliberately NOT here: `ExecServerMessage` has no
3068
+ * connect-SCM case at all (field 44 is `git_diff_request`), so nothing on the
3069
+ * exec channel ever answers it and the streamed announcement is the only
3070
+ * signal. `search_conversations` is not here either: frame 53 answers it, but
3071
+ * it carries its own `tool_call_id` on the streamed envelope and pairs there,
3072
+ * so the exec branch does not synthesize a block for it.
3073
+ */
3074
+ const EXEC_OWNED_TOOL_CALL_CASES: ReadonlySet<string> = new Set([
3075
+ "piReadToolCall",
3076
+ "piBashToolCall",
3077
+ "piEditToolCall",
3078
+ "piWriteToolCall",
3079
+ "piGrepToolCall",
3080
+ "piFindToolCall",
3081
+ "piLsToolCall",
3082
+ "listMcpResourcesToolCall",
3083
+ "readMcpResourceToolCall",
3084
+ ]);
3085
+
3086
+ function isExecOwnedToolCall(toolCall: { tool?: { case?: string } } | undefined): boolean {
3087
+ const variant = toolCall?.tool?.case;
3088
+ return variant !== undefined && EXEC_OWNED_TOOL_CALL_CASES.has(variant);
3089
+ }
3090
+
3091
+ /**
3092
+ * Retain a freshly opened streamed tool-call block.
3093
+ *
3094
+ * Keyed by the interaction envelope's `call_id`, which is the only key every
3095
+ * `ToolCall*Update` for that call shares. The block's own `id` is deliberately
3096
+ * not the key: MCP, Pi and connect-SCM blocks are filed under the id carried
3097
+ * inside the call's `args`, because that is what the exec channel pairs its
3098
+ * result under and what the transcript files the visible block under.
3099
+ *
3100
+ * `currentToolCall` is still set, as the fallback for frames that carry no
3101
+ * `call_id` (proto3-optional, and unset on what older builds send).
3102
+ */
3103
+ /**
3104
+ * Close every tool-call block still open when the stream ends.
3105
+ *
3106
+ * Not just the last one started: with interleaved calls several can be open at
3107
+ * once, and an unclosed block leaves its live card animating and its call
3108
+ * unpaired.
3109
+ *
3110
+ * Only blocks fed by a streamed argument buffer get reparsed. Todo,
3111
+ * connect-SCM and MCP-settled frames arrive with complete `arguments` and
3112
+ * never set the partial buffer; `parseStreamingJson(undefined)` returns `{}`,
3113
+ * so reparsing unconditionally would erase the arguments of every such block
3114
+ * caught open by a truncated stream.
3115
+ *
3116
+ * Server-owned blocks are also paired here. `connect-scm` and `todo` are
3117
+ * stamped {@link kCursorExecResolved} the moment they open, so `agent-loop.ts`
3118
+ * synthesizes no placeholder for them and only their `toolCallCompleted` frame
3119
+ * pairs a result. A transport that closes before that frame would leave the
3120
+ * call unpaired, and `buildSessionContext` strips a dangling call from every
3121
+ * rebuilt transcript — the interaction disappears. An interrupted result is
3122
+ * emitted instead.
3123
+ *
3124
+ * MCP blocks are excluded even when resolved: the exec dispatch that marked
3125
+ * them owns their result, and `drainInFlightDispatches` awaits it before this
3126
+ * runs, so pairing here would duplicate one against the same `toolCallId`.
3127
+ */
3128
+ export function flushOpenToolCalls(
3129
+ output: AssistantMessage,
3130
+ stream: AssistantMessageEventStream,
3131
+ state: BlockState,
3132
+ ): void {
3133
+ const openBlocks = new Set<ToolCallState>(state.openToolCalls.values());
3134
+ if (state.currentToolCall) openBlocks.add(state.currentToolCall);
3135
+ for (const block of openBlocks) {
3136
+ const idx = output.content.indexOf(block);
3137
+ const partialJson = block[kStreamingPartialJson];
3138
+ if (partialJson !== undefined) {
3139
+ block.arguments = parseStreamingJson(partialJson);
3140
+ clearStreamingPartialJson(block);
3141
+ }
3142
+ const kind = block[kStreamingBlockKind];
3143
+ if (kind === "connect-scm" || kind === "todo") {
3144
+ state.onToolResult?.({
3145
+ role: "toolResult",
3146
+ toolCallId: block.id,
3147
+ toolName: block.name,
3148
+ content: [{ type: "text", text: "The connection to Cursor closed before this call completed." }],
3149
+ isError: true,
3150
+ timestamp: Date.now(),
3151
+ });
3152
+ }
3153
+ stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output });
3154
+ }
3155
+ state.openToolCalls.clear();
3156
+ state.setToolCall(null);
3157
+ }
3158
+
3159
+ function retainStreamedCall(state: BlockState, block: ToolCallState, envelopeId: string | undefined): void {
3160
+ if (envelopeId) state.openToolCalls.set(envelopeId, block);
3161
+ state.setToolCall(block);
3162
+ }
3163
+
3164
+ /**
3165
+ * The open block a streamed update addresses, or `null` to ignore the update.
3166
+ *
3167
+ * Cursor interleaves calls: `start A, start B, complete A` is legal, so the
3168
+ * update must reach block A even though B opened last. An id naming no open
3169
+ * block is ignored rather than misapplied — settling the wrong block would pair
3170
+ * it with another call's result.
3171
+ *
3172
+ * A missing id falls back to the current block: the correlation key is
3173
+ * optional, and dropping those updates would strand a block stamped
3174
+ * {@link kCursorExecResolved}, which nothing else settles and whose whole
3175
+ * interaction is then stripped from every rebuilt transcript.
3176
+ */
3177
+ function resolveStreamedCall(state: BlockState, envelopeId: string | undefined): ToolCallState | null {
3178
+ if (!envelopeId) return state.currentToolCall;
3179
+ const keyed = state.openToolCalls.get(envelopeId);
3180
+ if (keyed) return keyed;
3181
+ // Blocks opened before this build tracked envelope ids, and blocks opened
3182
+ // from a frame that carried none, are only reachable as `currentToolCall`.
3183
+ const current = state.currentToolCall;
3184
+ return current && current[kStreamingEnvelopeId] === undefined ? current : null;
3185
+ }
3186
+
3187
+ /** Release a settled block from both the keyed map and the current slot. */
3188
+ function releaseStreamedCall(state: BlockState, block: ToolCallState): void {
3189
+ const envelopeId = block[kStreamingEnvelopeId];
3190
+ if (envelopeId) state.openToolCalls.delete(envelopeId);
3191
+ if (state.currentToolCall === block) state.setToolCall(null);
3192
+ }
3193
+
3194
+ interface CursorConnectScmRepository {
3195
+ owner?: string;
3196
+ repo?: string;
3197
+ }
3198
+
3199
+ interface CursorConnectScmCall {
3200
+ args?: {
3201
+ toolCallId?: string;
3202
+ /** `ConnectScmArgs.target` oneof; `github` is its only member today. */
3203
+ target?: { case?: string; value?: { repository?: CursorConnectScmRepository } };
3204
+ github?: { repository?: CursorConnectScmRepository };
3205
+ };
3206
+ /** `ConnectScmResult.result` oneof: `success` | `error` | `rejected`. */
3207
+ result?: { result?: { case?: string; value?: { error?: string; reason?: string } } };
3208
+ }
3209
+
3210
+ interface CursorConnectScmCarrier {
3211
+ tool?: { case?: string; value?: unknown };
3212
+ connectScmToolCall?: CursorConnectScmCall;
3213
+ }
3214
+
3215
+ /**
3216
+ * The streamed `connect_scm_tool_call` variant, if this update carries one.
3217
+ *
3218
+ * Same oneof-vs-flattened handling as {@link selectMcpCall}: a wire-decoded
3219
+ * `ToolCall` exposes its variant as `{ case, value }`, while hand-shaped test
3220
+ * fixtures use the flat property.
3221
+ */
3222
+ function selectConnectScmCall(toolCall: CursorConnectScmCarrier | undefined): CursorConnectScmCall | undefined {
3223
+ const oneof = toolCall?.tool;
3224
+ if (oneof?.case === "connectScmToolCall") return oneof.value as CursorConnectScmCall;
3225
+ return toolCall?.connectScmToolCall;
3226
+ }
3227
+
3228
+ /** The repository a connect-SCM call targets, across the `target` oneof. */
3229
+ function selectConnectScmRepository(call: CursorConnectScmCall | undefined): CursorConnectScmRepository | undefined {
3230
+ const target = call?.args?.target;
3231
+ if (target?.case === "github") return target.value?.repository;
3232
+ return call?.args?.github?.repository;
3233
+ }
3234
+
3235
+ /**
3236
+ * Render a settled `ConnectScmResult` as the text of its paired tool result.
3237
+ *
3238
+ * Returns `isError` because the three outcomes are not interchangeable: only
3239
+ * `success` means the repository was connected, and reporting a rejection as
3240
+ * success would tell the model to proceed against a repo it cannot reach.
3241
+ */
3242
+ function describeConnectScmResult(call: CursorConnectScmCall | undefined): { text: string; isError: boolean } {
3243
+ const result = call?.result?.result;
3244
+ switch (result?.case) {
3245
+ case "success":
3246
+ return { text: "SCM connected", isError: false };
3247
+ case "error":
3248
+ return { text: result.value?.error || "SCM connection failed", isError: true };
3249
+ case "rejected":
3250
+ return { text: result.value?.reason || "SCM connection rejected", isError: true };
3251
+ default:
3252
+ // A completion carrying no result settles the block anyway: it is
3253
+ // stamped resolved, so nothing downstream would ever pair it.
3254
+ return { text: "SCM connection reported no result", isError: true };
3255
+ }
3256
+ }
3257
+
2307
3258
  /**
2308
3259
  * Extract the authoritative full todo list from a completed native todo call.
2309
3260
  *
@@ -2637,6 +3588,42 @@ export function synthesizeCursorExecToolCall(
2637
3588
  stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output });
2638
3589
  }
2639
3590
 
3591
+ /**
3592
+ * Pair a `toolResult` for a synthesized block the client answered itself,
3593
+ * without ever consulting a handler.
3594
+ *
3595
+ * {@link resolveExecHandler} does this for every frame backed by a local tool.
3596
+ * Frames answered from a fixed verdict — no handler, no local execution — still
3597
+ * need the pair for the same reason: the block was stamped
3598
+ * {@link kCursorExecResolved}, so `agent-loop.ts` emits no placeholder for it
3599
+ * and `buildSessionContext` strips an unpaired call, taking the whole
3600
+ * interaction out of every rebuilt transcript.
3601
+ *
3602
+ * `isError` defaults true because most such verdicts are refusals; the MCP
3603
+ * resource frames run locally and can genuinely succeed, and a success filed
3604
+ * as an error would render as a failed call in every rebuilt transcript.
3605
+ */
3606
+ async function pairSynthesizedExecResult(
3607
+ state: BlockState,
3608
+ onToolResult: CursorToolResultHandler | undefined,
3609
+ toolCallId: string,
3610
+ toolName: string,
3611
+ text: string,
3612
+ isError = true,
3613
+ ): Promise<void> {
3614
+ const synthesized: ToolResultMessage = {
3615
+ role: "toolResult",
3616
+ toolCallId,
3617
+ toolName,
3618
+ content: [{ type: "text", text }],
3619
+ isError,
3620
+ timestamp: Date.now(),
3621
+ };
3622
+ const sink = onToolResult ?? state.onToolResult;
3623
+ if (!sink) return;
3624
+ await sink(synthesized);
3625
+ }
3626
+
2640
3627
  /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
2641
3628
  export function processInteractionUpdate(
2642
3629
  update: any,
@@ -2683,6 +3670,43 @@ export function processInteractionUpdate(
2683
3670
  stream.push({ type: "thinking_delta", contentIndex: idx, delta, partial: output });
2684
3671
  } else if (updateCase === "thinkingCompleted") {
2685
3672
  endCurrentThinkingBlock(output, stream, state);
3673
+ } else if (updateCase === "toolCallStarted" && selectConnectScmCall(update.message.value.toolCall)) {
3674
+ // `connect_scm` is resolved entirely server-side and has NO exec frame:
3675
+ // `ExecServerMessage` carries no connect-SCM case (field 44 is
3676
+ // `git_diff_request`), so the streamed pair is the only signal this client
3677
+ // sees. The authoritative outcome rides on the COMPLETION's `result`
3678
+ // oneof, so the block is opened here and settled there — answering now
3679
+ // would persist a verdict before the server has given one.
3680
+ //
3681
+ // Stamped resolved so `agent-loop.ts` runs no local tool for it: there is
3682
+ // no local `connect_scm`, and the completion pairs the result itself.
3683
+ endCurrentTextBlock(output, stream, state);
3684
+ endCurrentThinkingBlock(output, stream, state);
3685
+ const scmCall = selectConnectScmCall(update.message.value.toolCall);
3686
+ const repository = selectConnectScmRepository(scmCall);
3687
+ const block: ToolCallState = {
3688
+ type: "toolCall",
3689
+ id: scmCall?.args?.toolCallId || update.message.value.callId || crypto.randomUUID(),
3690
+ name: "connect_scm",
3691
+ arguments: repository ? { owner: repository.owner, repo: repository.repo } : {},
3692
+ [kStreamingBlockIndex]: output.content.length,
3693
+ [kStreamingBlockKind]: "connect-scm",
3694
+ [kStreamingEnvelopeId]: update.message.value.callId || undefined,
3695
+ [kCursorExecResolved]: true,
3696
+ };
3697
+ output.content.push(block);
3698
+ retainStreamedCall(state, block, update.message.value.callId);
3699
+ stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
3700
+ } else if (updateCase === "toolCallStarted" && isExecOwnedToolCall(update.message.value.toolCall)) {
3701
+ // The exec channel already synthesized this block (and marked it resolved)
3702
+ // when it ran the tool locally, so the streamed announcement must not
3703
+ // create a second one. Modern builds stream a `pi_*_tool_call` envelope
3704
+ // alongside every `ExecServerMessage` 45-51 frame; before this branch the
3705
+ // duplicate was avoided only because the decoder recognised neither, which
3706
+ // would silently start double-rendering the moment a variant was added.
3707
+ endCurrentTextBlock(output, stream, state);
3708
+ endCurrentThinkingBlock(output, stream, state);
3709
+ log("exec", "streamedToolCallOwnedByExec", { case: update.message.value.toolCall?.tool?.case });
2686
3710
  } else if (updateCase === "toolCallStarted") {
2687
3711
  endCurrentTextBlock(output, stream, state);
2688
3712
  endCurrentThinkingBlock(output, stream, state);
@@ -2707,12 +3731,13 @@ export function processInteractionUpdate(
2707
3731
  [kStreamingBlockIndex]: output.content.length,
2708
3732
  [kStreamingPartialJson]: "",
2709
3733
  [kStreamingBlockKind]: "mcp",
3734
+ [kStreamingEnvelopeId]: update.message.value.callId || undefined,
2710
3735
  };
2711
3736
  if (resolvedByExec) {
2712
3737
  markCursorExecResolved(block);
2713
3738
  }
2714
3739
  output.content.push(block);
2715
- state.setToolCall(block);
3740
+ retainStreamedCall(state, block, update.message.value.callId);
2716
3741
  stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
2717
3742
  return;
2718
3743
  }
@@ -2733,54 +3758,92 @@ export function processInteractionUpdate(
2733
3758
  arguments: buildTodoDisplayArgs(toolCall),
2734
3759
  [kStreamingBlockIndex]: output.content.length,
2735
3760
  [kStreamingBlockKind]: "todo",
3761
+ // Only the real envelope id is a correlation key; the minted
3762
+ // fallback below names no frame the server will ever send back.
3763
+ [kStreamingEnvelopeId]: update.message.value.callId || undefined,
2736
3764
  [kCursorExecResolved]: true,
2737
3765
  };
2738
3766
  output.content.push(block);
2739
- state.setToolCall(block);
3767
+ retainStreamedCall(state, block, update.message.value.callId);
2740
3768
  stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
2741
3769
  }
2742
3770
  }
2743
3771
  } else if (updateCase === "toolCallDelta" || updateCase === "partialToolCall") {
2744
- if (state.currentToolCall?.[kStreamingBlockKind] === "mcp") {
3772
+ // Same correlation rule as the completion path below: an argument delta
3773
+ // belonging to a different call must not be appended to this block's
3774
+ // buffer, which would corrupt the JSON both of them parse.
3775
+ const target = resolveStreamedCall(state, update.message.value.callId);
3776
+ if (target?.[kStreamingBlockKind] === "mcp") {
2745
3777
  // Cursor's `args_text_delta` is "aggregated args text so far" per agent.proto: each
2746
3778
  // delta is a cumulative snapshot of the JSON-text args. Strip the prefix we already
2747
3779
  // have to recover the new suffix; fall back to treating the value as an incremental
2748
3780
  // fragment when it doesn't extend the buffer.
2749
3781
  const snapshot: string = update.message.value.argsTextDelta || "";
2750
- const current = state.currentToolCall[kStreamingPartialJson] ?? "";
3782
+ const current = target[kStreamingPartialJson] ?? "";
2751
3783
  const chunk = snapshot.startsWith(current) ? snapshot.slice(current.length) : snapshot;
2752
3784
  if (chunk.length === 0) {
2753
3785
  return;
2754
3786
  }
2755
3787
  const nextBuffer = current + chunk;
2756
- state.currentToolCall[kStreamingPartialJson] = nextBuffer;
3788
+ target[kStreamingPartialJson] = nextBuffer;
2757
3789
  // Throttle mid-stream parses to keep total parse work O(N) instead of O(N²)
2758
3790
  // in the argument-buffer length; the authoritative full parse runs in
2759
3791
  // `toolCallCompleted` (mcp branch) and the fallback end-of-stream path.
2760
- const throttled = parseStreamingJsonThrottled(nextBuffer, state.currentToolCall[kStreamingLastParseLen] ?? 0);
3792
+ const throttled = parseStreamingJsonThrottled(nextBuffer, target[kStreamingLastParseLen] ?? 0);
2761
3793
  if (throttled) {
2762
- state.currentToolCall.arguments = throttled.value;
2763
- state.currentToolCall[kStreamingLastParseLen] = throttled.parsedLen;
3794
+ target.arguments = throttled.value;
3795
+ target[kStreamingLastParseLen] = throttled.parsedLen;
2764
3796
  }
2765
- const idx = output.content.indexOf(state.currentToolCall);
3797
+ const idx = output.content.indexOf(target);
2766
3798
  stream.push({ type: "toolcall_delta", contentIndex: idx, delta: chunk, partial: output });
2767
3799
  }
2768
3800
  } else if (updateCase === "toolCallCompleted") {
2769
- if (state.currentToolCall) {
3801
+ // Correlate on the envelope's `call_id`, NOT the block id: MCP, Pi and SCM
3802
+ // blocks are filed under the id inside the call's `args` (which is what
3803
+ // the exec channel pairs its result under), and that need not equal the
3804
+ // envelope id. Cursor also interleaves calls, so the block this settles
3805
+ // is looked up by id rather than assumed to be the last one opened —
3806
+ // otherwise an unrelated completion closes whichever block is current and
3807
+ // pairs it with the wrong result.
3808
+ const settled = resolveStreamedCall(state, update.message.value.callId);
3809
+ if (settled) {
2770
3810
  const toolCall = update.message.value.toolCall;
2771
- if (state.currentToolCall[kStreamingBlockKind] === "mcp") {
3811
+ if (settled[kStreamingBlockKind] === "mcp") {
2772
3812
  // Authoritative full parse of the accumulated argument buffer; the delta
2773
3813
  // path throttles mid-stream parses, so `arguments` may lag the buffer.
2774
- const partial = state.currentToolCall[kStreamingPartialJson];
3814
+ const partial = settled[kStreamingPartialJson];
2775
3815
  if (partial !== undefined) {
2776
- state.currentToolCall.arguments = parseStreamingJson(partial);
3816
+ settled.arguments = parseStreamingJson(partial);
2777
3817
  }
2778
3818
  const decodedArgs = decodeMcpArgsMap(selectMcpCall(toolCall)?.args?.args);
2779
- state.currentToolCall.arguments = mergeCursorMcpToolCallArgs(
2780
- state.currentToolCall.arguments as Record<string, unknown> | undefined,
3819
+ settled.arguments = mergeCursorMcpToolCallArgs(
3820
+ settled.arguments as Record<string, unknown> | undefined,
2781
3821
  decodedArgs,
2782
3822
  );
2783
- } else if (state.currentToolCall[kStreamingBlockKind] === "todo") {
3823
+ } else if (settled[kStreamingBlockKind] === "connect-scm") {
3824
+ // The authoritative outcome arrives only here, on the completion's
3825
+ // `ConnectScmResult` oneof. The block was stamped resolved at start,
3826
+ // so nothing downstream pairs it: settling is this branch's job, and
3827
+ // a completion with no `toolCall` still settles rather than leaking a
3828
+ // dangling call into every rebuilt transcript.
3829
+ //
3830
+ // Late args are merged too — a start frame may announce the call
3831
+ // before the target repository is known.
3832
+ const scmCall = selectConnectScmCall(toolCall);
3833
+ const repository = selectConnectScmRepository(scmCall);
3834
+ if (repository) {
3835
+ settled.arguments = { owner: repository.owner, repo: repository.repo };
3836
+ }
3837
+ const { text, isError } = describeConnectScmResult(scmCall);
3838
+ state.onToolResult?.({
3839
+ role: "toolResult",
3840
+ toolCallId: settled.id,
3841
+ toolName: "connect_scm",
3842
+ content: [{ type: "text", text }],
3843
+ isError,
3844
+ timestamp: Date.now(),
3845
+ });
3846
+ } else if (settled[kStreamingBlockKind] === "todo") {
2784
3847
  // Only the server's success snapshot is authoritative: the request args
2785
3848
  // may differ from what was actually stored after a merge, and on
2786
3849
  // `UpdateTodosError` nothing was stored at all. No snapshot => leave
@@ -2794,7 +3857,7 @@ export function processInteractionUpdate(
2794
3857
  const snapshot = toolCall ? extractTodoSnapshot(toolCall) : null;
2795
3858
  const error = toolCall ? extractTodoError(toolCall) : null;
2796
3859
  if (snapshot) {
2797
- state.currentToolCall.arguments = { todos: snapshot.todos, merged: snapshot.merged };
3860
+ settled.arguments = { todos: snapshot.todos, merged: snapshot.merged };
2798
3861
  }
2799
3862
  // The host settles EVERY completed native todo call, successful or
2800
3863
  // not: the interactive card only resolves on a matching
@@ -2810,7 +3873,7 @@ export function processInteractionUpdate(
2810
3873
  let persisted: ToolResultMessage | undefined;
2811
3874
  let hostError: string | null = null;
2812
3875
  try {
2813
- persisted = state.onTodoSnapshot?.(snapshot, state.currentToolCall.id, error) ?? undefined;
3876
+ persisted = state.onTodoSnapshot?.(snapshot, settled.id, error) ?? undefined;
2814
3877
  } catch (callbackError) {
2815
3878
  // A throwing host callback (e.g. session persistence failing on
2816
3879
  // disk error) must not leave the resolved block unpaired: the
@@ -2820,14 +3883,12 @@ export function processInteractionUpdate(
2820
3883
  hostError = callbackError instanceof Error ? callbackError.message : String(callbackError);
2821
3884
  log("error", "onTodoSnapshot", { error: hostError });
2822
3885
  }
2823
- state.onToolResult?.(
2824
- persisted ?? buildTodoToolResult(state.currentToolCall.id, snapshot, hostError ?? error),
2825
- );
3886
+ state.onToolResult?.(persisted ?? buildTodoToolResult(settled.id, snapshot, hostError ?? error));
2826
3887
  }
2827
- const idx = output.content.indexOf(state.currentToolCall);
2828
- clearStreamingPartialJson(state.currentToolCall);
2829
- stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: state.currentToolCall, partial: output });
2830
- state.setToolCall(null);
3888
+ const idx = output.content.indexOf(settled);
3889
+ clearStreamingPartialJson(settled);
3890
+ stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: settled, partial: output });
3891
+ releaseStreamedCall(state, settled);
2831
3892
  }
2832
3893
  } else if (updateCase === "turnEnded") {
2833
3894
  output.stopReason = "stop";
@@ -2877,7 +3938,18 @@ function readCursorBlob(blobStore: Map<string, Uint8Array>, blobId: Uint8Array):
2877
3938
  return data;
2878
3939
  }
2879
3940
 
2880
- const CURSOR_NATIVE_TOOL_NAMES = new Set(["bash", "read", "write", "delete", "ls", "grep", "lsp", "todo"]);
3941
+ /**
3942
+ * Local tools Cursor already drives natively over the exec channel, so
3943
+ * advertising them again as MCP tools would give the model two ways to call the
3944
+ * same thing.
3945
+ *
3946
+ * `lsp` is deliberately NOT here. The native `diagnosticsArgs` frame covers
3947
+ * exactly one of the tool's actions (`action: "diagnostics"`); the rest —
3948
+ * `definition`, `references`, `rename`, `code_actions`, `hover`,
3949
+ * `implementation`, `type_definition`, `symbols`, ... — have no native frame at
3950
+ * all, so filtering the whole tool out hid every one of them from the model.
3951
+ */
3952
+ const CURSOR_NATIVE_TOOL_NAMES = new Set(["bash", "read", "write", "delete", "ls", "grep", "todo"]);
2881
3953
 
2882
3954
  export function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] {
2883
3955
  if (!tools || tools.length === 0) {