@linxiraos/pi-ai 1.0.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 (639) hide show
  1. package/CHANGELOG.md +5066 -0
  2. package/README.md +1195 -0
  3. package/dist/types/api-registry.d.ts +30 -0
  4. package/dist/types/auth/sqlite-credential-store.d.ts +117 -0
  5. package/dist/types/auth-broker/client.d.ts +93 -0
  6. package/dist/types/auth-broker/discover.d.ts +39 -0
  7. package/dist/types/auth-broker/index.d.ts +7 -0
  8. package/dist/types/auth-broker/refresher.d.ts +25 -0
  9. package/dist/types/auth-broker/remote-store.d.ts +136 -0
  10. package/dist/types/auth-broker/server.d.ts +48 -0
  11. package/dist/types/auth-broker/snapshot-cache.d.ts +17 -0
  12. package/dist/types/auth-broker/types.d.ts +152 -0
  13. package/dist/types/auth-broker/wire-schema-resource.d.ts +53 -0
  14. package/dist/types/auth-broker/wire-schemas.d.ts +5 -0
  15. package/dist/types/auth-gateway/http.d.ts +56 -0
  16. package/dist/types/auth-gateway/index.d.ts +3 -0
  17. package/dist/types/auth-gateway/server.d.ts +36 -0
  18. package/dist/types/auth-gateway/types.d.ts +127 -0
  19. package/dist/types/auth-retry.d.ts +150 -0
  20. package/dist/types/auth-storage.d.ts +1258 -0
  21. package/dist/types/dialect/anthropic.d.ts +15 -0
  22. package/dist/types/dialect/catalog.d.ts +3 -0
  23. package/dist/types/dialect/coercion.d.ts +23 -0
  24. package/dist/types/dialect/deepseek.d.ts +14 -0
  25. package/dist/types/dialect/demotion.d.ts +30 -0
  26. package/dist/types/dialect/examples.d.ts +18 -0
  27. package/dist/types/dialect/factory.d.ts +3 -0
  28. package/dist/types/dialect/fenced-thinking.d.ts +53 -0
  29. package/dist/types/dialect/gemini.d.ts +17 -0
  30. package/dist/types/dialect/gemma.d.ts +15 -0
  31. package/dist/types/dialect/glm.d.ts +9 -0
  32. package/dist/types/dialect/harmony.d.ts +8 -0
  33. package/dist/types/dialect/hermes.d.ts +9 -0
  34. package/dist/types/dialect/history.d.ts +3 -0
  35. package/dist/types/dialect/index.d.ts +11 -0
  36. package/dist/types/dialect/inventory.d.ts +9 -0
  37. package/dist/types/dialect/kimi.d.ts +14 -0
  38. package/dist/types/dialect/minimax.d.ts +3 -0
  39. package/dist/types/dialect/owned-stream.d.ts +4 -0
  40. package/dist/types/dialect/qwen3.d.ts +9 -0
  41. package/dist/types/dialect/rendering.d.ts +54 -0
  42. package/dist/types/dialect/thinking.d.ts +6 -0
  43. package/dist/types/dialect/types.d.ts +68 -0
  44. package/dist/types/dialect/xml.d.ts +9 -0
  45. package/dist/types/error/abort.d.ts +14 -0
  46. package/dist/types/error/auth-classify.d.ts +20 -0
  47. package/dist/types/error/auth.d.ts +27 -0
  48. package/dist/types/error/aws.d.ts +27 -0
  49. package/dist/types/error/classes.d.ts +106 -0
  50. package/dist/types/error/finalize.d.ts +39 -0
  51. package/dist/types/error/flags.d.ts +94 -0
  52. package/dist/types/error/format.d.ts +20 -0
  53. package/dist/types/error/gateway.d.ts +20 -0
  54. package/dist/types/error/index.d.ts +14 -0
  55. package/dist/types/error/oauth.d.ts +43 -0
  56. package/dist/types/error/provider.d.ts +42 -0
  57. package/dist/types/error/rate-limit.d.ts +80 -0
  58. package/dist/types/error/retryable.d.ts +27 -0
  59. package/dist/types/error/validation.d.ts +32 -0
  60. package/dist/types/index.d.ts +51 -0
  61. package/dist/types/provider-details.d.ts +24 -0
  62. package/dist/types/providers/amazon-bedrock.d.ts +39 -0
  63. package/dist/types/providers/anthropic-client.d.ts +106 -0
  64. package/dist/types/providers/anthropic-messages-server-schema.d.ts +937 -0
  65. package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
  66. package/dist/types/providers/anthropic-wire.d.ts +345 -0
  67. package/dist/types/providers/anthropic.d.ts +261 -0
  68. package/dist/types/providers/aws-credentials.d.ts +48 -0
  69. package/dist/types/providers/aws-eventstream.d.ts +39 -0
  70. package/dist/types/providers/aws-sigv4.d.ts +55 -0
  71. package/dist/types/providers/azure-openai-responses.d.ts +16 -0
  72. package/dist/types/providers/bedrock-mantle.d.ts +13 -0
  73. package/dist/types/providers/claude-code-fingerprint.d.ts +19 -0
  74. package/dist/types/providers/cowork-fetch.d.ts +3 -0
  75. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  76. package/dist/types/providers/cursor-pi-args.d.ts +105 -0
  77. package/dist/types/providers/cursor.d.ts +221 -0
  78. package/dist/types/providers/devin.d.ts +12 -0
  79. package/dist/types/providers/error-message.d.ts +25 -0
  80. package/dist/types/providers/github-copilot-headers.d.ts +40 -0
  81. package/dist/types/providers/gitlab-duo-workflow.d.ts +254 -0
  82. package/dist/types/providers/gitlab-duo.d.ts +27 -0
  83. package/dist/types/providers/google-auth.d.ts +24 -0
  84. package/dist/types/providers/google-gemini-cli.d.ts +120 -0
  85. package/dist/types/providers/google-shared.d.ts +203 -0
  86. package/dist/types/providers/google-types.d.ts +155 -0
  87. package/dist/types/providers/google-vertex.d.ts +7 -0
  88. package/dist/types/providers/google.d.ts +4 -0
  89. package/dist/types/providers/grammar.d.ts +1 -0
  90. package/dist/types/providers/kimi.d.ts +27 -0
  91. package/dist/types/providers/mock.d.ts +179 -0
  92. package/dist/types/providers/ollama.d.ts +8 -0
  93. package/dist/types/providers/openai-anthropic-shim.d.ts +35 -0
  94. package/dist/types/providers/openai-chat-server-schema.d.ts +1311 -0
  95. package/dist/types/providers/openai-chat-server.d.ts +16 -0
  96. package/dist/types/providers/openai-chat-wire.d.ts +669 -0
  97. package/dist/types/providers/openai-codex/request-transformer.d.ts +109 -0
  98. package/dist/types/providers/openai-codex/response-handler.d.ts +26 -0
  99. package/dist/types/providers/openai-codex-responses.d.ts +238 -0
  100. package/dist/types/providers/openai-completions.d.ts +48 -0
  101. package/dist/types/providers/openai-reasoning-fallback.d.ts +25 -0
  102. package/dist/types/providers/openai-responses-server-schema.d.ts +1314 -0
  103. package/dist/types/providers/openai-responses-server.d.ts +17 -0
  104. package/dist/types/providers/openai-responses-wire.d.ts +6099 -0
  105. package/dist/types/providers/openai-responses.d.ts +131 -0
  106. package/dist/types/providers/openai-shared.d.ts +624 -0
  107. package/dist/types/providers/pi-native-client.d.ts +13 -0
  108. package/dist/types/providers/pi-native-server.d.ts +69 -0
  109. package/dist/types/providers/register-builtins.d.ts +37 -0
  110. package/dist/types/providers/synthetic.d.ts +26 -0
  111. package/dist/types/providers/transform-messages.d.ts +32 -0
  112. package/dist/types/providers/vision-guard.d.ts +20 -0
  113. package/dist/types/registry/aiand.d.ts +7 -0
  114. package/dist/types/registry/aimlapi.d.ts +4 -0
  115. package/dist/types/registry/alibaba-coding-plan.d.ts +8 -0
  116. package/dist/types/registry/alibaba-token-plan.d.ts +18 -0
  117. package/dist/types/registry/amazon-bedrock.d.ts +5 -0
  118. package/dist/types/registry/anthropic.d.ts +10 -0
  119. package/dist/types/registry/api-key-login.d.ts +42 -0
  120. package/dist/types/registry/api-key-validation.d.ts +43 -0
  121. package/dist/types/registry/aws.d.ts +13 -0
  122. package/dist/types/registry/azure.d.ts +4 -0
  123. package/dist/types/registry/baseten.d.ts +7 -0
  124. package/dist/types/registry/bedrock-mantle.d.ts +22 -0
  125. package/dist/types/registry/cerebras.d.ts +7 -0
  126. package/dist/types/registry/cloudflare-ai-gateway.d.ts +13 -0
  127. package/dist/types/registry/coreweave.d.ts +7 -0
  128. package/dist/types/registry/cursor.d.ts +7 -0
  129. package/dist/types/registry/deepseek.d.ts +8 -0
  130. package/dist/types/registry/derived.d.ts +5 -0
  131. package/dist/types/registry/devin.d.ts +8 -0
  132. package/dist/types/registry/exa.d.ts +8 -0
  133. package/dist/types/registry/firepass.d.ts +16 -0
  134. package/dist/types/registry/fireworks.d.ts +7 -0
  135. package/dist/types/registry/github-copilot.d.ts +7 -0
  136. package/dist/types/registry/gitlab-duo-workflow.d.ts +10 -0
  137. package/dist/types/registry/gitlab-duo.d.ts +9 -0
  138. package/dist/types/registry/gmi-cloud.d.ts +7 -0
  139. package/dist/types/registry/google-antigravity.d.ts +9 -0
  140. package/dist/types/registry/google-gemini-cli.d.ts +9 -0
  141. package/dist/types/registry/google-vertex.d.ts +5 -0
  142. package/dist/types/registry/google.d.ts +4 -0
  143. package/dist/types/registry/groq.d.ts +4 -0
  144. package/dist/types/registry/huggingface.d.ts +7 -0
  145. package/dist/types/registry/index.d.ts +4 -0
  146. package/dist/types/registry/kagi.d.ts +14 -0
  147. package/dist/types/registry/kilo.d.ts +7 -0
  148. package/dist/types/registry/kimi-code.d.ts +7 -0
  149. package/dist/types/registry/litellm.d.ts +13 -0
  150. package/dist/types/registry/llama-cpp.d.ts +8 -0
  151. package/dist/types/registry/lm-studio.d.ts +8 -0
  152. package/dist/types/registry/meta.d.ts +7 -0
  153. package/dist/types/registry/minimax-code-cn.d.ts +6 -0
  154. package/dist/types/registry/minimax-code.d.ts +6 -0
  155. package/dist/types/registry/minimax.d.ts +4 -0
  156. package/dist/types/registry/mistral.d.ts +4 -0
  157. package/dist/types/registry/moonshot.d.ts +7 -0
  158. package/dist/types/registry/nanogpt.d.ts +7 -0
  159. package/dist/types/registry/novita.d.ts +6 -0
  160. package/dist/types/registry/nvidia.d.ts +7 -0
  161. package/dist/types/registry/oauth/anthropic-constants.d.ts +12 -0
  162. package/dist/types/registry/oauth/anthropic.d.ts +24 -0
  163. package/dist/types/registry/oauth/callback-server.d.ts +74 -0
  164. package/dist/types/registry/oauth/cursor.d.ts +16 -0
  165. package/dist/types/registry/oauth/device-code.d.ts +25 -0
  166. package/dist/types/registry/oauth/devin.d.ts +5 -0
  167. package/dist/types/registry/oauth/github-copilot.d.ts +30 -0
  168. package/dist/types/registry/oauth/gitlab-duo-workflow.d.ts +6 -0
  169. package/dist/types/registry/oauth/gitlab-duo.d.ts +3 -0
  170. package/dist/types/registry/oauth/google-antigravity.d.ts +11 -0
  171. package/dist/types/registry/oauth/google-gemini-cli.d.ts +22 -0
  172. package/dist/types/registry/oauth/google-oauth-shared.d.ts +56 -0
  173. package/dist/types/registry/oauth/index.d.ts +45 -0
  174. package/dist/types/registry/oauth/kimi.d.ts +21 -0
  175. package/dist/types/registry/oauth/minimax-code.d.ts +27 -0
  176. package/dist/types/registry/oauth/openai-codex.d.ts +33 -0
  177. package/dist/types/registry/oauth/opencode.d.ts +18 -0
  178. package/dist/types/registry/oauth/perplexity.d.ts +9 -0
  179. package/dist/types/registry/oauth/pkce.d.ts +8 -0
  180. package/dist/types/registry/oauth/types.d.ts +87 -0
  181. package/dist/types/registry/oauth/wafer.d.ts +1 -0
  182. package/dist/types/registry/oauth/xai-oauth.d.ts +46 -0
  183. package/dist/types/registry/oauth/xiaomi.d.ts +25 -0
  184. package/dist/types/registry/oauth/zai.d.ts +25 -0
  185. package/dist/types/registry/ollama-cloud.d.ts +7 -0
  186. package/dist/types/registry/ollama.d.ts +12 -0
  187. package/dist/types/registry/openai-codex-device.d.ts +8 -0
  188. package/dist/types/registry/openai-codex.d.ts +9 -0
  189. package/dist/types/registry/openai.d.ts +4 -0
  190. package/dist/types/registry/opencode-go.d.ts +6 -0
  191. package/dist/types/registry/opencode-zen.d.ts +6 -0
  192. package/dist/types/registry/openrouter.d.ts +13 -0
  193. package/dist/types/registry/parallel.d.ts +14 -0
  194. package/dist/types/registry/perplexity.d.ts +7 -0
  195. package/dist/types/registry/qianfan.d.ts +7 -0
  196. package/dist/types/registry/qwen-portal.d.ts +7 -0
  197. package/dist/types/registry/registry.d.ts +367 -0
  198. package/dist/types/registry/sakana.d.ts +7 -0
  199. package/dist/types/registry/siliconflow-cn.d.ts +7 -0
  200. package/dist/types/registry/siliconflow.d.ts +7 -0
  201. package/dist/types/registry/synthetic.d.ts +6 -0
  202. package/dist/types/registry/tavily.d.ts +14 -0
  203. package/dist/types/registry/together.d.ts +6 -0
  204. package/dist/types/registry/types.d.ts +75 -0
  205. package/dist/types/registry/umans.d.ts +7 -0
  206. package/dist/types/registry/venice.d.ts +13 -0
  207. package/dist/types/registry/vercel-ai-gateway.d.ts +7 -0
  208. package/dist/types/registry/vllm.d.ts +7 -0
  209. package/dist/types/registry/wafer-serverless.d.ts +6 -0
  210. package/dist/types/registry/xai-oauth.d.ts +7 -0
  211. package/dist/types/registry/xai.d.ts +7 -0
  212. package/dist/types/registry/xiaomi-token-plan-ams.d.ts +6 -0
  213. package/dist/types/registry/xiaomi-token-plan-cn.d.ts +6 -0
  214. package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +6 -0
  215. package/dist/types/registry/xiaomi.d.ts +6 -0
  216. package/dist/types/registry/zai.d.ts +15 -0
  217. package/dist/types/registry/zenmux.d.ts +7 -0
  218. package/dist/types/registry/zhipu-coding-plan.d.ts +7 -0
  219. package/dist/types/stream.d.ts +46 -0
  220. package/dist/types/types.d.ts +1064 -0
  221. package/dist/types/usage/alibaba-token-plan.d.ts +3 -0
  222. package/dist/types/usage/claude.d.ts +4 -0
  223. package/dist/types/usage/cursor.d.ts +4 -0
  224. package/dist/types/usage/gemini.d.ts +2 -0
  225. package/dist/types/usage/github-copilot.d.ts +7 -0
  226. package/dist/types/usage/google-antigravity.d.ts +15 -0
  227. package/dist/types/usage/kimi.d.ts +2 -0
  228. package/dist/types/usage/minimax-code.d.ts +3 -0
  229. package/dist/types/usage/ollama.d.ts +5 -0
  230. package/dist/types/usage/openai-codex-base-url.d.ts +18 -0
  231. package/dist/types/usage/openai-codex-reset.d.ts +88 -0
  232. package/dist/types/usage/openai-codex.d.ts +10 -0
  233. package/dist/types/usage/opencode-go.d.ts +2 -0
  234. package/dist/types/usage/shared.d.ts +1 -0
  235. package/dist/types/usage/synthetic.d.ts +2 -0
  236. package/dist/types/usage/umans.d.ts +2 -0
  237. package/dist/types/usage/xai-oauth.d.ts +12 -0
  238. package/dist/types/usage/zai.d.ts +3 -0
  239. package/dist/types/usage.d.ts +527 -0
  240. package/dist/types/utils/abort.d.ts +25 -0
  241. package/dist/types/utils/anthropic-auth.d.ts +35 -0
  242. package/dist/types/utils/aws-profile.d.ts +17 -0
  243. package/dist/types/utils/block-symbols.d.ts +62 -0
  244. package/dist/types/utils/deterministic-id.d.ts +16 -0
  245. package/dist/types/utils/empty-completion-retry.d.ts +21 -0
  246. package/dist/types/utils/event-stream.d.ts +39 -0
  247. package/dist/types/utils/foundry.d.ts +1 -0
  248. package/dist/types/utils/google-validation.d.ts +2 -0
  249. package/dist/types/utils/harmony-leak.d.ts +135 -0
  250. package/dist/types/utils/http-inspector.d.ts +49 -0
  251. package/dist/types/utils/idle-iterator.d.ts +149 -0
  252. package/dist/types/utils/leaked-thinking-stream.d.ts +33 -0
  253. package/dist/types/utils/openai-http.d.ts +48 -0
  254. package/dist/types/utils/openrouter-headers.d.ts +1 -0
  255. package/dist/types/utils/parse-bind.d.ts +23 -0
  256. package/dist/types/utils/provider-response.d.ts +3 -0
  257. package/dist/types/utils/proxy.d.ts +39 -0
  258. package/dist/types/utils/request-debug.d.ts +29 -0
  259. package/dist/types/utils/retry-after.d.ts +4 -0
  260. package/dist/types/utils/retry.d.ts +14 -0
  261. package/dist/types/utils/schema/adapt.d.ts +24 -0
  262. package/dist/types/utils/schema/compatibility.d.ts +30 -0
  263. package/dist/types/utils/schema/dereference.d.ts +11 -0
  264. package/dist/types/utils/schema/draft.d.ts +10 -0
  265. package/dist/types/utils/schema/equality.d.ts +4 -0
  266. package/dist/types/utils/schema/fields.d.ts +54 -0
  267. package/dist/types/utils/schema/index.d.ts +14 -0
  268. package/dist/types/utils/schema/json-schema-validator.d.ts +20 -0
  269. package/dist/types/utils/schema/meta-validator.d.ts +2 -0
  270. package/dist/types/utils/schema/normalize.d.ts +153 -0
  271. package/dist/types/utils/schema/spill.d.ts +8 -0
  272. package/dist/types/utils/schema/stamps.d.ts +17 -0
  273. package/dist/types/utils/schema/strict-tool-validation.d.ts +16 -0
  274. package/dist/types/utils/schema/types.d.ts +4 -0
  275. package/dist/types/utils/schema/typescript.d.ts +24 -0
  276. package/dist/types/utils/schema/wire.d.ts +52 -0
  277. package/dist/types/utils/sdk-stream-timeout.d.ts +33 -0
  278. package/dist/types/utils/sse-debug.d.ts +5 -0
  279. package/dist/types/utils/stream-markup-healing.d.ts +87 -0
  280. package/dist/types/utils/thinking-loop.d.ts +102 -0
  281. package/dist/types/utils/tool-call-loop-guard.d.ts +26 -0
  282. package/dist/types/utils/tool-choice.d.ts +52 -0
  283. package/dist/types/utils/validation.d.ts +28 -0
  284. package/dist/types/utils.d.ts +57 -0
  285. package/package.json +138 -0
  286. package/src/api-registry.ts +109 -0
  287. package/src/auth/sqlite-credential-store.ts +2066 -0
  288. package/src/auth-broker/client.ts +471 -0
  289. package/src/auth-broker/discover.ts +310 -0
  290. package/src/auth-broker/index.ts +7 -0
  291. package/src/auth-broker/refresher.ts +117 -0
  292. package/src/auth-broker/remote-store.ts +1332 -0
  293. package/src/auth-broker/server.ts +898 -0
  294. package/src/auth-broker/snapshot-cache.ts +200 -0
  295. package/src/auth-broker/types.ts +193 -0
  296. package/src/auth-broker/wire-schema-resource.ts +487 -0
  297. package/src/auth-broker/wire-schemas.ts +43 -0
  298. package/src/auth-gateway/http.ts +227 -0
  299. package/src/auth-gateway/index.ts +3 -0
  300. package/src/auth-gateway/server.ts +836 -0
  301. package/src/auth-gateway/types.ts +153 -0
  302. package/src/auth-retry.ts +401 -0
  303. package/src/auth-storage.ts +6540 -0
  304. package/src/dialect/anthropic.md +31 -0
  305. package/src/dialect/anthropic.ts +608 -0
  306. package/src/dialect/catalog.ts +29 -0
  307. package/src/dialect/coercion.ts +136 -0
  308. package/src/dialect/deepseek.md +24 -0
  309. package/src/dialect/deepseek.ts +609 -0
  310. package/src/dialect/demotion.ts +40 -0
  311. package/src/dialect/examples.ts +71 -0
  312. package/src/dialect/factory.ts +34 -0
  313. package/src/dialect/fenced-thinking.ts +184 -0
  314. package/src/dialect/gemini.md +44 -0
  315. package/src/dialect/gemini.ts +583 -0
  316. package/src/dialect/gemma.md +33 -0
  317. package/src/dialect/gemma.ts +387 -0
  318. package/src/dialect/glm.md +32 -0
  319. package/src/dialect/glm.ts +579 -0
  320. package/src/dialect/harmony.md +31 -0
  321. package/src/dialect/harmony.ts +345 -0
  322. package/src/dialect/hermes.md +25 -0
  323. package/src/dialect/hermes.ts +206 -0
  324. package/src/dialect/history.ts +81 -0
  325. package/src/dialect/index.ts +15 -0
  326. package/src/dialect/inventory.ts +30 -0
  327. package/src/dialect/kimi.md +24 -0
  328. package/src/dialect/kimi.ts +340 -0
  329. package/src/dialect/minimax.md +31 -0
  330. package/src/dialect/minimax.ts +95 -0
  331. package/src/dialect/owned-stream.ts +481 -0
  332. package/src/dialect/prompt-template.md +12 -0
  333. package/src/dialect/qwen3.md +28 -0
  334. package/src/dialect/qwen3.ts +240 -0
  335. package/src/dialect/rendering.ts +304 -0
  336. package/src/dialect/thinking.ts +292 -0
  337. package/src/dialect/types.ts +56 -0
  338. package/src/dialect/xml.md +22 -0
  339. package/src/dialect/xml.ts +90 -0
  340. package/src/error/abort.ts +18 -0
  341. package/src/error/auth-classify.ts +47 -0
  342. package/src/error/auth.ts +48 -0
  343. package/src/error/aws.ts +35 -0
  344. package/src/error/classes.ts +281 -0
  345. package/src/error/finalize.ts +69 -0
  346. package/src/error/flags.ts +602 -0
  347. package/src/error/format.ts +45 -0
  348. package/src/error/gateway.ts +96 -0
  349. package/src/error/index.ts +14 -0
  350. package/src/error/oauth.ts +58 -0
  351. package/src/error/provider.ts +63 -0
  352. package/src/error/rate-limit.ts +303 -0
  353. package/src/error/retryable.ts +70 -0
  354. package/src/error/validation.ts +44 -0
  355. package/src/index.ts +51 -0
  356. package/src/provider-details.ts +90 -0
  357. package/src/providers/amazon-bedrock.ts +1064 -0
  358. package/src/providers/anthropic-client.ts +317 -0
  359. package/src/providers/anthropic-messages-server-schema.ts +252 -0
  360. package/src/providers/anthropic-messages-server.ts +818 -0
  361. package/src/providers/anthropic-wire.ts +359 -0
  362. package/src/providers/anthropic.ts +4539 -0
  363. package/src/providers/aws-credentials.ts +772 -0
  364. package/src/providers/aws-eventstream.ts +181 -0
  365. package/src/providers/aws-sigv4.ts +218 -0
  366. package/src/providers/azure-openai-responses.ts +438 -0
  367. package/src/providers/bedrock-mantle.ts +110 -0
  368. package/src/providers/claude-code-fingerprint.ts +20 -0
  369. package/src/providers/cowork-fetch.ts +201 -0
  370. package/src/providers/cursor/exec-modern.ts +496 -0
  371. package/src/providers/cursor/proto/agent.proto +4533 -0
  372. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  373. package/src/providers/cursor/proto/buf.yaml +17 -0
  374. package/src/providers/cursor-pi-args.ts +165 -0
  375. package/src/providers/cursor.ts +4689 -0
  376. package/src/providers/devin/proto/buf/validate/validate.proto +468 -0
  377. package/src/providers/devin/proto/buf.gen.yaml +33 -0
  378. package/src/providers/devin/proto/buf.yaml +17 -0
  379. package/src/providers/devin/proto/cel/expr/checked.proto +103 -0
  380. package/src/providers/devin/proto/cel/expr/eval.proto +38 -0
  381. package/src/providers/devin/proto/cel/expr/explain.proto +15 -0
  382. package/src/providers/devin/proto/cel/expr/syntax.proto +113 -0
  383. package/src/providers/devin/proto/cel/expr/value.proto +41 -0
  384. package/src/providers/devin/proto/connectext/grpc/status/v1/status.proto +11 -0
  385. package/src/providers/devin/proto/errorspb/errors.proto +56 -0
  386. package/src/providers/devin/proto/errorspb/hintdetail.proto +7 -0
  387. package/src/providers/devin/proto/errorspb/markers.proto +10 -0
  388. package/src/providers/devin/proto/errorspb/tags.proto +12 -0
  389. package/src/providers/devin/proto/errorspb/testing.proto +6 -0
  390. package/src/providers/devin/proto/exa/analytics_pb/analytics.proto +188 -0
  391. package/src/providers/devin/proto/exa/api_server_pb/api_server.proto +2461 -0
  392. package/src/providers/devin/proto/exa/auth_pb/auth.proto +19 -0
  393. package/src/providers/devin/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +79 -0
  394. package/src/providers/devin/proto/exa/browser_preview_pb/browser_preview.proto +32 -0
  395. package/src/providers/devin/proto/exa/bug_checker_pb/bug_checker.proto +22 -0
  396. package/src/providers/devin/proto/exa/cascade_plugins_pb/cascade_plugins.proto +262 -0
  397. package/src/providers/devin/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
  398. package/src/providers/devin/proto/exa/chat_pb/chat.proto +449 -0
  399. package/src/providers/devin/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
  400. package/src/providers/devin/proto/exa/codeium_common_pb/codeium_common.proto +4157 -0
  401. package/src/providers/devin/proto/exa/context_module_pb/context_module.proto +175 -0
  402. package/src/providers/devin/proto/exa/cortex_pb/cortex.proto +3268 -0
  403. package/src/providers/devin/proto/exa/dev_pb/dev.proto +26 -0
  404. package/src/providers/devin/proto/exa/diff_action_pb/diff_action.proto +75 -0
  405. package/src/providers/devin/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +103 -0
  406. package/src/providers/devin/proto/exa/eval_pb/eval.proto +1315 -0
  407. package/src/providers/devin/proto/exa/extension_server_pb/extension_server.proto +556 -0
  408. package/src/providers/devin/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
  409. package/src/providers/devin/proto/exa/index_pb/index.proto +461 -0
  410. package/src/providers/devin/proto/exa/knowledge_base_pb/knowledge_base.proto +144 -0
  411. package/src/providers/devin/proto/exa/language_server_pb/language_server.proto +2385 -0
  412. package/src/providers/devin/proto/exa/model_management_pb/model_management.proto +186 -0
  413. package/src/providers/devin/proto/exa/opensearch_clients_pb/opensearch_clients.proto +503 -0
  414. package/src/providers/devin/proto/exa/product_analytics_pb/product_analytics.proto +37 -0
  415. package/src/providers/devin/proto/exa/prompt_pb/prompt.proto +92 -0
  416. package/src/providers/devin/proto/exa/reactive_component_pb/reactive_component.proto +96 -0
  417. package/src/providers/devin/proto/exa/seat_management_pb/seat_management.proto +2680 -0
  418. package/src/providers/devin/proto/exa/tokenizer_pb/tokenizer.proto +37 -0
  419. package/src/providers/devin/proto/exa/trainer_pb/config.proto +647 -0
  420. package/src/providers/devin/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
  421. package/src/providers/devin/proto/exa/trust_pb/trust.proto +157 -0
  422. package/src/providers/devin/proto/exa/user_analytics_pb/user_analytics.proto +519 -0
  423. package/src/providers/devin/proto/google.golang.org/appengine/internal/base/api_base.proto +28 -0
  424. package/src/providers/devin/proto/google.golang.org/appengine/internal/datastore/datastore_v3.proto +484 -0
  425. package/src/providers/devin/proto/google.golang.org/appengine/internal/log/log_service.proto +136 -0
  426. package/src/providers/devin/proto/google.golang.org/appengine/internal/remote_api/remote_api.proto +42 -0
  427. package/src/providers/devin/proto/google.golang.org/appengine/internal/urlfetch/urlfetch_service.proto +61 -0
  428. package/src/providers/devin/proto/grpc/binlog/v1/binarylog.proto +84 -0
  429. package/src/providers/devin/proto/io/prometheus/client/metrics.proto +98 -0
  430. package/src/providers/devin.ts +679 -0
  431. package/src/providers/error-message.ts +23 -0
  432. package/src/providers/github-copilot-headers.ts +141 -0
  433. package/src/providers/gitlab-duo-workflow-chatml-note.md +1 -0
  434. package/src/providers/gitlab-duo-workflow.ts +3135 -0
  435. package/src/providers/gitlab-duo.ts +399 -0
  436. package/src/providers/google-auth.ts +330 -0
  437. package/src/providers/google-gemini-cli.ts +1370 -0
  438. package/src/providers/google-shared.ts +1122 -0
  439. package/src/providers/google-types.ts +180 -0
  440. package/src/providers/google-vertex.ts +135 -0
  441. package/src/providers/google.ts +47 -0
  442. package/src/providers/grammar.ts +70 -0
  443. package/src/providers/kimi.ts +51 -0
  444. package/src/providers/mock.ts +514 -0
  445. package/src/providers/ollama.ts +776 -0
  446. package/src/providers/openai-anthropic-shim.ts +166 -0
  447. package/src/providers/openai-chat-server-schema.ts +243 -0
  448. package/src/providers/openai-chat-server.ts +752 -0
  449. package/src/providers/openai-chat-wire.ts +859 -0
  450. package/src/providers/openai-codex/request-transformer.ts +491 -0
  451. package/src/providers/openai-codex/response-handler.ts +102 -0
  452. package/src/providers/openai-codex-responses.ts +4716 -0
  453. package/src/providers/openai-completions.ts +2389 -0
  454. package/src/providers/openai-reasoning-fallback.ts +269 -0
  455. package/src/providers/openai-responses-server-schema.ts +397 -0
  456. package/src/providers/openai-responses-server.ts +1466 -0
  457. package/src/providers/openai-responses-wire.ts +6416 -0
  458. package/src/providers/openai-responses.ts +1393 -0
  459. package/src/providers/openai-shared.ts +3500 -0
  460. package/src/providers/pi-native-client.ts +275 -0
  461. package/src/providers/pi-native-server.ts +245 -0
  462. package/src/providers/register-builtins.ts +503 -0
  463. package/src/providers/synthetic.ts +50 -0
  464. package/src/providers/transform-messages.ts +1083 -0
  465. package/src/providers/vision-guard.ts +54 -0
  466. package/src/registry/aiand.ts +22 -0
  467. package/src/registry/aimlapi.ts +6 -0
  468. package/src/registry/alibaba-coding-plan.ts +104 -0
  469. package/src/registry/alibaba-token-plan.ts +125 -0
  470. package/src/registry/amazon-bedrock.ts +22 -0
  471. package/src/registry/anthropic.ts +26 -0
  472. package/src/registry/api-key-login.ts +115 -0
  473. package/src/registry/api-key-validation.ts +145 -0
  474. package/src/registry/aws.ts +57 -0
  475. package/src/registry/azure.ts +6 -0
  476. package/src/registry/baseten.ts +22 -0
  477. package/src/registry/bedrock-mantle.ts +34 -0
  478. package/src/registry/cerebras.ts +23 -0
  479. package/src/registry/cloudflare-ai-gateway.ts +45 -0
  480. package/src/registry/coreweave.ts +40 -0
  481. package/src/registry/cursor.ts +20 -0
  482. package/src/registry/deepseek.ts +46 -0
  483. package/src/registry/derived.ts +9 -0
  484. package/src/registry/devin.ts +15 -0
  485. package/src/registry/exa.ts +19 -0
  486. package/src/registry/firepass.ts +32 -0
  487. package/src/registry/fireworks.ts +28 -0
  488. package/src/registry/github-copilot.ts +22 -0
  489. package/src/registry/gitlab-duo-workflow.ts +20 -0
  490. package/src/registry/gitlab-duo.ts +19 -0
  491. package/src/registry/gmi-cloud.ts +22 -0
  492. package/src/registry/google-antigravity.ts +22 -0
  493. package/src/registry/google-gemini-cli.ts +22 -0
  494. package/src/registry/google-vertex.ts +38 -0
  495. package/src/registry/google.ts +6 -0
  496. package/src/registry/groq.ts +6 -0
  497. package/src/registry/huggingface.ts +29 -0
  498. package/src/registry/index.ts +4 -0
  499. package/src/registry/kagi.ts +46 -0
  500. package/src/registry/kilo.ts +114 -0
  501. package/src/registry/kimi-code.ts +17 -0
  502. package/src/registry/litellm.ts +45 -0
  503. package/src/registry/llama-cpp.ts +35 -0
  504. package/src/registry/lm-studio.ts +31 -0
  505. package/src/registry/meta.ts +22 -0
  506. package/src/registry/minimax-code-cn.ts +12 -0
  507. package/src/registry/minimax-code.ts +12 -0
  508. package/src/registry/minimax.ts +6 -0
  509. package/src/registry/mistral.ts +6 -0
  510. package/src/registry/moonshot.ts +28 -0
  511. package/src/registry/nanogpt.ts +22 -0
  512. package/src/registry/novita.ts +25 -0
  513. package/src/registry/nvidia.ts +61 -0
  514. package/src/registry/oauth/anthropic-constants.ts +12 -0
  515. package/src/registry/oauth/anthropic.ts +346 -0
  516. package/src/registry/oauth/callback-server.ts +438 -0
  517. package/src/registry/oauth/cursor.ts +187 -0
  518. package/src/registry/oauth/device-code.ts +92 -0
  519. package/src/registry/oauth/devin.ts +124 -0
  520. package/src/registry/oauth/github-copilot.ts +369 -0
  521. package/src/registry/oauth/gitlab-duo-workflow.ts +146 -0
  522. package/src/registry/oauth/gitlab-duo.ts +222 -0
  523. package/src/registry/oauth/google-antigravity.ts +225 -0
  524. package/src/registry/oauth/google-gemini-cli.ts +297 -0
  525. package/src/registry/oauth/google-oauth-shared.ts +211 -0
  526. package/src/registry/oauth/index.ts +187 -0
  527. package/src/registry/oauth/kimi.ts +297 -0
  528. package/src/registry/oauth/minimax-code.ts +53 -0
  529. package/src/registry/oauth/oauth.html +317 -0
  530. package/src/registry/oauth/openai-codex.ts +384 -0
  531. package/src/registry/oauth/opencode.ts +50 -0
  532. package/src/registry/oauth/perplexity.ts +228 -0
  533. package/src/registry/oauth/pkce.ts +18 -0
  534. package/src/registry/oauth/types.ts +96 -0
  535. package/src/registry/oauth/wafer.ts +24 -0
  536. package/src/registry/oauth/xai-oauth.ts +559 -0
  537. package/src/registry/oauth/xiaomi.ts +211 -0
  538. package/src/registry/oauth/zai.ts +285 -0
  539. package/src/registry/ollama-cloud.ts +36 -0
  540. package/src/registry/ollama.ts +43 -0
  541. package/src/registry/openai-codex-device.ts +18 -0
  542. package/src/registry/openai-codex.ts +19 -0
  543. package/src/registry/openai.ts +6 -0
  544. package/src/registry/opencode-go.ts +12 -0
  545. package/src/registry/opencode-zen.ts +12 -0
  546. package/src/registry/openrouter.ts +28 -0
  547. package/src/registry/parallel.ts +45 -0
  548. package/src/registry/perplexity.ts +13 -0
  549. package/src/registry/qianfan.ts +27 -0
  550. package/src/registry/qwen-portal.ts +50 -0
  551. package/src/registry/registry.ts +182 -0
  552. package/src/registry/sakana.ts +22 -0
  553. package/src/registry/siliconflow-cn.ts +22 -0
  554. package/src/registry/siliconflow.ts +22 -0
  555. package/src/registry/synthetic.ts +21 -0
  556. package/src/registry/tavily.ts +45 -0
  557. package/src/registry/together.ts +22 -0
  558. package/src/registry/types.ts +86 -0
  559. package/src/registry/umans.ts +23 -0
  560. package/src/registry/venice.ts +33 -0
  561. package/src/registry/vercel-ai-gateway.ts +38 -0
  562. package/src/registry/vllm.ts +34 -0
  563. package/src/registry/wafer-serverless.ts +12 -0
  564. package/src/registry/xai-oauth.ts +17 -0
  565. package/src/registry/xai.ts +22 -0
  566. package/src/registry/xiaomi-token-plan-ams.ts +12 -0
  567. package/src/registry/xiaomi-token-plan-cn.ts +12 -0
  568. package/src/registry/xiaomi-token-plan-sgp.ts +12 -0
  569. package/src/registry/xiaomi.ts +12 -0
  570. package/src/registry/zai.ts +41 -0
  571. package/src/registry/zenmux.ts +22 -0
  572. package/src/registry/zhipu-coding-plan.ts +27 -0
  573. package/src/stream.ts +1944 -0
  574. package/src/types.ts +1243 -0
  575. package/src/usage/alibaba-token-plan.ts +230 -0
  576. package/src/usage/claude.ts +830 -0
  577. package/src/usage/cursor.ts +335 -0
  578. package/src/usage/gemini.ts +258 -0
  579. package/src/usage/github-copilot.ts +424 -0
  580. package/src/usage/google-antigravity.ts +497 -0
  581. package/src/usage/kimi.ts +277 -0
  582. package/src/usage/minimax-code.ts +291 -0
  583. package/src/usage/ollama.ts +41 -0
  584. package/src/usage/openai-codex-base-url.ts +35 -0
  585. package/src/usage/openai-codex-reset.ts +205 -0
  586. package/src/usage/openai-codex.ts +627 -0
  587. package/src/usage/opencode-go.ts +89 -0
  588. package/src/usage/shared.ts +10 -0
  589. package/src/usage/synthetic.ts +180 -0
  590. package/src/usage/umans.ts +192 -0
  591. package/src/usage/xai-oauth.ts +414 -0
  592. package/src/usage/zai.ts +370 -0
  593. package/src/usage.ts +411 -0
  594. package/src/utils/abort.ts +67 -0
  595. package/src/utils/anthropic-auth.ts +93 -0
  596. package/src/utils/aws-profile.ts +88 -0
  597. package/src/utils/block-symbols.ts +78 -0
  598. package/src/utils/deterministic-id.ts +20 -0
  599. package/src/utils/empty-completion-retry.ts +161 -0
  600. package/src/utils/event-stream.ts +202 -0
  601. package/src/utils/foundry.ts +8 -0
  602. package/src/utils/google-validation.ts +25 -0
  603. package/src/utils/harmony-leak.ts +500 -0
  604. package/src/utils/http-inspector.ts +196 -0
  605. package/src/utils/idle-iterator.ts +531 -0
  606. package/src/utils/leaked-thinking-stream.ts +483 -0
  607. package/src/utils/openai-http.ts +119 -0
  608. package/src/utils/openrouter-headers.ts +12 -0
  609. package/src/utils/parse-bind.ts +56 -0
  610. package/src/utils/provider-response.ts +30 -0
  611. package/src/utils/proxy.ts +314 -0
  612. package/src/utils/request-debug.ts +351 -0
  613. package/src/utils/retry-after.ts +121 -0
  614. package/src/utils/retry.ts +77 -0
  615. package/src/utils/schema/CONSTRAINTS.md +168 -0
  616. package/src/utils/schema/adapt.ts +36 -0
  617. package/src/utils/schema/compatibility.ts +435 -0
  618. package/src/utils/schema/dereference.ts +98 -0
  619. package/src/utils/schema/draft.ts +341 -0
  620. package/src/utils/schema/equality.ts +97 -0
  621. package/src/utils/schema/fields.ts +210 -0
  622. package/src/utils/schema/index.ts +14 -0
  623. package/src/utils/schema/json-schema-validator.ts +595 -0
  624. package/src/utils/schema/meta-validator.ts +167 -0
  625. package/src/utils/schema/normalize.ts +2314 -0
  626. package/src/utils/schema/spill.ts +43 -0
  627. package/src/utils/schema/stamps.ts +109 -0
  628. package/src/utils/schema/strict-tool-validation.ts +117 -0
  629. package/src/utils/schema/types.ts +10 -0
  630. package/src/utils/schema/typescript.ts +212 -0
  631. package/src/utils/schema/wire.ts +662 -0
  632. package/src/utils/sdk-stream-timeout.ts +43 -0
  633. package/src/utils/sse-debug.ts +18 -0
  634. package/src/utils/stream-markup-healing.ts +247 -0
  635. package/src/utils/thinking-loop.ts +552 -0
  636. package/src/utils/tool-call-loop-guard.ts +107 -0
  637. package/src/utils/tool-choice.ts +101 -0
  638. package/src/utils/validation.ts +1932 -0
  639. package/src/utils.ts +492 -0
@@ -0,0 +1,4689 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import http2 from "node:http2";
4
+ import { create, fromBinary, fromJson, type JsonValue, toBinary, toJson } from "@bufbuild/protobuf";
5
+ import { ValueSchema } from "@bufbuild/protobuf/wkt";
6
+ import type { ConversationStep, McpToolDefinition } from "@linxiraos/pi-catalog/discovery/cursor-gen/agent_pb";
7
+ import {
8
+ AgentClientMessageSchema,
9
+ AgentConversationTurnStructureSchema,
10
+ AgentRunRequestSchema,
11
+ type AgentServerMessage,
12
+ AgentServerMessageSchema,
13
+ AgentStoreConflictErrorSchema,
14
+ AgentStoreConflictResultSchema,
15
+ AssistantMessageSchema,
16
+ BackgroundShellSpawnResultSchema,
17
+ CanvasDiagnosticsErrorSchema,
18
+ CanvasDiagnosticsResultSchema,
19
+ ClientHeartbeatSchema,
20
+ ComputerUseErrorSchema,
21
+ ComputerUseResultSchema,
22
+ ConversationActionSchema,
23
+ ConversationSearchErrorSchema,
24
+ ConversationSearchResultSchema,
25
+ type ConversationStateStructure,
26
+ ConversationStateStructureSchema,
27
+ ConversationStepSchema,
28
+ ConversationTurnStructureSchema,
29
+ DeleteErrorSchema,
30
+ DeleteRejectedSchema,
31
+ DeleteResultSchema,
32
+ DeleteSuccessSchema,
33
+ DiagnosticsErrorSchema,
34
+ DiagnosticsRejectedSchema,
35
+ DiagnosticsResultSchema,
36
+ DiagnosticsSuccessSchema,
37
+ ExecClientControlMessageSchema,
38
+ type ExecClientMessage,
39
+ ExecClientMessageSchema,
40
+ ExecClientStreamCloseSchema,
41
+ ExecClientThrowSchema,
42
+ type ExecServerMessage,
43
+ FetchErrorSchema,
44
+ FetchResultSchema,
45
+ ForceBackgroundShellResultSchema,
46
+ ForceBackgroundShellStatus,
47
+ ForceBackgroundSubagentResultSchema,
48
+ ForceBackgroundSubagentStatus,
49
+ GetBlobResultSchema,
50
+ GrepContentMatchSchema,
51
+ GrepContentResultSchema,
52
+ GrepCountResultSchema,
53
+ GrepErrorSchema,
54
+ type GrepFileCount,
55
+ GrepFileCountSchema,
56
+ GrepFileMatchSchema,
57
+ GrepFilesResultSchema,
58
+ GrepResultSchema,
59
+ GrepSuccessSchema,
60
+ type GrepUnionResult,
61
+ GrepUnionResultSchema,
62
+ KvClientMessageSchema,
63
+ type KvServerMessage,
64
+ ListMcpResourcesErrorSchema,
65
+ type ListMcpResourcesExecResult,
66
+ ListMcpResourcesExecResult_McpResourceSchema,
67
+ ListMcpResourcesExecResultSchema,
68
+ ListMcpResourcesSuccessSchema,
69
+ type LsDirectoryTreeNode,
70
+ type LsDirectoryTreeNode_File,
71
+ LsDirectoryTreeNode_FileSchema,
72
+ LsDirectoryTreeNodeSchema,
73
+ LsErrorSchema,
74
+ LsRejectedSchema,
75
+ LsResultSchema,
76
+ LsSuccessSchema,
77
+ McpAllowlistPrecheckResultSchema,
78
+ McpApprovedSchema,
79
+ McpArgsSchema,
80
+ McpErrorSchema,
81
+ McpImageContentSchema,
82
+ McpRejectedSchema,
83
+ McpResultSchema,
84
+ McpSuccessSchema,
85
+ McpTextContentSchema,
86
+ McpToolCallSchema,
87
+ McpToolDefinitionSchema,
88
+ McpToolErrorSchema,
89
+ McpToolNotFoundSchema,
90
+ McpToolResultContentItemSchema,
91
+ McpToolResultSchema,
92
+ ModelDetailsSchema,
93
+ ReadErrorSchema,
94
+ ReadMcpResourceErrorSchema,
95
+ type ReadMcpResourceExecResult,
96
+ ReadMcpResourceExecResultSchema,
97
+ ReadMcpResourceNotFoundSchema,
98
+ ReadMcpResourceSuccessSchema,
99
+ ReadRejectedSchema,
100
+ ReadResultSchema,
101
+ ReadSuccessSchema,
102
+ RecordScreenFailureSchema,
103
+ RecordScreenResultSchema,
104
+ RequestContextResultSchema,
105
+ RequestContextSchema,
106
+ RequestContextSuccessSchema,
107
+ RequestedModelSchema,
108
+ ResumeActionSchema,
109
+ SelectedContextSchema,
110
+ SelectedImageSchema,
111
+ SetBlobResultSchema,
112
+ ShellAllowlistPrecheckResultSchema,
113
+ type ShellArgs,
114
+ ShellFailureSchema,
115
+ ShellRejectedSchema,
116
+ type ShellResult,
117
+ ShellResultSchema,
118
+ type ShellStream,
119
+ ShellStreamExitSchema,
120
+ ShellStreamSchema,
121
+ ShellStreamStartSchema,
122
+ ShellStreamStderrSchema,
123
+ ShellStreamStdoutSchema,
124
+ ShellSuccessSchema,
125
+ SmartModeClassifierErrorSchema,
126
+ SmartModeClassifierResultSchema,
127
+ SubagentAwaitNotFoundSchema,
128
+ SubagentAwaitResultSchema,
129
+ SubagentErrorSchema,
130
+ SubagentResultSchema,
131
+ ThinkingMessageSchema,
132
+ ToolCallSchema,
133
+ UserMessageActionSchema,
134
+ UserMessageSchema,
135
+ WebFetchAllowlistPrecheckResultSchema,
136
+ WriteErrorSchema,
137
+ WriteRejectedSchema,
138
+ WriteResultSchema,
139
+ WriteShellStdinErrorSchema,
140
+ WriteShellStdinResultSchema,
141
+ WriteSuccessSchema,
142
+ } from "@linxiraos/pi-catalog/discovery/cursor-gen/agent_pb";
143
+ import { isKimiK3ModelId } from "@linxiraos/pi-catalog/identity";
144
+ import { calculateCost } from "@linxiraos/pi-catalog/models";
145
+ import {
146
+ $env,
147
+ logger,
148
+ parseJsonWithRepair,
149
+ parseStreamingJson,
150
+ parseStreamingJsonThrottled,
151
+ sanitizeText,
152
+ } from "@linxiraos/pi-utils";
153
+ import * as AIError from "../error";
154
+ import type {
155
+ Api,
156
+ AssistantMessage,
157
+ Context,
158
+ CursorExecHandlerResult,
159
+ CursorExecHandlers,
160
+ CursorExecPairing,
161
+ CursorMcpCall,
162
+ CursorShellStreamCallbacks,
163
+ CursorTodoSnapshot,
164
+ CursorTodoSnapshotItem,
165
+ CursorTodoSyncHandler,
166
+ CursorToolResultHandler,
167
+ ImageContent,
168
+ Message,
169
+ Model,
170
+ StreamFunction,
171
+ StreamOptions,
172
+ TextContent,
173
+ ThinkingContent,
174
+ Tool,
175
+ ToolCall,
176
+ ToolResultMessage,
177
+ } from "../types";
178
+ import { normalizeSystemPrompts } from "../utils";
179
+ import {
180
+ type CursorExecResolvedCarrier,
181
+ clearStreamingPartialJson,
182
+ kCursorExecResolved,
183
+ kStreamingBlockIndex,
184
+ kStreamingBlockKind,
185
+ kStreamingEnvelopeId,
186
+ kStreamingLastParseLen,
187
+ kStreamingPartialJson,
188
+ } from "../utils/block-symbols";
189
+ import { deterministicUuid } from "../utils/deterministic-id";
190
+ import { AssistantMessageEventStream } from "../utils/event-stream";
191
+ import { connectProxiedSocket, getProxyForProvider, shouldBypassProxy } from "../utils/proxy";
192
+ import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
193
+ import { toolWireSchema } from "../utils/schema/wire";
194
+ import {
195
+ buildMcpStateResult,
196
+ buildNeutralHookResult,
197
+ buildPiBashError,
198
+ buildPiBashResult,
199
+ buildPiEditError,
200
+ buildPiEditRejected,
201
+ buildPiEditResult,
202
+ buildPiFindError,
203
+ buildPiFindResult,
204
+ buildPiGrepError,
205
+ buildPiGrepResult,
206
+ buildPiLsError,
207
+ buildPiLsResult,
208
+ buildPiReadError,
209
+ buildPiReadResult,
210
+ buildPiWriteError,
211
+ buildPiWriteRejected,
212
+ buildPiWriteResult,
213
+ piEscapeRegexLiteral,
214
+ piGrepSkip,
215
+ piJoinPath,
216
+ piLimit,
217
+ piLsPath,
218
+ piReadDisplayPath,
219
+ piReadPathHasRange,
220
+ piTimeout,
221
+ } from "./cursor/exec-modern";
222
+
223
+ export const CURSOR_API_URL = "https://api2.cursor.sh";
224
+ export const CURSOR_CLIENT_VERSION = "cli-2026.07.23-e383d2b";
225
+
226
+ const CURSOR_PROXY_TUNNEL_TIMEOUT_MS = 30_000;
227
+
228
+ /**
229
+ * Text for a recognised frame this client answers with its own typed error
230
+ * variant. Phrased as a client capability statement, not a tool failure: the
231
+ * model reads it and should route around the capability, not retry the call.
232
+ */
233
+ const NOT_IMPLEMENTED_SUFFIX = "not implemented by this client";
234
+ const NOT_IMPLEMENTED = `Not implemented by this client`;
235
+
236
+ const conversationStateCache = new Map<string, ConversationStateStructure>();
237
+ const conversationBlobStores = new Map<string, Map<string, Uint8Array>>();
238
+ const warnedCursorKimiK3ReplayMessages = new Set<string>();
239
+
240
+ export interface CursorOptions extends StreamOptions {
241
+ customSystemPrompt?: string;
242
+ conversationId?: string;
243
+ execHandlers?: CursorExecHandlers;
244
+ onToolResult?: CursorToolResultHandler;
245
+ }
246
+
247
+ const CONNECT_END_STREAM_FLAG = 0b00000010;
248
+
249
+ interface CursorLogEntry {
250
+ ts: number;
251
+ type: string;
252
+ subtype?: string;
253
+ data?: unknown;
254
+ }
255
+
256
+ async function appendCursorDebugLog(entry: CursorLogEntry): Promise<void> {
257
+ const logPath = $env.DEBUG_CURSOR_LOG;
258
+ if (!logPath) return;
259
+ try {
260
+ await fs.appendFile(logPath, `${JSON.stringify(entry, debugReplacer)}\n`);
261
+ } catch {
262
+ // Ignore debug log failures
263
+ }
264
+ }
265
+
266
+ function log(type: string, subtype?: string, data?: unknown): void {
267
+ if (!$env.DEBUG_CURSOR) return;
268
+ const normalizedData = data ? decodeLogData(data) : data;
269
+ const entry: CursorLogEntry = { ts: Date.now(), type, subtype, data: normalizedData };
270
+ const verbose = $env.DEBUG_CURSOR === "2" || $env.DEBUG_CURSOR === "verbose";
271
+ const dataStr = verbose && normalizedData ? ` ${JSON.stringify(normalizedData, debugReplacer)?.slice(0, 500)}` : "";
272
+ console.error(`[CURSOR] ${type}${subtype ? `: ${subtype}` : ""}${dataStr}`);
273
+ void appendCursorDebugLog(entry);
274
+ }
275
+
276
+ function frameConnectMessage(data: Uint8Array, flags = 0): Buffer {
277
+ const frame = Buffer.alloc(5 + data.length);
278
+ frame[0] = flags;
279
+ frame.writeUInt32BE(data.length, 1);
280
+ frame.set(data, 5);
281
+ return frame;
282
+ }
283
+
284
+ function parseConnectEndStream(data: Uint8Array): Error | null {
285
+ try {
286
+ const payload = JSON.parse(new TextDecoder().decode(data));
287
+ const error = payload?.error;
288
+ if (error) {
289
+ const code = typeof error.code === "string" ? error.code : "unknown";
290
+ const message = typeof error.message === "string" ? error.message : "Unknown error";
291
+ return new AIError.ProviderResponseError(`Connect error ${code}: ${message}`, { kind: "envelope" });
292
+ }
293
+ return null;
294
+ } catch {
295
+ return new AIError.ProviderResponseError("Failed to parse Connect end stream", { kind: "envelope" });
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Maps an opaque HTTP/2 negotiation failure into an actionable error.
301
+ *
302
+ * bun only opens an HTTP/2 session when TLS-ALPN negotiates `h2`. Behind a
303
+ * TLS-intercepting proxy that strips ALPN (e.g. Zscaler), the handshake yields
304
+ * no `h2` protocol and bun throws `ERR_HTTP2_ERROR: h2 is not supported`. The
305
+ * Cursor run RPC is HTTP/2-only (the ALB rejects HTTP/1.1 with 464), so there
306
+ * is no h1 fallback the way model discovery has one — the run simply cannot
307
+ * proceed. Replace the opaque message with one that names the cause and points
308
+ * at the `providers.cursor.baseUrl` workaround.
309
+ *
310
+ * Non-ALPN errors pass through untouched.
311
+ */
312
+ export function mapH2TransportError(error: unknown, baseUrl: string): unknown {
313
+ const code = (error as { code?: unknown } | null)?.code;
314
+ const message = error instanceof Error ? error.message : String(error);
315
+ if (code === "ERR_HTTP2_ERROR" && /h2 is not supported/i.test(message)) {
316
+ return new AIError.ProviderResponseError(
317
+ `Cursor run transport could not negotiate HTTP/2 with ${baseUrl}: "h2 is not supported". ` +
318
+ "This host serves the run RPC over HTTP/2 only, and the TLS handshake did not negotiate " +
319
+ "h2 via ALPN — typically an ALPN-stripping TLS-intercepting proxy (e.g. Zscaler). " +
320
+ "Front the provider with a local HTTP/2 bridge and set providers.cursor.baseUrl to it.",
321
+ { provider: "cursor", kind: "runtime", cause: error },
322
+ );
323
+ }
324
+ return error;
325
+ }
326
+
327
+ function debugBytes(bytes: Uint8Array, asHex: boolean): string {
328
+ if (asHex) {
329
+ return Buffer.from(bytes).toString("hex");
330
+ }
331
+ try {
332
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
333
+ if (/^[\x20-\x7E\s]*$/.test(text)) return text;
334
+ } catch {}
335
+ return Buffer.from(bytes).toString("hex");
336
+ }
337
+
338
+ function debugReplacer(key: string, value: unknown): unknown {
339
+ if (
340
+ value instanceof Uint8Array ||
341
+ (value && typeof value === "object" && "type" in value && value.type === "Buffer")
342
+ ) {
343
+ const bytes = value instanceof Uint8Array ? value : new Uint8Array((value as any).data);
344
+ const asHex = key === "blobId" || key === "blob_id" || key.endsWith("Id") || key.endsWith("_id");
345
+ return debugBytes(bytes, asHex);
346
+ }
347
+ if (typeof value === "bigint") return value.toString();
348
+ return value;
349
+ }
350
+
351
+ function extractLogBytes(value: unknown): Uint8Array | null {
352
+ if (value instanceof Uint8Array) {
353
+ return value;
354
+ }
355
+ if (value && typeof value === "object" && "type" in value && value.type === "Buffer") {
356
+ const data = (value as { data?: number[] }).data;
357
+ if (Array.isArray(data)) {
358
+ return new Uint8Array(data);
359
+ }
360
+ }
361
+ return null;
362
+ }
363
+
364
+ function decodeMcpArgsForLog(args?: Record<string, unknown>): Record<string, unknown> | undefined {
365
+ if (!args) {
366
+ return undefined;
367
+ }
368
+ let mutated = false;
369
+ const decoded: Record<string, unknown> = {};
370
+ for (const [key, value] of Object.entries(args)) {
371
+ const bytes = extractLogBytes(value);
372
+ if (bytes) {
373
+ decoded[key] = decodeMcpArgValue(bytes);
374
+ mutated = true;
375
+ continue;
376
+ }
377
+ const normalizedValue = decodeLogData(value);
378
+ decoded[key] = normalizedValue;
379
+ if (normalizedValue !== value) {
380
+ mutated = true;
381
+ }
382
+ }
383
+ return mutated ? decoded : args;
384
+ }
385
+
386
+ function decodeLogData(value: unknown): unknown {
387
+ if (!value || typeof value !== "object") {
388
+ return value;
389
+ }
390
+ if (Array.isArray(value)) {
391
+ return value.map(entry => decodeLogData(entry));
392
+ }
393
+ const record = value as Record<string, unknown>;
394
+ const typeName = record.$typeName;
395
+ const stripTypeName = typeof typeName === "string" && typeName.startsWith("agent.v1.");
396
+
397
+ if (typeName === "agent.v1.McpArgs") {
398
+ const decodedArgs = decodeMcpArgsForLog(record.args as Record<string, unknown> | undefined);
399
+ const base = stripTypeName ? omitTypeName(record) : record;
400
+ return decodedArgs ? { ...base, args: decodedArgs } : base;
401
+ }
402
+ if (typeName === "agent.v1.McpToolCall") {
403
+ const argsRecord = record.args as Record<string, unknown> | undefined;
404
+ const decodedArgs = decodeMcpArgsForLog(argsRecord?.args as Record<string, unknown> | undefined);
405
+ const base = stripTypeName ? omitTypeName(record) : record;
406
+ if (decodedArgs && argsRecord) {
407
+ return { ...base, args: { ...argsRecord, args: decodedArgs } };
408
+ }
409
+ return base;
410
+ }
411
+
412
+ let mutated = stripTypeName;
413
+ const decoded: Record<string, unknown> = {};
414
+ for (const [key, entry] of Object.entries(record)) {
415
+ if (stripTypeName && key === "$typeName") {
416
+ continue;
417
+ }
418
+ const normalizedEntry = decodeLogData(entry);
419
+ decoded[key] = normalizedEntry;
420
+ if (normalizedEntry !== entry) {
421
+ mutated = true;
422
+ }
423
+ }
424
+ return mutated ? decoded : record;
425
+ }
426
+
427
+ function omitTypeName(record: Record<string, unknown>): Record<string, unknown> {
428
+ const { $typeName: _, ...rest } = record;
429
+ return rest;
430
+ }
431
+
432
+ export const streamCursor: StreamFunction<"cursor-agent"> = (
433
+ model: Model<"cursor-agent">,
434
+ context: Context,
435
+ options?: CursorOptions,
436
+ ): AssistantMessageEventStream => {
437
+ const stream = new AssistantMessageEventStream();
438
+
439
+ (async () => {
440
+ const startTime = performance.now();
441
+ let firstTokenTime: number | undefined;
442
+
443
+ const output: AssistantMessage = {
444
+ role: "assistant",
445
+ content: [],
446
+ api: "cursor-agent" as Api,
447
+ provider: model.provider,
448
+ model: model.id,
449
+ usage: {
450
+ input: 0,
451
+ output: 0,
452
+ cacheRead: 0,
453
+ cacheWrite: 0,
454
+ totalTokens: 0,
455
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
456
+ },
457
+ stopReason: "stop",
458
+ timestamp: Date.now(),
459
+ };
460
+
461
+ // Declared outside the `try` because BOTH exits must drain it: an exec
462
+ // handler decoded from the last chunk can still be running when the
463
+ // transport fails, and the error path finalizes the synthesized call just
464
+ // like the success path does.
465
+ const inFlightDispatches = new Set<Promise<void>>();
466
+ // A dispatch can spawn another (a handler that decodes a nested frame), so
467
+ // re-check rather than awaiting one snapshot. Each dispatch already
468
+ // swallows its own rejection, so this only waits.
469
+ //
470
+ // The wait is bounded by the abort signal: exec handlers have no
471
+ // cancellation contract (the coding-agent bridge invokes `tool.execute`
472
+ // with no signal), so a hung or long-running tool would otherwise hold
473
+ // the terminal event hostage after the user already gave up on the turn.
474
+ // Once aborted, the Agent finalizes from the abort error and discards
475
+ // late results regardless, so skipping the rest of the drain loses
476
+ // nothing that could still be delivered.
477
+ let abortSettled: Promise<void> | undefined;
478
+ const drainInFlightDispatches = async (): Promise<void> => {
479
+ const signal = options?.signal;
480
+ while (inFlightDispatches.size > 0) {
481
+ if (signal?.aborted) return;
482
+ const settled = Promise.all([...inFlightDispatches]);
483
+ if (!signal) {
484
+ await settled;
485
+ continue;
486
+ }
487
+ abortSettled ??= new Promise<void>(resolve =>
488
+ signal.addEventListener("abort", () => resolve(), { once: true }),
489
+ );
490
+ await Promise.race([settled, abortSettled]);
491
+ }
492
+ };
493
+
494
+ let h2Client: http2.ClientHttp2Session | null = null;
495
+ let h2Request: http2.ClientHttp2Stream | null = null;
496
+ let heartbeatTimer: NodeJS.Timeout | null = null;
497
+ let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
498
+ const h2Completion = Promise.withResolvers<void>();
499
+ let h2Settled = false;
500
+ let sawTurnEnded = false;
501
+ let endStreamError: Error | null = null;
502
+ // Reachable from the catch: a stream that dies mid-turn must still close
503
+ // and pair the blocks it left open, and `state` itself is scoped to the
504
+ // try below.
505
+ let openBlockState: BlockState | undefined;
506
+ const settleH2 = (error?: unknown): void => {
507
+ if (h2Settled) return;
508
+ h2Settled = true;
509
+ if (error !== undefined) {
510
+ h2Completion.reject(error);
511
+ return;
512
+ }
513
+ if (endStreamError) {
514
+ h2Completion.reject(endStreamError);
515
+ return;
516
+ }
517
+ if (!sawTurnEnded) {
518
+ h2Completion.reject(
519
+ new AIError.ProviderResponseError("Cursor stream ended before turnEnded", {
520
+ kind: "incomplete-stream",
521
+ }),
522
+ );
523
+ return;
524
+ }
525
+ h2Completion.resolve();
526
+ };
527
+
528
+ try {
529
+ const apiKey = options?.apiKey;
530
+ if (!apiKey) {
531
+ throw new AIError.MissingApiKeyError(undefined, "Cursor API key (access token) is required");
532
+ }
533
+
534
+ const conversationId = options?.conversationId ?? options?.sessionId ?? crypto.randomUUID();
535
+ const blobStore = conversationBlobStores.get(conversationId) ?? new Map<string, Uint8Array>();
536
+ conversationBlobStores.set(conversationId, blobStore);
537
+ const cachedState = conversationStateCache.get(conversationId);
538
+ const { requestBytes, conversationState } = buildGrpcRequest(model, context, options, {
539
+ conversationId,
540
+ blobStore,
541
+ conversationState: cachedState,
542
+ });
543
+ conversationStateCache.set(conversationId, conversationState);
544
+ const requestContextTools = buildMcpToolDefinitions(context.tools);
545
+
546
+ const baseUrl = model.baseUrl || CURSOR_API_URL;
547
+ const requestPath = "/agent.v1.AgentService/Run";
548
+ const requestHeaders = {
549
+ ":method": "POST",
550
+ ":path": requestPath,
551
+ "content-type": "application/connect+proto",
552
+ "connect-protocol-version": "1",
553
+ te: "trailers",
554
+ authorization: `Bearer ${apiKey}`,
555
+ "x-ghost-mode": "true",
556
+ "x-cursor-client-version": CURSOR_CLIENT_VERSION,
557
+ "x-cursor-client-type": "cli",
558
+ "x-request-id": crypto.randomUUID(),
559
+ };
560
+ const debugSession = isRequestDebugEnabled()
561
+ ? await createRequestDebugSession({
562
+ protocol: "http2",
563
+ method: "POST",
564
+ url: new URL(requestPath, baseUrl).toString(),
565
+ headers: requestHeaders,
566
+ bodyBase64: Buffer.from(requestBytes).toString("base64"),
567
+ })
568
+ : undefined;
569
+
570
+ const proxyUrl = shouldBypassProxy(new URL(baseUrl)) ? undefined : getProxyForProvider(model.provider);
571
+ if (proxyUrl) {
572
+ const tlsSocket = await connectProxiedSocket(proxyUrl, baseUrl, {
573
+ signal: options?.signal,
574
+ timeoutMs: CURSOR_PROXY_TUNNEL_TIMEOUT_MS,
575
+ });
576
+ h2Client = http2.connect(baseUrl, {
577
+ createConnection: () => tlsSocket,
578
+ });
579
+ } else {
580
+ h2Client = http2.connect(baseUrl);
581
+ }
582
+ h2Client.on("error", error => settleH2(mapH2TransportError(error, baseUrl)));
583
+
584
+ h2Request = h2Client.request(requestHeaders);
585
+
586
+ stream.push({ type: "start", partial: output });
587
+
588
+ let pendingBuffer: Buffer = Buffer.alloc(0);
589
+ let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
590
+ let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
591
+ let currentToolCall: ToolCallState | null = null;
592
+ const resolvedMcpToolCallIds = new Set<string>();
593
+ const usageState: UsageState = { sawTokenDelta: false };
594
+
595
+ const state: BlockState = {
596
+ get currentTextBlock() {
597
+ return currentTextBlock;
598
+ },
599
+ get currentThinkingBlock() {
600
+ return currentThinkingBlock;
601
+ },
602
+ get currentToolCall() {
603
+ return currentToolCall;
604
+ },
605
+ openToolCalls: new Map<string, ToolCallState>(),
606
+ resolvedMcpToolCallIds,
607
+ get firstTokenTime() {
608
+ return firstTokenTime;
609
+ },
610
+ setTextBlock: b => {
611
+ currentTextBlock = b;
612
+ },
613
+ setThinkingBlock: b => {
614
+ currentThinkingBlock = b;
615
+ },
616
+ setToolCall: t => {
617
+ currentToolCall = t;
618
+ },
619
+ setFirstTokenTime: () => {
620
+ if (!firstTokenTime) firstTokenTime = performance.now();
621
+ },
622
+ onTodoSnapshot: options?.execHandlers?.todoSync?.bind(options.execHandlers),
623
+ onToolResult: options?.onToolResult,
624
+ };
625
+ openBlockState = state;
626
+
627
+ const onConversationCheckpoint = (checkpoint: ConversationStateStructure) => {
628
+ conversationStateCache.set(conversationId, checkpoint);
629
+ };
630
+
631
+ h2Request.on("response", headers => {
632
+ debugResponseLogPromise = debugSession?.openResponseLog(
633
+ `HTTP/2 ${headers[":status"] ?? ""}`.trim(),
634
+ headers,
635
+ );
636
+ });
637
+
638
+ h2Request.on("data", (chunk: Buffer) => {
639
+ if (debugResponseLogPromise) {
640
+ void debugResponseLogPromise.then(log => {
641
+ log?.write(chunk);
642
+ });
643
+ }
644
+ // Steady state drains fully per chunk; alias the fresh h2 chunk instead
645
+ // of copying it through Buffer.concat (see aws-eventstream.ts).
646
+ pendingBuffer = pendingBuffer.length === 0 ? chunk : Buffer.concat([pendingBuffer, chunk]);
647
+
648
+ while (pendingBuffer.length >= 5) {
649
+ const flags = pendingBuffer[0];
650
+ const msgLen = pendingBuffer.readUInt32BE(1);
651
+ if (pendingBuffer.length < 5 + msgLen) break;
652
+
653
+ const messageBytes = pendingBuffer.subarray(5, 5 + msgLen);
654
+ pendingBuffer = pendingBuffer.subarray(5 + msgLen);
655
+
656
+ if (flags & CONNECT_END_STREAM_FLAG) {
657
+ const endError = parseConnectEndStream(messageBytes);
658
+ if (endError) {
659
+ endStreamError = endError;
660
+ h2Request?.close();
661
+ }
662
+ continue;
663
+ }
664
+
665
+ try {
666
+ const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
667
+ const isTurnEnded =
668
+ serverMessage.message.case === "interactionUpdate" &&
669
+ serverMessage.message.value.message?.case === "turnEnded";
670
+ // Dispatch is fire-and-forget so the socket keeps draining while a
671
+ // handler runs, but the promise is tracked: `done` must not be
672
+ // pushed while an exec handler is still resolving, or the Agent
673
+ // drains its Cursor result buffer before the handler reserved its
674
+ // entry and the call is left unpaired. Awaited after
675
+ // `h2Completion` below.
676
+ const dispatch = handleServerMessage(
677
+ serverMessage,
678
+ output,
679
+ stream,
680
+ state,
681
+ blobStore,
682
+ h2Request!,
683
+ options?.execHandlers,
684
+ options?.onToolResult,
685
+ usageState,
686
+ requestContextTools,
687
+ onConversationCheckpoint,
688
+ ).catch(error => {
689
+ log("error", "handleServerMessage", { error: String(error) });
690
+ });
691
+ inFlightDispatches.add(dispatch);
692
+ void dispatch.finally(() => inFlightDispatches.delete(dispatch));
693
+
694
+ // Application completion is not protocol success; wait for a clean HTTP/2 end.
695
+ if (isTurnEnded) {
696
+ sawTurnEnded = true;
697
+ }
698
+ } catch (e) {
699
+ log("error", "parseServerMessage", { error: String(e) });
700
+ }
701
+ }
702
+ });
703
+
704
+ const sendHeartbeat = () => {
705
+ if (!h2Request || h2Request.closed) {
706
+ return;
707
+ }
708
+ const heartbeatMessage = create(AgentClientMessageSchema, {
709
+ message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) },
710
+ });
711
+ const heartbeatBytes = toBinary(AgentClientMessageSchema, heartbeatMessage);
712
+ h2Request.write(frameConnectMessage(heartbeatBytes));
713
+ };
714
+
715
+ const closeDebugLog = async (): Promise<void> => {
716
+ const log = await debugResponseLogPromise;
717
+ await log?.close();
718
+ };
719
+
720
+ h2Request.on("trailers", trailers => {
721
+ const status = trailers["grpc-status"];
722
+ const msg = trailers["grpc-message"];
723
+ if (status && status !== "0" && !endStreamError) {
724
+ endStreamError = new AIError.ProviderResponseError(
725
+ `gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
726
+ { kind: "envelope" },
727
+ );
728
+ }
729
+ });
730
+
731
+ h2Request.on("end", () => {
732
+ void closeDebugLog()
733
+ .then(() => settleH2())
734
+ .catch(error => settleH2(error));
735
+ });
736
+
737
+ h2Request.on("error", error => {
738
+ const mapped = mapH2TransportError(error, baseUrl);
739
+ void closeDebugLog().finally(() => settleH2(mapped));
740
+ });
741
+
742
+ if (options?.signal) {
743
+ options.signal.addEventListener("abort", () => {
744
+ h2Request?.close();
745
+ void closeDebugLog().finally(() => {
746
+ settleH2(new AIError.AbortError());
747
+ });
748
+ });
749
+ }
750
+
751
+ h2Request.write(frameConnectMessage(requestBytes));
752
+ heartbeatTimer = setInterval(sendHeartbeat, 5000);
753
+ await h2Completion.promise;
754
+ // The transport is done, but a handler decoded from the last chunk may
755
+ // still be running: exec handlers and `onToolResult` transformers are
756
+ // async. Pushing `done` now would let the Agent drain its Cursor result
757
+ // buffer before such a handler reserves its entry, leaving the call
758
+ // unpaired and stripped from every rebuilt transcript. Each dispatch
759
+ // already swallows its own rejection, so this only waits.
760
+ await drainInFlightDispatches();
761
+
762
+ endCurrentTextBlock(output, stream, state);
763
+ endCurrentThinkingBlock(output, stream, state);
764
+ flushOpenToolCalls(output, stream, state);
765
+
766
+ calculateCost(model, output.usage);
767
+
768
+ output.duration = performance.now() - startTime;
769
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
770
+ stream.push({
771
+ type: "done",
772
+ reason: output.stopReason as "stop" | "length" | "toolUse",
773
+ message: output,
774
+ });
775
+ stream.end();
776
+ } catch (error) {
777
+ // Same reason as the success path: the Agent finalizes the synthesized
778
+ // call from this terminal error and clears its Cursor result buffer, so
779
+ // a handler still running would land its real result after `agent_end`
780
+ // and be discarded — even though the tool may already have run side
781
+ // effects. Wait for it first; on abort the drain returns immediately
782
+ // (handlers have no cancellation contract and must not delay the
783
+ // terminal error the user asked for).
784
+ await drainInFlightDispatches();
785
+ // A stream that dies mid-turn leaves blocks open, and this is the path
786
+ // it takes: `settleH2` rejects when the transport closes without
787
+ // `turnEnded`, so the success-path flush above never runs. Closing
788
+ // them here settles their live cards and pairs the server-owned calls
789
+ // (`connect_scm`, native todo) that nothing else answers — an
790
+ // unpaired call is stripped from every rebuilt transcript.
791
+ // Undefined only when the failure predates the state's construction,
792
+ // in which case no block was ever opened.
793
+ if (openBlockState) {
794
+ endCurrentTextBlock(output, stream, openBlockState);
795
+ endCurrentThinkingBlock(output, stream, openBlockState);
796
+ flushOpenToolCalls(output, stream, openBlockState);
797
+ }
798
+ const result = await AIError.finalize(error, { api: model.api, signal: options?.signal });
799
+ output.stopReason = result.stopReason;
800
+ output.errorStatus = result.status;
801
+ output.errorId = result.id;
802
+ output.errorMessage = result.message;
803
+ output.duration = performance.now() - startTime;
804
+ if (firstTokenTime) output.ttft = firstTokenTime - startTime;
805
+ stream.push({ type: "error", reason: output.stopReason, error: output });
806
+ stream.end();
807
+ } finally {
808
+ const log = await debugResponseLogPromise;
809
+ await log?.close();
810
+ if (heartbeatTimer) {
811
+ clearInterval(heartbeatTimer);
812
+ heartbeatTimer = null;
813
+ }
814
+ h2Request?.close();
815
+ h2Client?.close();
816
+ }
817
+ })();
818
+
819
+ return stream;
820
+ };
821
+
822
+ export type ToolCallState = ToolCall & {
823
+ [kStreamingBlockIndex]: number;
824
+ [kStreamingPartialJson]?: string;
825
+ [kStreamingLastParseLen]?: number;
826
+ [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec" | "connect-scm";
827
+ [kStreamingEnvelopeId]?: string;
828
+ [kCursorExecResolved]?: true;
829
+ };
830
+
831
+ export interface BlockState {
832
+ currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null;
833
+ currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null;
834
+ currentToolCall: ToolCallState | null;
835
+ /**
836
+ * Open streamed tool-call blocks, keyed by the interaction envelope's
837
+ * `call_id`.
838
+ *
839
+ * Cursor interleaves calls: two `toolCallStarted` frames can arrive before
840
+ * either completes. A single "current" slot would let the second overwrite
841
+ * the first, orphaning a block that nothing then settles. Every keyed block
842
+ * stays reachable until its own completion, and `currentToolCall` remains
843
+ * only as the fallback for frames that carry no `call_id`.
844
+ */
845
+ openToolCalls: Map<string, ToolCallState>;
846
+ /** MCP call IDs synthesized from exec frames before their redundant streamed block arrives. */
847
+ resolvedMcpToolCallIds: Set<string>;
848
+ firstTokenTime: number | undefined;
849
+ setTextBlock: (b: (TextContent & { [kStreamingBlockIndex]: number }) | null) => void;
850
+ setThinkingBlock: (b: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null) => void;
851
+ setToolCall: (t: ToolCallState | null) => void;
852
+ setFirstTokenTime: () => void;
853
+ /** Mirror a server-confirmed todo snapshot into local session state. */
854
+ onTodoSnapshot?: CursorTodoSyncHandler;
855
+ /**
856
+ * Persist a paired `toolResult` for a server-resolved call. Native todo calls
857
+ * never travel the exec channel, so without this the resolved block has no
858
+ * matching result and every transcript rebuild strips it as dangling.
859
+ */
860
+ onToolResult?: CursorToolResultHandler;
861
+ }
862
+
863
+ function markCursorExecResolved(block: CursorExecResolvedCarrier): void {
864
+ block[kCursorExecResolved] = true;
865
+ }
866
+
867
+ export interface UsageState {
868
+ sawTokenDelta: boolean;
869
+ }
870
+
871
+ /** Exported for tests: drives one Cursor server message through the stream (exec waits mark the stream busy). */
872
+ export async function handleServerMessage(
873
+ msg: AgentServerMessage,
874
+ output: AssistantMessage,
875
+ stream: AssistantMessageEventStream,
876
+ state: BlockState,
877
+ blobStore: Map<string, Uint8Array>,
878
+ h2Request: http2.ClientHttp2Stream,
879
+ execHandlers: CursorExecHandlers | undefined,
880
+ onToolResult: CursorToolResultHandler | undefined,
881
+ usageState: UsageState,
882
+ requestContextTools: McpToolDefinition[],
883
+ onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void,
884
+ ): Promise<void> {
885
+ const msgCase = msg.message.case;
886
+
887
+ log("serverMessage", msgCase, msg.message.value);
888
+
889
+ if (msgCase === "interactionUpdate") {
890
+ processInteractionUpdate(msg.message.value, output, stream, state, usageState);
891
+ } else if (msgCase === "kvServerMessage") {
892
+ handleKvServerMessage(msg.message.value as KvServerMessage, blobStore, h2Request);
893
+ } else if (msgCase === "execServerMessage") {
894
+ // The server is waiting on OUR local tool result during this window — no
895
+ // AssistantMessageEvent flows until the handler finishes. Mark the wait
896
+ // as local work so the lazy stream idle watchdog attributes the silence
897
+ // to the tool run instead of aborting a healthy stream (issue #4593).
898
+ await stream.trackLocalWork(
899
+ handleExecServerMessage(
900
+ msg.message.value as ExecServerMessage,
901
+ h2Request,
902
+ execHandlers,
903
+ onToolResult,
904
+ requestContextTools,
905
+ output,
906
+ stream,
907
+ state,
908
+ ),
909
+ );
910
+ } else if (msgCase === "conversationCheckpointUpdate") {
911
+ handleConversationCheckpointUpdate(msg.message.value, output, usageState, onConversationCheckpoint);
912
+ }
913
+ }
914
+
915
+ function handleKvServerMessage(
916
+ kvMsg: KvServerMessage,
917
+ blobStore: Map<string, Uint8Array>,
918
+ h2Request: http2.ClientHttp2Stream,
919
+ ): void {
920
+ const kvCase = kvMsg.message.case;
921
+
922
+ if (kvCase === "getBlobArgs") {
923
+ const blobId = kvMsg.message.value.blobId;
924
+ const blobIdKey = Buffer.from(blobId).toString("hex");
925
+
926
+ const blobData = blobStore.get(blobIdKey);
927
+
928
+ const response = create(KvClientMessageSchema, {
929
+ id: kvMsg.id,
930
+ message: {
931
+ case: "getBlobResult",
932
+ value: create(GetBlobResultSchema, blobData ? { blobData } : {}),
933
+ },
934
+ });
935
+
936
+ const kvClientMessage = create(AgentClientMessageSchema, {
937
+ message: { case: "kvClientMessage", value: response },
938
+ });
939
+
940
+ const responseBytes = toBinary(AgentClientMessageSchema, kvClientMessage);
941
+ h2Request.write(frameConnectMessage(responseBytes));
942
+
943
+ log("kvClient", "getBlobResult", { blobId: blobIdKey.slice(0, 40) });
944
+ } else if (kvCase === "setBlobArgs") {
945
+ const { blobId, blobData } = kvMsg.message.value;
946
+ const blobIdKey = Buffer.from(blobId).toString("hex");
947
+ blobStore.set(blobIdKey, blobData);
948
+
949
+ const response = create(KvClientMessageSchema, {
950
+ id: kvMsg.id,
951
+ message: {
952
+ case: "setBlobResult",
953
+ value: create(SetBlobResultSchema, {}),
954
+ },
955
+ });
956
+
957
+ const kvClientMessage = create(AgentClientMessageSchema, {
958
+ message: { case: "kvClientMessage", value: response },
959
+ });
960
+
961
+ const responseBytes = toBinary(AgentClientMessageSchema, kvClientMessage);
962
+ h2Request.write(frameConnectMessage(responseBytes));
963
+
964
+ log("kvClient", "setBlobResult", { blobId: blobIdKey.slice(0, 40) });
965
+ }
966
+ }
967
+
968
+ function sendShellStreamEvent(
969
+ h2Request: http2.ClientHttp2Stream,
970
+ execMsg: ExecServerMessage,
971
+ event: ShellStream["event"],
972
+ ): void {
973
+ sendExecClientMessage(h2Request, execMsg, "shellStream", create(ShellStreamSchema, { event }));
974
+ }
975
+
976
+ function sanitizeShellExecResult(execResult: ShellResult): ShellResult {
977
+ const result = execResult.result;
978
+ if (!result) return execResult;
979
+
980
+ switch (result.case) {
981
+ case "success":
982
+ case "failure": {
983
+ const value = result.value;
984
+ return {
985
+ ...execResult,
986
+ result: {
987
+ case: result.case,
988
+ value: {
989
+ ...value,
990
+ stdout: value.stdout ? sanitizeText(value.stdout) : value.stdout,
991
+ stderr: value.stderr ? sanitizeText(value.stderr) : value.stderr,
992
+ },
993
+ },
994
+ } as ShellResult;
995
+ }
996
+ default:
997
+ return execResult;
998
+ }
999
+ }
1000
+
1001
+ async function handleShellStreamArgs(
1002
+ args: ShellArgs,
1003
+ execMsg: ExecServerMessage,
1004
+ h2Request: http2.ClientHttp2Stream,
1005
+ execHandlers: CursorExecHandlers | undefined,
1006
+ onToolResult: CursorToolResultHandler | undefined,
1007
+ ): Promise<void> {
1008
+ const normalizedWorkingDirectory = args.workingDirectory || process.cwd();
1009
+ const normalizedArgs: ShellArgs = { ...args, workingDirectory: normalizedWorkingDirectory };
1010
+ const startTs = performance.now();
1011
+ log("shellStream", "start", {
1012
+ command: (args as any).command,
1013
+ workingDirectory: normalizedWorkingDirectory,
1014
+ execId: execMsg.execId,
1015
+ hasExecHandlers: !!execHandlers,
1016
+ hasShell: !!execHandlers?.shell,
1017
+ hasShellStream: !!execHandlers?.shellStream,
1018
+ });
1019
+
1020
+ sendShellStreamEvent(h2Request, execMsg, { case: "start", value: create(ShellStreamStartSchema, {}) });
1021
+
1022
+ // Buffer for incomplete ANSI sequences across chunks
1023
+ let stdoutBuffer = "";
1024
+ let stderrBuffer = "";
1025
+
1026
+ const incompleteEscapeRegex = /\x1b(|\[|\[\d*|\[\?|\[\?\d*|\]\d*;?)$/;
1027
+
1028
+ const flushStdout = () => {
1029
+ if (stdoutBuffer) {
1030
+ let safeEnd = stdoutBuffer.length;
1031
+ const match = stdoutBuffer.match(incompleteEscapeRegex);
1032
+ if (match && match[0].length > 0) {
1033
+ safeEnd = stdoutBuffer.length - match[0].length;
1034
+ }
1035
+ const toSend = stdoutBuffer.slice(0, safeEnd);
1036
+ const remaining = stdoutBuffer.slice(safeEnd);
1037
+ if (toSend) {
1038
+ sendShellStreamEvent(h2Request, execMsg, {
1039
+ case: "stdout",
1040
+ value: create(ShellStreamStdoutSchema, { data: sanitizeText(toSend) }),
1041
+ });
1042
+ }
1043
+ stdoutBuffer = remaining;
1044
+ }
1045
+ };
1046
+
1047
+ const flushStderr = () => {
1048
+ if (stderrBuffer) {
1049
+ let safeEnd = stderrBuffer.length;
1050
+ const match = stderrBuffer.match(incompleteEscapeRegex);
1051
+ if (match && match[0].length > 0) {
1052
+ safeEnd = stderrBuffer.length - match[0].length;
1053
+ }
1054
+ const toSend = stderrBuffer.slice(0, safeEnd);
1055
+ const remaining = stderrBuffer.slice(safeEnd);
1056
+ if (toSend) {
1057
+ sendShellStreamEvent(h2Request, execMsg, {
1058
+ case: "stderr",
1059
+ value: create(ShellStreamStderrSchema, { data: sanitizeText(toSend) }),
1060
+ });
1061
+ }
1062
+ stderrBuffer = remaining;
1063
+ }
1064
+ };
1065
+
1066
+ let stdoutFlushTimer: NodeJS.Timeout | null = null;
1067
+ let stderrFlushTimer: NodeJS.Timeout | null = null;
1068
+
1069
+ const scheduleStdoutFlush = () => {
1070
+ if (!stdoutFlushTimer) {
1071
+ stdoutFlushTimer = setTimeout(() => {
1072
+ stdoutFlushTimer = null;
1073
+ flushStdout();
1074
+ }, 100);
1075
+ }
1076
+ };
1077
+
1078
+ const scheduleStderrFlush = () => {
1079
+ if (!stderrFlushTimer) {
1080
+ stderrFlushTimer = setTimeout(() => {
1081
+ stderrFlushTimer = null;
1082
+ flushStderr();
1083
+ }, 100);
1084
+ }
1085
+ };
1086
+
1087
+ const streamCallbacks: CursorShellStreamCallbacks = {
1088
+ onStdout(data: string) {
1089
+ stdoutBuffer += data;
1090
+ if (stdoutBuffer.includes("\n") || stdoutBuffer.length > 4096) {
1091
+ if (stdoutFlushTimer) {
1092
+ clearTimeout(stdoutFlushTimer);
1093
+ stdoutFlushTimer = null;
1094
+ }
1095
+ flushStdout();
1096
+ } else {
1097
+ scheduleStdoutFlush();
1098
+ }
1099
+ },
1100
+ onStderr(data: string) {
1101
+ stderrBuffer += data;
1102
+ if (stderrBuffer.includes("\n") || stderrBuffer.length > 4096) {
1103
+ if (stderrFlushTimer) {
1104
+ clearTimeout(stderrFlushTimer);
1105
+ stderrFlushTimer = null;
1106
+ }
1107
+ flushStderr();
1108
+ } else {
1109
+ scheduleStderrFlush();
1110
+ }
1111
+ },
1112
+ };
1113
+
1114
+ // Prefer the streaming handler — it forwards output chunks in real time.
1115
+ // Falls back to the batch shell handler otherwise.
1116
+ const streamHandler = execHandlers?.shellStream?.bind(execHandlers);
1117
+ const batchHandler = execHandlers?.shell?.bind(execHandlers);
1118
+ const handler = streamHandler ? (shellArgs: ShellArgs) => streamHandler(shellArgs, streamCallbacks) : batchHandler;
1119
+
1120
+ const { execResult } = await resolveExecHandler(
1121
+ args as any,
1122
+ handler as typeof batchHandler,
1123
+ onToolResult,
1124
+ toolResult => buildShellResultFromToolResult(normalizedArgs as any, toolResult),
1125
+ reason =>
1126
+ buildShellRejectedResult((normalizedArgs as any).command, (normalizedArgs as any).workingDirectory, reason),
1127
+ error =>
1128
+ buildShellFailureResult((normalizedArgs as any).command, (normalizedArgs as any).workingDirectory, error),
1129
+ { toolCallId: args.toolCallId, toolName: "bash" },
1130
+ );
1131
+
1132
+ // When using the batch handler (no shellStream), send buffered stdout/stderr
1133
+ // after execution completes. With shellStream these were already sent in real time.
1134
+ const sendBufferedOutput = !streamHandler;
1135
+ const sanitizedExecResult = sanitizeShellExecResult(execResult);
1136
+
1137
+ // Flush any remaining buffered output before sending results
1138
+ if (stdoutFlushTimer) clearTimeout(stdoutFlushTimer);
1139
+ if (stderrFlushTimer) clearTimeout(stderrFlushTimer);
1140
+ flushStdout();
1141
+ flushStderr();
1142
+
1143
+ sendShellStreamExitFromResult(h2Request, execMsg, sanitizedExecResult, sendBufferedOutput);
1144
+ // Cursor can keep the turn pending when it receives only stream deltas.
1145
+ // Send the final structured shellResult as completion acknowledgement.
1146
+ sendExecClientMessage(h2Request, execMsg, "shellResult", sanitizedExecResult);
1147
+ sendExecClientStreamClose(h2Request, execMsg);
1148
+
1149
+ log("shellStream", "done", { elapsed: performance.now() - startTs });
1150
+ }
1151
+
1152
+ function sendShellStreamExitFromResult(
1153
+ h2Request: http2.ClientHttp2Stream,
1154
+ execMsg: ExecServerMessage,
1155
+ execResult: ShellResult,
1156
+ sendBufferedOutput: boolean,
1157
+ ): void {
1158
+ const result = execResult.result;
1159
+ switch (result.case) {
1160
+ case "success": {
1161
+ const value = result.value;
1162
+ if (sendBufferedOutput) {
1163
+ if (value.stdout) {
1164
+ sendShellStreamEvent(h2Request, execMsg, {
1165
+ case: "stdout",
1166
+ value: create(ShellStreamStdoutSchema, { data: sanitizeText(value.stdout) }),
1167
+ });
1168
+ }
1169
+ if (value.stderr) {
1170
+ sendShellStreamEvent(h2Request, execMsg, {
1171
+ case: "stderr",
1172
+ value: create(ShellStreamStderrSchema, { data: sanitizeText(value.stderr) }),
1173
+ });
1174
+ }
1175
+ }
1176
+ sendShellStreamEvent(h2Request, execMsg, {
1177
+ case: "exit",
1178
+ value: create(ShellStreamExitSchema, {
1179
+ code: value.exitCode,
1180
+ cwd: value.workingDirectory,
1181
+ aborted: false,
1182
+ }),
1183
+ });
1184
+ return;
1185
+ }
1186
+ case "failure": {
1187
+ const value = result.value;
1188
+ if (sendBufferedOutput) {
1189
+ if (value.stdout) {
1190
+ sendShellStreamEvent(h2Request, execMsg, {
1191
+ case: "stdout",
1192
+ value: create(ShellStreamStdoutSchema, { data: sanitizeText(value.stdout) }),
1193
+ });
1194
+ }
1195
+ if (value.stderr) {
1196
+ sendShellStreamEvent(h2Request, execMsg, {
1197
+ case: "stderr",
1198
+ value: create(ShellStreamStderrSchema, { data: sanitizeText(value.stderr) }),
1199
+ });
1200
+ }
1201
+ }
1202
+ sendShellStreamEvent(h2Request, execMsg, {
1203
+ case: "exit",
1204
+ value: create(ShellStreamExitSchema, {
1205
+ code: value.exitCode,
1206
+ cwd: value.workingDirectory,
1207
+ aborted: value.aborted,
1208
+ abortReason: value.abortReason,
1209
+ }),
1210
+ });
1211
+ return;
1212
+ }
1213
+ case "rejected": {
1214
+ sendShellStreamEvent(h2Request, execMsg, { case: "rejected", value: result.value });
1215
+ sendShellStreamEvent(h2Request, execMsg, {
1216
+ case: "exit",
1217
+ value: create(ShellStreamExitSchema, {
1218
+ code: 1,
1219
+ cwd: result.value.workingDirectory,
1220
+ aborted: false,
1221
+ }),
1222
+ });
1223
+ return;
1224
+ }
1225
+ case "timeout": {
1226
+ const value = result.value;
1227
+ sendShellStreamEvent(h2Request, execMsg, {
1228
+ case: "stderr",
1229
+ value: create(ShellStreamStderrSchema, {
1230
+ data: `Command timed out after ${value.timeoutMs}ms`,
1231
+ }),
1232
+ });
1233
+ sendShellStreamEvent(h2Request, execMsg, {
1234
+ case: "exit",
1235
+ value: create(ShellStreamExitSchema, {
1236
+ code: 1,
1237
+ cwd: value.workingDirectory,
1238
+ aborted: true,
1239
+ }),
1240
+ });
1241
+ return;
1242
+ }
1243
+ case "permissionDenied": {
1244
+ sendShellStreamEvent(h2Request, execMsg, { case: "permissionDenied", value: result.value });
1245
+ sendShellStreamEvent(h2Request, execMsg, {
1246
+ case: "exit",
1247
+ value: create(ShellStreamExitSchema, {
1248
+ code: 1,
1249
+ cwd: result.value.workingDirectory,
1250
+ aborted: false,
1251
+ }),
1252
+ });
1253
+ return;
1254
+ }
1255
+ default:
1256
+ return;
1257
+ }
1258
+ }
1259
+
1260
+ async function handleExecServerMessage(
1261
+ execMsg: ExecServerMessage,
1262
+ h2Request: http2.ClientHttp2Stream,
1263
+ execHandlers: CursorExecHandlers | undefined,
1264
+ onToolResult: CursorToolResultHandler | undefined,
1265
+ requestContextTools: McpToolDefinition[],
1266
+ output: AssistantMessage,
1267
+ stream: AssistantMessageEventStream,
1268
+ state: BlockState,
1269
+ ): Promise<void> {
1270
+ const execCase = execMsg.message.case;
1271
+ log("exec", "dispatch", { execCase, execId: execMsg.execId, hasHandlers: !!execHandlers });
1272
+ if (execCase === "requestContextArgs") {
1273
+ const requestContext = create(RequestContextSchema, {
1274
+ rules: [],
1275
+ repositoryInfo: [],
1276
+ tools: requestContextTools,
1277
+ gitRepos: [],
1278
+ projectLayouts: [],
1279
+ mcpInstructions: [],
1280
+ fileContents: {},
1281
+ customSubagents: [],
1282
+ });
1283
+
1284
+ const requestContextResult = create(RequestContextResultSchema, {
1285
+ result: {
1286
+ case: "success",
1287
+ value: create(RequestContextSuccessSchema, { requestContext }),
1288
+ },
1289
+ });
1290
+
1291
+ sendExecClientMessage(h2Request, execMsg, "requestContextResult", requestContextResult);
1292
+ log("execClient", "requestContextResult");
1293
+ return;
1294
+ }
1295
+
1296
+ if (!execCase) {
1297
+ // A frame carrying a oneof number this build's `agent.proto` does not
1298
+ // model at all: protobuf decodes it into unknown fields and leaves
1299
+ // `message.case` unset, so the client cannot even name what was asked.
1300
+ // Returning silently strands the exec id — the server waits on a reply
1301
+ // that never comes. Distinct from the `default:` branch below, which
1302
+ // names a frame it recognises but cannot serve.
1303
+ log("warn", "unknownExecVariant", { id: execMsg.id, execId: execMsg.execId });
1304
+ sendExecClientThrow(h2Request, execMsg, "Unknown exec message variant", "unknown_exec_variant");
1305
+ return;
1306
+ }
1307
+
1308
+ switch (execCase) {
1309
+ case "readArgs": {
1310
+ const args = execMsg.message.value;
1311
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1312
+ // The same composed selector the bridge executes: showing a bare path
1313
+ // for a ranged read makes the returned slice look like the whole
1314
+ // file in every rebuilt transcript.
1315
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", {
1316
+ path: piReadDisplayPath(args.path, args.offset, args.limit),
1317
+ });
1318
+ const { execResult } = await resolveExecHandler(
1319
+ args,
1320
+ execHandlers?.read?.bind(execHandlers),
1321
+ onToolResult,
1322
+ toolResult =>
1323
+ buildReadResultFromToolResult(
1324
+ args.path,
1325
+ toolResult,
1326
+ args.offset !== undefined || args.limit !== undefined || piReadPathHasRange(args.path),
1327
+ ),
1328
+ reason => buildReadRejectedResult(args.path, reason),
1329
+ error => buildReadErrorResult(args.path, error),
1330
+ { toolCallId: args.toolCallId, toolName: "read" },
1331
+ );
1332
+ sendExecClientMessage(h2Request, execMsg, "readResult", execResult);
1333
+ return;
1334
+ }
1335
+ case "lsArgs": {
1336
+ const args = execMsg.message.value;
1337
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1338
+ // Bridge maps `ls` onto the coding-agent `read` tool (see
1339
+ // `CursorExecHandlers.ls` in `pi-coding-agent/src/cursor.ts`); mirror
1340
+ // that here so the synthesized block matches the toolResult's `toolName`.
1341
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "read", { path: args.path });
1342
+ const { execResult } = await resolveExecHandler(
1343
+ args,
1344
+ execHandlers?.ls?.bind(execHandlers),
1345
+ onToolResult,
1346
+ toolResult => buildLsResultFromToolResult(args.path, toolResult),
1347
+ reason => buildLsRejectedResult(args.path, reason),
1348
+ error => buildLsErrorResult(args.path, error),
1349
+ { toolCallId: args.toolCallId, toolName: "read" },
1350
+ );
1351
+ sendExecClientMessage(h2Request, execMsg, "lsResult", execResult);
1352
+ return;
1353
+ }
1354
+ case "grepArgs": {
1355
+ const args = execMsg.message.value;
1356
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1357
+ // Cursor's model sometimes emits `grepArgs` with an empty `pattern` and a
1358
+ // non-empty `glob`, expecting grep to list files matching the glob. Reject
1359
+ // that up front with an actionable error so the model retries with a real
1360
+ // regex or switches to `ls`/`read`, instead of the local grep tool
1361
+ // surfacing a bare "Pattern must not be empty" (issue #4574) after the
1362
+ // synthesized block has already been persisted with a placeholder pattern.
1363
+ const emptyPatternError = emptyGrepPatternRejection(args.pattern, args.glob);
1364
+ if (emptyPatternError !== null) {
1365
+ sendExecClientMessage(h2Request, execMsg, "grepResult", buildGrepErrorResult(emptyPatternError));
1366
+ return;
1367
+ }
1368
+ // Mirror the coding-agent bridge's arg mapping so live UI (from
1369
+ // `tool_execution_start`) and rebuilt transcript (from this block)
1370
+ // display identical args.
1371
+ const searchPath = args.glob ? `${args.path || "."}/${args.glob}` : args.path || ".";
1372
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "grep", {
1373
+ pattern: args.pattern,
1374
+ path: searchPath,
1375
+ case: args.caseInsensitive === true ? false : undefined,
1376
+ skip: piGrepSkip(args.offset),
1377
+ });
1378
+ const { execResult } = await resolveExecHandler(
1379
+ args,
1380
+ execHandlers?.grep?.bind(execHandlers),
1381
+ onToolResult,
1382
+ toolResult => buildGrepResultFromToolResult(args, toolResult),
1383
+ reason => buildGrepErrorResult(reason),
1384
+ error => buildGrepErrorResult(error),
1385
+ { toolCallId: args.toolCallId, toolName: "grep" },
1386
+ );
1387
+ sendExecClientMessage(h2Request, execMsg, "grepResult", execResult);
1388
+ return;
1389
+ }
1390
+ case "writeArgs": {
1391
+ const args = execMsg.message.value;
1392
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1393
+ // Match the bridge: prefer `fileText`, fall back to decoded `fileBytes`.
1394
+ const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array());
1395
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "write", {
1396
+ path: args.path,
1397
+ content,
1398
+ });
1399
+ const { execResult } = await resolveExecHandler(
1400
+ args,
1401
+ execHandlers?.write?.bind(execHandlers),
1402
+ onToolResult,
1403
+ toolResult =>
1404
+ buildWriteResultFromToolResult(
1405
+ {
1406
+ path: args.path,
1407
+ fileText: args.fileText,
1408
+ fileBytes: args.fileBytes,
1409
+ returnFileContentAfterWrite: args.returnFileContentAfterWrite,
1410
+ },
1411
+ toolResult,
1412
+ ),
1413
+ reason => buildWriteRejectedResult(args.path, reason),
1414
+ error => buildWriteErrorResult(args.path, error),
1415
+ { toolCallId: args.toolCallId, toolName: "write" },
1416
+ );
1417
+ sendExecClientMessage(h2Request, execMsg, "writeResult", execResult);
1418
+ return;
1419
+ }
1420
+ case "deleteArgs": {
1421
+ const args = execMsg.message.value;
1422
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1423
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "delete", { path: args.path });
1424
+ const { execResult } = await resolveExecHandler(
1425
+ args,
1426
+ execHandlers?.delete?.bind(execHandlers),
1427
+ onToolResult,
1428
+ toolResult => buildDeleteResultFromToolResult(args.path, toolResult),
1429
+ reason => buildDeleteRejectedResult(args.path, reason),
1430
+ error => buildDeleteErrorResult(args.path, error),
1431
+ { toolCallId: args.toolCallId, toolName: "delete" },
1432
+ );
1433
+ sendExecClientMessage(h2Request, execMsg, "deleteResult", execResult);
1434
+ return;
1435
+ }
1436
+ case "shellArgs": {
1437
+ const args = execMsg.message.value;
1438
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1439
+ const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() };
1440
+ // Match the bridge (`CursorExecHandlers.shell`): map `workingDirectory`
1441
+ // → `cwd`, drop non-positive timeouts.
1442
+ const shellTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
1443
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1444
+ command: args.command,
1445
+ cwd: args.workingDirectory || undefined,
1446
+ timeout: shellTimeout,
1447
+ });
1448
+ const { execResult } = await resolveExecHandler(
1449
+ args,
1450
+ execHandlers?.shell?.bind(execHandlers),
1451
+ onToolResult,
1452
+ toolResult => buildShellResultFromToolResult(normalizedArgs, toolResult),
1453
+ reason => buildShellRejectedResult(normalizedArgs.command, normalizedArgs.workingDirectory, reason),
1454
+ error => buildShellFailureResult(normalizedArgs.command, normalizedArgs.workingDirectory, error),
1455
+ { toolCallId: args.toolCallId, toolName: "bash" },
1456
+ );
1457
+ const sanitizedExecResult = sanitizeShellExecResult(execResult);
1458
+ sendExecClientMessage(h2Request, execMsg, "shellResult", sanitizedExecResult);
1459
+ return;
1460
+ }
1461
+ case "shellStreamArgs": {
1462
+ const args = execMsg.message.value;
1463
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1464
+ const shellStreamTimeout = args.timeout && args.timeout > 0 ? args.timeout : undefined;
1465
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1466
+ command: args.command,
1467
+ cwd: args.workingDirectory || undefined,
1468
+ timeout: shellStreamTimeout,
1469
+ });
1470
+ await handleShellStreamArgs(args, execMsg, h2Request, execHandlers, onToolResult);
1471
+ return;
1472
+ }
1473
+ case "backgroundShellSpawnArgs": {
1474
+ const args = execMsg.message.value;
1475
+ const execResult = create(BackgroundShellSpawnResultSchema, {
1476
+ result: {
1477
+ case: "rejected",
1478
+ value: create(ShellRejectedSchema, {
1479
+ command: args.command,
1480
+ workingDirectory: args.workingDirectory,
1481
+ reason: "Not implemented",
1482
+ isReadonly: false,
1483
+ }),
1484
+ },
1485
+ });
1486
+ sendExecClientMessage(h2Request, execMsg, "backgroundShellSpawnResult", execResult);
1487
+ return;
1488
+ }
1489
+ case "writeShellStdinArgs": {
1490
+ const execResult = create(WriteShellStdinResultSchema, {
1491
+ result: {
1492
+ case: "error",
1493
+ value: create(WriteShellStdinErrorSchema, {
1494
+ error: "Not implemented",
1495
+ }),
1496
+ },
1497
+ });
1498
+ sendExecClientMessage(h2Request, execMsg, "writeShellStdinResult", execResult);
1499
+ return;
1500
+ }
1501
+ case "fetchArgs": {
1502
+ const args = execMsg.message.value;
1503
+ const execResult = create(FetchResultSchema, {
1504
+ result: {
1505
+ case: "error",
1506
+ value: create(FetchErrorSchema, {
1507
+ url: args.url,
1508
+ error: "Not implemented",
1509
+ }),
1510
+ },
1511
+ });
1512
+ sendExecClientMessage(h2Request, execMsg, "fetchResult", execResult);
1513
+ return;
1514
+ }
1515
+ case "diagnosticsArgs": {
1516
+ const args = execMsg.message.value;
1517
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1518
+ // Bridge maps `diagnostics` onto the coding-agent `lsp` tool with
1519
+ // `action: "diagnostics"` and `file: path`.
1520
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "lsp", {
1521
+ action: "diagnostics",
1522
+ file: args.path,
1523
+ });
1524
+ const { execResult } = await resolveExecHandler(
1525
+ args,
1526
+ execHandlers?.diagnostics?.bind(execHandlers),
1527
+ onToolResult,
1528
+ toolResult => buildDiagnosticsResultFromToolResult(args.path, toolResult),
1529
+ reason => buildDiagnosticsRejectedResult(args.path, reason),
1530
+ error => buildDiagnosticsErrorResult(args.path, error),
1531
+ { toolCallId: args.toolCallId, toolName: "lsp" },
1532
+ );
1533
+ sendExecClientMessage(h2Request, execMsg, "diagnosticsResult", execResult);
1534
+ return;
1535
+ }
1536
+ case "mcpArgs": {
1537
+ const args = execMsg.message.value;
1538
+ const mcpCall = decodeMcpCall(args);
1539
+ // An approval probe, not an invocation: the frame asks whether the
1540
+ // call would be permitted. Running the tool to find out fires a side
1541
+ // effect the user has not been asked about, and fires it again when
1542
+ // the real frame follows — so this must answer without executing.
1543
+ //
1544
+ // The host resolves it against the same policy the wrapper applies at
1545
+ // execution time. Only a definite allow is approved: a pending prompt
1546
+ // cannot be asked through this frame, and answering yes on its behalf
1547
+ // would pre-authorize a call the user never saw. Without a handler
1548
+ // there is nothing to decide with, so it is refused. Either way no
1549
+ // block is synthesized — nothing ran.
1550
+ if (mcpCall.approvalOnly) {
1551
+ const approved = (await execHandlers?.mcpApprovalPreflight?.(mcpCall)) === true;
1552
+ sendExecClientMessage(
1553
+ h2Request,
1554
+ execMsg,
1555
+ "mcpResult",
1556
+ create(McpResultSchema, {
1557
+ result: approved
1558
+ ? { case: "approved", value: create(McpApprovedSchema, {}) }
1559
+ : {
1560
+ case: "rejected",
1561
+ value: create(McpRejectedSchema, {
1562
+ reason: `Tool "${mcpCall.toolName || mcpCall.name}" is not approved to run without asking.`,
1563
+ }),
1564
+ },
1565
+ }),
1566
+ );
1567
+ return;
1568
+ }
1569
+ if (execHandlers?.mcp) {
1570
+ const existingBlock = output.content.find(
1571
+ block => block.type === "toolCall" && block.id === mcpCall.toolCallId,
1572
+ );
1573
+ if (existingBlock) {
1574
+ markCursorExecResolved(existingBlock);
1575
+ } else {
1576
+ synthesizeCursorExecToolCall(
1577
+ output,
1578
+ stream,
1579
+ state,
1580
+ mcpCall.toolCallId,
1581
+ mcpCall.toolName || mcpCall.name,
1582
+ mcpCall.args,
1583
+ );
1584
+ state.resolvedMcpToolCallIds.add(mcpCall.toolCallId);
1585
+ }
1586
+ }
1587
+ const { execResult } = await resolveExecHandler(
1588
+ mcpCall,
1589
+ execHandlers?.mcp?.bind(execHandlers),
1590
+ onToolResult,
1591
+ toolResult => buildMcpResultFromToolResult(mcpCall, toolResult),
1592
+ _reason => buildMcpToolNotFoundResult(mcpCall),
1593
+ error => buildMcpErrorResult(error),
1594
+ execHandlers?.mcp ? { toolCallId: mcpCall.toolCallId, toolName: mcpCall.toolName } : null,
1595
+ );
1596
+ sendExecClientMessage(h2Request, execMsg, "mcpResult", execResult);
1597
+ return;
1598
+ }
1599
+ case "listMcpResourcesExecArgs": {
1600
+ // A host holding live MCP connections answers from them; without a
1601
+ // handler the honest answer is an explicit empty success. An
1602
+ // unset-oneof result would read as "the call produced nothing".
1603
+ const args = execMsg.message.value;
1604
+ let execResult: ListMcpResourcesExecResult;
1605
+ // The model consumes this catalog, so it needs a block and a paired
1606
+ // result or the listing is invisible in the UI and gone from every
1607
+ // rebuilt history. Only synthesized when a handler exists: without
1608
+ // one the frame is a fixed empty answer that executed nothing.
1609
+ const toolCallId = execHandlers?.listMcpResources ? crypto.randomUUID() : undefined;
1610
+ if (toolCallId) {
1611
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "list_mcp_resources", {
1612
+ server: args.server,
1613
+ });
1614
+ }
1615
+ try {
1616
+ const resources = (await execHandlers?.listMcpResources?.({ server: args.server })) ?? [];
1617
+ execResult = create(ListMcpResourcesExecResultSchema, {
1618
+ result: {
1619
+ case: "success",
1620
+ value: create(ListMcpResourcesSuccessSchema, {
1621
+ resources: resources.map(resource =>
1622
+ create(ListMcpResourcesExecResult_McpResourceSchema, {
1623
+ uri: resource.uri,
1624
+ name: resource.name,
1625
+ description: resource.description,
1626
+ mimeType: resource.mimeType,
1627
+ server: resource.server,
1628
+ }),
1629
+ ),
1630
+ }),
1631
+ },
1632
+ });
1633
+ } catch (error) {
1634
+ execResult = create(ListMcpResourcesExecResultSchema, {
1635
+ result: {
1636
+ case: "error",
1637
+ value: create(ListMcpResourcesErrorSchema, {
1638
+ error: error instanceof Error ? error.message : String(error),
1639
+ }),
1640
+ },
1641
+ });
1642
+ }
1643
+ if (toolCallId) {
1644
+ // Derived from the answer that goes on the wire, so the block can
1645
+ // never disagree with what the model was told.
1646
+ const settled = execResult.result;
1647
+ const text =
1648
+ settled.case === "success"
1649
+ ? formatListedMcpResources(settled.value.resources)
1650
+ : settled.case === "error"
1651
+ ? settled.value.error || "Failed to list MCP resources"
1652
+ : (settled.value?.reason ?? "Failed to list MCP resources");
1653
+ await pairSynthesizedExecResult(
1654
+ state,
1655
+ onToolResult,
1656
+ toolCallId,
1657
+ "list_mcp_resources",
1658
+ text,
1659
+ settled.case !== "success",
1660
+ );
1661
+ }
1662
+ sendExecClientMessage(h2Request, execMsg, "listMcpResourcesExecResult", execResult);
1663
+ return;
1664
+ }
1665
+ case "readMcpResourceExecArgs": {
1666
+ const args = execMsg.message.value;
1667
+ let execResult: ReadMcpResourceExecResult;
1668
+ // The read runs locally, and in download mode it writes a workspace
1669
+ // file — an operation with no transcript block is invisible in the UI
1670
+ // and absent from every rebuilt history. Only synthesized when a
1671
+ // handler exists: without one the frame is a fixed `not_found` that
1672
+ // executed nothing, and a block would claim work that never happened.
1673
+ const toolCallId = execHandlers?.readMcpResource ? crypto.randomUUID() : undefined;
1674
+ if (toolCallId) {
1675
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "read_mcp_resource", {
1676
+ server: args.server,
1677
+ uri: args.uri,
1678
+ download_path: args.downloadPath,
1679
+ });
1680
+ }
1681
+ try {
1682
+ // `null` is the handler's "no such server or uri", which is exactly
1683
+ // `not_found`; a throw is a real failure and must not masquerade as
1684
+ // a missing resource.
1685
+ const content = await execHandlers?.readMcpResource?.({
1686
+ server: args.server,
1687
+ uri: args.uri,
1688
+ downloadPath: args.downloadPath,
1689
+ });
1690
+ execResult = content
1691
+ ? create(ReadMcpResourceExecResultSchema, {
1692
+ result: {
1693
+ case: "success",
1694
+ value: create(ReadMcpResourceSuccessSchema, {
1695
+ uri: content.uri,
1696
+ name: content.name,
1697
+ description: content.description,
1698
+ mimeType: content.mimeType,
1699
+ downloadPath: content.downloadPath,
1700
+ // A download returns no content to the model: the file is
1701
+ // on disk and the path is the answer. Otherwise the wire's
1702
+ // content oneof carries one of the two, text winning when
1703
+ // a host supplies both.
1704
+ content:
1705
+ content.downloadPath !== undefined
1706
+ ? { case: undefined }
1707
+ : content.text !== undefined
1708
+ ? { case: "text", value: content.text }
1709
+ : content.blob !== undefined
1710
+ ? { case: "blob", value: content.blob }
1711
+ : { case: undefined },
1712
+ }),
1713
+ },
1714
+ })
1715
+ : create(ReadMcpResourceExecResultSchema, {
1716
+ result: { case: "notFound", value: create(ReadMcpResourceNotFoundSchema, { uri: args.uri }) },
1717
+ });
1718
+ } catch (error) {
1719
+ execResult = create(ReadMcpResourceExecResultSchema, {
1720
+ result: {
1721
+ case: "error",
1722
+ value: create(ReadMcpResourceErrorSchema, {
1723
+ uri: args.uri,
1724
+ error: error instanceof Error ? error.message : String(error),
1725
+ }),
1726
+ },
1727
+ });
1728
+ }
1729
+ if (toolCallId) {
1730
+ // Derived from the answer that actually goes on the wire, so no exit
1731
+ // can drift out of sync with what the model was told.
1732
+ const settled = execResult.result;
1733
+ let text: string;
1734
+ switch (settled.case) {
1735
+ case "success":
1736
+ text = settled.value.downloadPath
1737
+ ? `Downloaded ${args.uri} to ${settled.value.downloadPath}`
1738
+ : `Read ${args.uri}`;
1739
+ break;
1740
+ case "notFound":
1741
+ text = `No such resource: ${args.uri}`;
1742
+ break;
1743
+ // The wire union carries a refusal variant this client never
1744
+ // builds today — the handler answers content or `null`. Handled
1745
+ // anyway so the switch stays total: it holds a `reason`, not an
1746
+ // `error`, so a collapsed default would have read `undefined`.
1747
+ case "rejected":
1748
+ text = `Refused: ${settled.value.reason}`;
1749
+ break;
1750
+ default:
1751
+ text = settled.value?.error ?? `Failed to read ${args.uri}`;
1752
+ break;
1753
+ }
1754
+ await pairSynthesizedExecResult(
1755
+ state,
1756
+ onToolResult,
1757
+ toolCallId,
1758
+ "read_mcp_resource",
1759
+ text,
1760
+ settled.case !== "success",
1761
+ );
1762
+ }
1763
+ sendExecClientMessage(h2Request, execMsg, "readMcpResourceExecResult", execResult);
1764
+ return;
1765
+ }
1766
+ case "recordScreenArgs": {
1767
+ const execResult = create(RecordScreenResultSchema, {
1768
+ result: { case: "failure", value: create(RecordScreenFailureSchema, { error: NOT_IMPLEMENTED }) },
1769
+ });
1770
+ sendExecClientMessage(h2Request, execMsg, "recordScreenResult", execResult);
1771
+ return;
1772
+ }
1773
+ case "computerUseArgs": {
1774
+ const execResult = create(ComputerUseResultSchema, {
1775
+ result: { case: "error", value: create(ComputerUseErrorSchema, { error: NOT_IMPLEMENTED }) },
1776
+ });
1777
+ sendExecClientMessage(h2Request, execMsg, "computerUseResult", execResult);
1778
+ return;
1779
+ }
1780
+ case "piReadArgs": {
1781
+ const args = execMsg.message.value;
1782
+ const toolCallId = crypto.randomUUID();
1783
+ // The displayed block must show the operation that actually runs: the
1784
+ // bridge composes the same range selector onto the path.
1785
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "read", {
1786
+ path: piReadDisplayPath(args.path, args.offset, args.limit),
1787
+ });
1788
+ const { execResult } = await resolveExecHandler(
1789
+ { args, toolCallId },
1790
+ execHandlers?.piRead?.bind(execHandlers),
1791
+ onToolResult,
1792
+ buildPiReadResult,
1793
+ buildPiReadError,
1794
+ buildPiReadError,
1795
+ { toolCallId, toolName: "read" },
1796
+ );
1797
+ sendExecClientMessage(h2Request, execMsg, "piReadResult", execResult);
1798
+ return;
1799
+ }
1800
+ case "piBashArgs": {
1801
+ const args = execMsg.message.value;
1802
+ const toolCallId = crypto.randomUUID();
1803
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "bash", {
1804
+ command: args.command,
1805
+ timeout: piTimeout(args.timeout),
1806
+ });
1807
+ const { execResult } = await resolveExecHandler(
1808
+ { args, toolCallId },
1809
+ execHandlers?.piBash?.bind(execHandlers),
1810
+ onToolResult,
1811
+ buildPiBashResult,
1812
+ buildPiBashError,
1813
+ buildPiBashError,
1814
+ { toolCallId, toolName: "bash" },
1815
+ );
1816
+ sendExecClientMessage(h2Request, execMsg, "piBashResult", execResult);
1817
+ return;
1818
+ }
1819
+ case "piEditArgs": {
1820
+ const args = execMsg.message.value;
1821
+ const toolCallId = crypto.randomUUID();
1822
+ // `PiEditReplacement` maps onto the local `edit` tool's replace mode:
1823
+ // one snake_case `old_string`/`new_string` per call. Multi-replacement
1824
+ // frames display the first replacement; the exec handler applies all.
1825
+ const firstEdit = args.edits[0];
1826
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "edit", {
1827
+ path: args.path,
1828
+ old_string: firstEdit?.oldText ?? "",
1829
+ new_string: firstEdit?.newText ?? "",
1830
+ });
1831
+ const { execResult } = await resolveExecHandler(
1832
+ { args, toolCallId },
1833
+ execHandlers?.piEdit?.bind(execHandlers),
1834
+ onToolResult,
1835
+ buildPiEditResult,
1836
+ buildPiEditRejected,
1837
+ buildPiEditError,
1838
+ { toolCallId, toolName: "edit" },
1839
+ );
1840
+ sendExecClientMessage(h2Request, execMsg, "piEditResult", execResult);
1841
+ return;
1842
+ }
1843
+ case "piWriteArgs": {
1844
+ const args = execMsg.message.value;
1845
+ const toolCallId = crypto.randomUUID();
1846
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "write", {
1847
+ path: args.path,
1848
+ content: args.content,
1849
+ });
1850
+ const { execResult } = await resolveExecHandler(
1851
+ { args, toolCallId },
1852
+ execHandlers?.piWrite?.bind(execHandlers),
1853
+ onToolResult,
1854
+ buildPiWriteResult,
1855
+ buildPiWriteRejected,
1856
+ buildPiWriteError,
1857
+ { toolCallId, toolName: "write" },
1858
+ );
1859
+ sendExecClientMessage(h2Request, execMsg, "piWriteResult", execResult);
1860
+ return;
1861
+ }
1862
+ case "piGrepArgs": {
1863
+ const args = execMsg.message.value;
1864
+ const toolCallId = crypto.randomUUID();
1865
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "grep", {
1866
+ pattern: args.literal === true ? piEscapeRegexLiteral(args.pattern) : args.pattern,
1867
+ path: args.glob ? piJoinPath(args.path, args.glob) : args.path || ".",
1868
+ case: args.ignoreCase === true ? false : undefined,
1869
+ // Neither field exists in the model-facing `grep` schema — the bridge
1870
+ // serves them by building a scoped tool instead. Recorded anyway, for
1871
+ // the same reason `pi_read` renders its range into the displayed path:
1872
+ // a capped or context-widened search is otherwise replayed as an
1873
+ // ordinary grep sitting next to output no ordinary grep produces.
1874
+ context: args.context,
1875
+ limit: piLimit(args.limit),
1876
+ });
1877
+ const { execResult } = await resolveExecHandler(
1878
+ { args, toolCallId },
1879
+ execHandlers?.piGrep?.bind(execHandlers),
1880
+ onToolResult,
1881
+ buildPiGrepResult,
1882
+ buildPiGrepError,
1883
+ buildPiGrepError,
1884
+ { toolCallId, toolName: "grep" },
1885
+ );
1886
+ sendExecClientMessage(h2Request, execMsg, "piGrepResult", execResult);
1887
+ return;
1888
+ }
1889
+ case "piFindArgs": {
1890
+ const args = execMsg.message.value;
1891
+ const toolCallId = crypto.randomUUID();
1892
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "glob", {
1893
+ path: piJoinPath(args.path, args.pattern),
1894
+ limit: piLimit(args.limit),
1895
+ });
1896
+ const { execResult } = await resolveExecHandler(
1897
+ { args, toolCallId },
1898
+ execHandlers?.piFind?.bind(execHandlers),
1899
+ onToolResult,
1900
+ buildPiFindResult,
1901
+ buildPiFindError,
1902
+ buildPiFindError,
1903
+ { toolCallId, toolName: "glob" },
1904
+ );
1905
+ sendExecClientMessage(h2Request, execMsg, "piFindResult", execResult);
1906
+ return;
1907
+ }
1908
+ case "piLsArgs": {
1909
+ const args = execMsg.message.value;
1910
+ const toolCallId = crypto.randomUUID();
1911
+ // Same mapping as the legacy `lsArgs` frame: the local `read` tool lists
1912
+ // directories, so the synthesized block must name `read` to match the
1913
+ // bridge's own `toolResult`.
1914
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "read", { path: piLsPath(args.path) });
1915
+ const { execResult } = await resolveExecHandler(
1916
+ { args, toolCallId },
1917
+ execHandlers?.piLs?.bind(execHandlers),
1918
+ onToolResult,
1919
+ buildPiLsResult,
1920
+ buildPiLsError,
1921
+ buildPiLsError,
1922
+ { toolCallId, toolName: "read" },
1923
+ );
1924
+ sendExecClientMessage(h2Request, execMsg, "piLsResult", execResult);
1925
+ return;
1926
+ }
1927
+ case "miniSweAgentBashArgs": {
1928
+ // Same `ShellArgs`/`ShellResult` pair as `shellArgs`, under its own frame
1929
+ // number, so the existing shell handler answers it unchanged.
1930
+ const args = execMsg.message.value;
1931
+ if (!args.toolCallId) args.toolCallId = crypto.randomUUID();
1932
+ const normalizedArgs: ShellArgs = { ...args, workingDirectory: args.workingDirectory || process.cwd() };
1933
+ synthesizeCursorExecToolCall(output, stream, state, args.toolCallId, "bash", {
1934
+ command: args.command,
1935
+ cwd: args.workingDirectory || undefined,
1936
+ timeout: args.timeout && args.timeout > 0 ? args.timeout : undefined,
1937
+ });
1938
+ const { execResult } = await resolveExecHandler(
1939
+ normalizedArgs,
1940
+ execHandlers?.shell?.bind(execHandlers),
1941
+ onToolResult,
1942
+ toolResult => buildShellResultFromToolResult(normalizedArgs, toolResult),
1943
+ reason => buildShellRejectedResult(normalizedArgs.command, normalizedArgs.workingDirectory, reason),
1944
+ error => buildShellFailureResult(normalizedArgs.command, normalizedArgs.workingDirectory, error),
1945
+ { toolCallId: args.toolCallId, toolName: "bash" },
1946
+ );
1947
+ sendExecClientMessage(h2Request, execMsg, "miniSweAgentBashResult", sanitizeShellExecResult(execResult));
1948
+ return;
1949
+ }
1950
+ case "redactedReadArgs": {
1951
+ // Same `ReadArgs`/`ReadResult` pair as `readArgs`, but the server expects
1952
+ // the client to strip secrets from the content first. No redaction is
1953
+ // implemented here, and serving a plain read would hand back exactly the
1954
+ // unredacted bytes the frame exists to withhold.
1955
+ const args = execMsg.message.value;
1956
+ sendExecClientMessage(
1957
+ h2Request,
1958
+ execMsg,
1959
+ "redactedReadResult",
1960
+ buildReadErrorResult(args.path, "Secret redaction is not implemented by this client"),
1961
+ );
1962
+ return;
1963
+ }
1964
+ case "mcpStateExecArgs": {
1965
+ const args = execMsg.message.value;
1966
+ sendExecClientMessage(
1967
+ h2Request,
1968
+ execMsg,
1969
+ "mcpStateExecResult",
1970
+ buildMcpStateResult(requestContextTools, args.serverIdentifiers),
1971
+ );
1972
+ return;
1973
+ }
1974
+ case "executeHookArgs": {
1975
+ const args = execMsg.message.value;
1976
+ const execResult = buildNeutralHookResult(args.request);
1977
+ if (!execResult) {
1978
+ sendExecClientThrow(
1979
+ h2Request,
1980
+ execMsg,
1981
+ `Unsupported hook request: ${args.request?.request.case ?? "unset"}`,
1982
+ "unknown_hook_request",
1983
+ );
1984
+ return;
1985
+ }
1986
+ sendExecClientMessage(h2Request, execMsg, "executeHookResult", execResult);
1987
+ return;
1988
+ }
1989
+ case "subagentArgs": {
1990
+ const args = execMsg.message.value;
1991
+ const execResult = create(SubagentResultSchema, {
1992
+ result: {
1993
+ case: "error",
1994
+ value: create(SubagentErrorSchema, { error: `Subagents are ${NOT_IMPLEMENTED_SUFFIX}` }),
1995
+ },
1996
+ });
1997
+ log("exec", "subagentRejected", { subagentType: args.subagentType });
1998
+ sendExecClientMessage(h2Request, execMsg, "subagentResult", execResult);
1999
+ return;
2000
+ }
2001
+ case "subagentAwaitArgs": {
2002
+ // No subagent was ever spawned, so every awaited id is genuinely unknown.
2003
+ const args = execMsg.message.value;
2004
+ const execResult = create(SubagentAwaitResultSchema, {
2005
+ result: {
2006
+ case: "notFound",
2007
+ value: create(SubagentAwaitNotFoundSchema, { agentId: args.agentId }),
2008
+ },
2009
+ });
2010
+ sendExecClientMessage(h2Request, execMsg, "subagentAwaitResult", execResult);
2011
+ return;
2012
+ }
2013
+ case "forceBackgroundShellArgs": {
2014
+ // Backgrounding targets a running tool call by id. This client runs every
2015
+ // shell to completion in band, so there is never one to move.
2016
+ const execResult = create(ForceBackgroundShellResultSchema, {
2017
+ status: ForceBackgroundShellStatus.NOT_FOUND,
2018
+ });
2019
+ sendExecClientMessage(h2Request, execMsg, "forceBackgroundShellResult", execResult);
2020
+ return;
2021
+ }
2022
+ case "forceBackgroundSubagentArgs": {
2023
+ const execResult = create(ForceBackgroundSubagentResultSchema, {
2024
+ status: ForceBackgroundSubagentStatus.NOT_FOUND,
2025
+ });
2026
+ sendExecClientMessage(h2Request, execMsg, "forceBackgroundSubagentResult", execResult);
2027
+ return;
2028
+ }
2029
+ case "smartModeClassifierArgs": {
2030
+ // The classifier decides whether a risky action needs approval. Answering
2031
+ // `ALLOW` would silently wave through actions the server asked us to
2032
+ // judge, so the honest answer is that no classifier exists here.
2033
+ const execResult = create(SmartModeClassifierResultSchema, {
2034
+ result: {
2035
+ case: "error",
2036
+ value: create(SmartModeClassifierErrorSchema, {
2037
+ error: `Smart-mode classification is ${NOT_IMPLEMENTED_SUFFIX}`,
2038
+ }),
2039
+ },
2040
+ });
2041
+ sendExecClientMessage(h2Request, execMsg, "smartModeClassifierResult", execResult);
2042
+ return;
2043
+ }
2044
+ case "canvasDiagnosticsArgs": {
2045
+ const args = execMsg.message.value;
2046
+ const execResult = create(CanvasDiagnosticsResultSchema, {
2047
+ result: {
2048
+ case: "error",
2049
+ value: create(CanvasDiagnosticsErrorSchema, {
2050
+ path: args.path,
2051
+ error: `Canvas diagnostics are ${NOT_IMPLEMENTED_SUFFIX}`,
2052
+ }),
2053
+ },
2054
+ });
2055
+ sendExecClientMessage(h2Request, execMsg, "canvasDiagnosticsResult", execResult);
2056
+ return;
2057
+ }
2058
+ case "shellAllowlistPrecheckArgs": {
2059
+ // The prechecks ask "is this pre-approved, so may it skip the approval
2060
+ // prompt?". This client keeps no allowlist, so the answer is always no:
2061
+ // `false` costs an approval round-trip, `true` would grant one that was
2062
+ // never configured.
2063
+ sendExecClientMessage(
2064
+ h2Request,
2065
+ execMsg,
2066
+ "shellAllowlistPrecheckResult",
2067
+ create(ShellAllowlistPrecheckResultSchema, { allowlisted: false }),
2068
+ );
2069
+ return;
2070
+ }
2071
+ case "mcpAllowlistPrecheckArgs": {
2072
+ sendExecClientMessage(
2073
+ h2Request,
2074
+ execMsg,
2075
+ "mcpAllowlistPrecheckResult",
2076
+ create(McpAllowlistPrecheckResultSchema, { allowlisted: false }),
2077
+ );
2078
+ return;
2079
+ }
2080
+ case "webFetchAllowlistPrecheckArgs": {
2081
+ sendExecClientMessage(
2082
+ h2Request,
2083
+ execMsg,
2084
+ "webFetchAllowlistPrecheckResult",
2085
+ create(WebFetchAllowlistPrecheckResultSchema, { allowlisted: false }),
2086
+ );
2087
+ return;
2088
+ }
2089
+ case "conversationSearchArgs": {
2090
+ // Cursor conversation history lives server-side; this client keeps no
2091
+ // local index of it to search.
2092
+ //
2093
+ // The streamed `search_conversations_tool_call` envelope announces this
2094
+ // call but the interaction decoder builds no block for it, so the block
2095
+ // and its paired result are synthesized here — exactly like every other
2096
+ // exec frame. Without the pair, `buildSessionContext` strips the whole
2097
+ // interaction on replay. The frame carries its own `tool_call_id`, so
2098
+ // the streamed announcement and this block agree on the key.
2099
+ const args = execMsg.message.value;
2100
+ const toolCallId = args.toolCallId || crypto.randomUUID();
2101
+ const error = `Conversation search is ${NOT_IMPLEMENTED_SUFFIX}`;
2102
+ synthesizeCursorExecToolCall(output, stream, state, toolCallId, "search_conversations", {
2103
+ query: args.query,
2104
+ limit: args.limit,
2105
+ });
2106
+ await pairSynthesizedExecResult(state, onToolResult, toolCallId, "search_conversations", error);
2107
+ const execResult = create(ConversationSearchResultSchema, {
2108
+ result: { case: "error", value: create(ConversationSearchErrorSchema, { error }) },
2109
+ });
2110
+ sendExecClientMessage(h2Request, execMsg, "conversationSearchResult", execResult);
2111
+ return;
2112
+ }
2113
+ case "agentStoreConflictArgs": {
2114
+ // The agent store is Cursor's own on-disk journal; this client never
2115
+ // writes one, so it has no conflict events to replay.
2116
+ const execResult = create(AgentStoreConflictResultSchema, {
2117
+ result: {
2118
+ case: "error",
2119
+ value: create(AgentStoreConflictErrorSchema, {
2120
+ error: `Agent store conflicts are ${NOT_IMPLEMENTED_SUFFIX}`,
2121
+ }),
2122
+ },
2123
+ });
2124
+ sendExecClientMessage(h2Request, execMsg, "agentStoreConflictResult", execResult);
2125
+ return;
2126
+ }
2127
+ case "gitDiffRequest": {
2128
+ // `GetDiffResponse` has no error variant: it models five output formats
2129
+ // plus before/after file contents and nothing else. Any in-band answer is
2130
+ // therefore a claim that a diff was computed, so a `throw` is the only
2131
+ // truthful reply.
2132
+ sendExecClientThrow(h2Request, execMsg, `Git diff is ${NOT_IMPLEMENTED_SUFFIX}`, "exec_variant_unsupported");
2133
+ return;
2134
+ }
2135
+ default: {
2136
+ // A frame number this build recognises structurally but has no answer
2137
+ // for. Distinct from the unset-case path above: there the client cannot
2138
+ // even name the frame.
2139
+ log("warn", "unhandledExecMessage", { execCase });
2140
+ sendExecClientThrow(
2141
+ h2Request,
2142
+ execMsg,
2143
+ `No handler for exec message of type ${execCase}`,
2144
+ "exec_variant_unsupported",
2145
+ );
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ /**
2151
+ * Send one typed answer on the exec channel.
2152
+ *
2153
+ * `ExecClientMessage["message"]` is a discriminated union pairing each case
2154
+ * with its own result type, so the generic is keyed on the case: passing a
2155
+ * `ReadResult` under `"shellResult"` is a compile error rather than a wire
2156
+ * message the server rejects at runtime.
2157
+ */
2158
+ function sendExecClientMessage<TCase extends NonNullable<ExecClientMessage["message"]["case"]>>(
2159
+ h2Request: http2.ClientHttp2Stream,
2160
+ execMsg: ExecServerMessage,
2161
+ messageCase: TCase,
2162
+ value: Extract<ExecClientMessage["message"], { case: TCase }>["value"],
2163
+ ): void {
2164
+ const execClientMessage = create(ExecClientMessageSchema, {
2165
+ id: execMsg.id,
2166
+ execId: execMsg.execId,
2167
+ message: { case: messageCase, value } as ExecClientMessage["message"],
2168
+ });
2169
+
2170
+ const clientMessage = create(AgentClientMessageSchema, {
2171
+ message: { case: "execClientMessage", value: execClientMessage },
2172
+ });
2173
+
2174
+ const responseBytes = toBinary(AgentClientMessageSchema, clientMessage);
2175
+ h2Request.write(frameConnectMessage(responseBytes));
2176
+
2177
+ log("execClientMessage", messageCase, value);
2178
+ }
2179
+
2180
+ /**
2181
+ * Fail one exec frame in band.
2182
+ *
2183
+ * `ExecClientThrow` is the protocol's failure channel for a frame that cannot
2184
+ * be answered at all — as opposed to a frame answered with its own typed error
2185
+ * variant, which means "the tool ran and failed". Cursor's own executor sends
2186
+ * exactly this for a frame no handler claims
2187
+ * (`agent-exec/dist/index.js`: `No handler found for server message of type …`
2188
+ * → `case: 'throw'` then `streamClose`), so the server already knows how to
2189
+ * recover from it: it surfaces the error to the model instead of blocking on a
2190
+ * reply that never comes.
2191
+ *
2192
+ * The alternative this replaces — writing an `ExecClientMessage` whose `message`
2193
+ * oneof is unset — is not a valid answer: the server sees a reply carrying no
2194
+ * result and cannot tell it apart from a malformed frame.
2195
+ */
2196
+ function sendExecClientThrow(
2197
+ h2Request: http2.ClientHttp2Stream,
2198
+ execMsg: ExecServerMessage,
2199
+ error: string,
2200
+ errorCode?: string,
2201
+ ): void {
2202
+ const controlMessage = create(ExecClientControlMessageSchema, {
2203
+ message: {
2204
+ case: "throw",
2205
+ value: create(ExecClientThrowSchema, { id: execMsg.id, error, errorCode }),
2206
+ },
2207
+ });
2208
+ const clientMessage = create(AgentClientMessageSchema, {
2209
+ message: { case: "execClientControlMessage", value: controlMessage },
2210
+ });
2211
+ h2Request.write(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
2212
+ log("execClientControl", "throw", { id: execMsg.id, execId: execMsg.execId, error, errorCode });
2213
+ sendExecClientStreamClose(h2Request, execMsg);
2214
+ }
2215
+
2216
+ function sendExecClientStreamClose(h2Request: http2.ClientHttp2Stream, execMsg: ExecServerMessage): void {
2217
+ const closeMessage = create(ExecClientControlMessageSchema, {
2218
+ message: {
2219
+ case: "streamClose",
2220
+ value: create(ExecClientStreamCloseSchema, {
2221
+ id: execMsg.id,
2222
+ }),
2223
+ },
2224
+ });
2225
+ const clientMessage = create(AgentClientMessageSchema, {
2226
+ message: { case: "execClientControlMessage", value: closeMessage },
2227
+ });
2228
+ const responseBytes = toBinary(AgentClientMessageSchema, clientMessage);
2229
+ h2Request.write(frameConnectMessage(responseBytes));
2230
+ log("execClientControl", "streamClose", { id: execMsg.id, execId: execMsg.execId });
2231
+ }
2232
+
2233
+ /**
2234
+ * Exported for tests: verifies handler is invoked with correct `this` when passed as bound.
2235
+ *
2236
+ * Every exit pairs a `toolResult`. The synthesized block was already marked
2237
+ * `kCursorExecResolved` before this runs (`synthesizeCursorExecToolCall`), so
2238
+ * `agent-loop.ts` emits no placeholder for it: a path that returns without a
2239
+ * result leaves the call unpaired and `buildSessionContext` strips the whole
2240
+ * interaction on replay. The three result-less paths — no handler installed, a
2241
+ * handler that produced nothing, and a thrown handler — therefore synthesize
2242
+ * one from the same text the server sees in `execResult`.
2243
+ *
2244
+ * `pairing` is required so a new callsite cannot silently recreate the orphan,
2245
+ * and nullable for the one caller whose block is NOT pre-resolved: MCP without
2246
+ * an `mcp` handler, which `agent-loop.ts` runs locally and pairs itself.
2247
+ */
2248
+ export async function resolveExecHandler<TArgs, TResult>(
2249
+ args: TArgs,
2250
+ handler: ((args: TArgs) => Promise<CursorExecHandlerResult<TResult>>) | undefined,
2251
+ onToolResult: CursorToolResultHandler | undefined,
2252
+ buildFromToolResult: (toolResult: ToolResultMessage) => TResult,
2253
+ buildRejected: (reason: string) => TResult,
2254
+ buildError: (error: string) => TResult,
2255
+ pairing: CursorExecPairing | null,
2256
+ ): Promise<{ execResult: TResult; toolResult?: ToolResultMessage }> {
2257
+ const pair = async (text: string, isError: boolean): Promise<ToolResultMessage | undefined> => {
2258
+ // `null` only for MCP without a handler: that block is never marked
2259
+ // resolved, so `agent-loop.ts` runs it locally and pairs its own result.
2260
+ // Synthesizing one here would double up.
2261
+ if (!pairing) return undefined;
2262
+ const synthesized: ToolResultMessage = {
2263
+ role: "toolResult",
2264
+ toolCallId: pairing.toolCallId,
2265
+ toolName: pairing.toolName,
2266
+ content: [{ type: "text", text }],
2267
+ isError,
2268
+ timestamp: Date.now(),
2269
+ };
2270
+ return await applyToolResultHandler(synthesized, onToolResult);
2271
+ };
2272
+
2273
+ if (!handler) {
2274
+ const reason = "Tool not available";
2275
+ return { execResult: buildRejected(reason), toolResult: await pair(reason, true) };
2276
+ }
2277
+
2278
+ try {
2279
+ const handlerResult = await handler(args);
2280
+ const { execResult, toolResult } = splitExecHandlerResult(handlerResult);
2281
+ const finalToolResult = await applyToolResultHandler(toolResult, onToolResult);
2282
+
2283
+ if (execResult) {
2284
+ // TResult-only is a supported return form, so the transcript entry has to
2285
+ // be synthesized here. Deriving its state from the raw result keeps the
2286
+ // two views consistent: every exec result is a proto oneof whose only
2287
+ // non-failure variant is `success`, so a `rejected`/`error`/
2288
+ // `file_not_found`/... result must not be recorded as a successful call.
2289
+ return {
2290
+ execResult,
2291
+ toolResult: finalToolResult ?? (await pair(...describeExecResult(execResult))),
2292
+ };
2293
+ }
2294
+ if (finalToolResult) {
2295
+ return { execResult: buildFromToolResult(finalToolResult), toolResult: finalToolResult };
2296
+ }
2297
+ const reason = "Tool returned no result";
2298
+ return { execResult: buildRejected(reason), toolResult: await pair(reason, true) };
2299
+ } catch (error) {
2300
+ const message = error instanceof Error ? error.message : String(error);
2301
+ return { execResult: buildError(message), toolResult: await pair(message, true) };
2302
+ }
2303
+ }
2304
+
2305
+ /**
2306
+ * Derive the transcript state of an exec result the SDK handler returned in the
2307
+ * TResult-only form, which carries no `toolResult` to copy it from.
2308
+ *
2309
+ * Every exec result in `agent.proto` is a `oneof result` whose success variant
2310
+ * is named `success` — the rest (`error`, `rejected`, `file_not_found`,
2311
+ * `permission_denied`, `invalid_file`, ...) are failures. Recording those as a
2312
+ * successful call would show the user a green entry for a call Cursor was told
2313
+ * failed. The variant's own `error`/`reason` text is the same string the server
2314
+ * receives, so it is reused verbatim as the transcript body.
2315
+ *
2316
+ * MCP is the one shape where `success` is not enough: `McpSuccess.is_error`
2317
+ * carries an application-level tool failure inside the success variant
2318
+ * (`agent.proto:2058`), mirroring the MCP spec's own `isError`. The transport
2319
+ * succeeded, the tool did not — so the entry must be a failure, and its text
2320
+ * comes from the payload's own content rather than a placeholder.
2321
+ */
2322
+ function describeExecResult(execResult: unknown): [text: string, isError: boolean] {
2323
+ const result = (execResult as { result?: { case?: string; value?: unknown } } | null)?.result;
2324
+ const variant = result?.case;
2325
+ if (variant === "success") {
2326
+ const success = result?.value as { isError?: boolean; content?: unknown[] } | undefined;
2327
+ if (!success?.isError) return ["Tool produced no transcript result", false];
2328
+ return [mcpContentToText(success.content) || "MCP tool reported an error", true];
2329
+ }
2330
+ if (!variant) return ["Tool produced no transcript result", false];
2331
+ const value = result?.value as { error?: string; reason?: string } | undefined;
2332
+ return [value?.error || value?.reason || `Tool call ${variant}`, true];
2333
+ }
2334
+
2335
+ /**
2336
+ * Flatten `McpSuccess.content` into transcript text. Image items carry no text
2337
+ * to surface, so only the text variant contributes; an all-image failure falls
2338
+ * back to the caller's generic message.
2339
+ */
2340
+ function mcpContentToText(content: unknown[] | undefined): string {
2341
+ if (!Array.isArray(content)) return "";
2342
+ const parts: string[] = [];
2343
+ for (const item of content) {
2344
+ const inner = (item as { content?: { case?: string; value?: { text?: string } } } | null)?.content;
2345
+ if (inner?.case === "text" && inner.value?.text) parts.push(inner.value.text);
2346
+ }
2347
+ return parts.join("\n");
2348
+ }
2349
+
2350
+ function splitExecHandlerResult<TResult>(result: CursorExecHandlerResult<TResult>): {
2351
+ execResult?: TResult;
2352
+ toolResult?: ToolResultMessage;
2353
+ } {
2354
+ if (isToolResultMessage(result)) {
2355
+ return { toolResult: result };
2356
+ }
2357
+ if (result && typeof result === "object") {
2358
+ const record = result as Record<string, unknown>;
2359
+ if ("execResult" in record) {
2360
+ const { execResult, toolResult } = record as {
2361
+ execResult: TResult;
2362
+ toolResult?: ToolResultMessage;
2363
+ };
2364
+ return { execResult, toolResult };
2365
+ }
2366
+ if ("toolResult" in record && !isToolResultMessage(record)) {
2367
+ const { result: execResult, toolResult } = record as {
2368
+ result?: TResult;
2369
+ toolResult?: ToolResultMessage;
2370
+ };
2371
+ return { execResult, toolResult };
2372
+ }
2373
+ if ("result" in record && !("$typeName" in record)) {
2374
+ const { result: execResult, toolResult } = record as {
2375
+ result: TResult;
2376
+ toolResult?: ToolResultMessage;
2377
+ };
2378
+ return { execResult, toolResult };
2379
+ }
2380
+ }
2381
+ return { execResult: result as TResult };
2382
+ }
2383
+
2384
+ function isToolResultMessage(value: unknown): value is ToolResultMessage {
2385
+ return !!value && typeof value === "object" && (value as ToolResultMessage).role === "toolResult";
2386
+ }
2387
+
2388
+ async function applyToolResultHandler(
2389
+ toolResult: ToolResultMessage | undefined,
2390
+ onToolResult: CursorToolResultHandler | undefined,
2391
+ ): Promise<ToolResultMessage | undefined> {
2392
+ if (!toolResult || !onToolResult) {
2393
+ return toolResult;
2394
+ }
2395
+ const updated = await onToolResult(toolResult);
2396
+ return updated ?? toolResult;
2397
+ }
2398
+
2399
+ function toolResultToText(toolResult: ToolResultMessage): string {
2400
+ return toolResult.content.map(item => (item.type === "text" ? item.text : `[${item.mimeType} image]`)).join("\n");
2401
+ }
2402
+
2403
+ /**
2404
+ * The catalog as the paired transcript result records it.
2405
+ *
2406
+ * Cursor receives every resource's identity on the wire, but rebuilt history is
2407
+ * serialized from this local result — so recording only a count leaves the
2408
+ * model, one reload later, aware that it once saw N resources and unable to
2409
+ * name any of them. The URI is what a follow-up `read_mcp_resource` needs, so
2410
+ * it leads; name and mime type follow only when the server supplied them.
2411
+ */
2412
+ function formatListedMcpResources(
2413
+ resources: { uri: string; name?: string; mimeType?: string; server?: string }[],
2414
+ ): string {
2415
+ if (resources.length === 0) return "No MCP resources available";
2416
+ const lines = resources.map(resource => {
2417
+ const qualifiers = [resource.name, resource.mimeType].filter(part => !!part).join(", ");
2418
+ const server = resource.server ? `[${resource.server}] ` : "";
2419
+ return qualifiers ? `- ${server}${resource.uri} (${qualifiers})` : `- ${server}${resource.uri}`;
2420
+ });
2421
+ return [`Listed ${resources.length} MCP resource(s):`, ...lines].join("\n");
2422
+ }
2423
+
2424
+ function toolResultWasTruncated(toolResult: ToolResultMessage): boolean {
2425
+ if (!toolResult.details || typeof toolResult.details !== "object") {
2426
+ return false;
2427
+ }
2428
+ const truncation = (toolResult.details as { truncation?: { truncated?: boolean } }).truncation;
2429
+ return !!truncation?.truncated;
2430
+ }
2431
+
2432
+ function toolResultDetailBoolean(toolResult: ToolResultMessage, key: string): boolean {
2433
+ if (!toolResult.details || typeof toolResult.details !== "object") {
2434
+ return false;
2435
+ }
2436
+ const value = (toolResult.details as Record<string, unknown>)[key];
2437
+ return typeof value === "boolean" ? value : false;
2438
+ }
2439
+
2440
+ /**
2441
+ * The file's own line count, when the tool recorded one.
2442
+ *
2443
+ * Read results expose the source-wide count directly when known. Older tool
2444
+ * results carry it at `details.meta.truncation.totalLines`; the flat
2445
+ * `details.truncation.totalLines` counts from a window's start and is
2446
+ * deliberately not consulted here.
2447
+ */
2448
+ function readTotalLinesFromDetails(toolResult: ToolResultMessage): number | undefined {
2449
+ const details = toolResult.details;
2450
+ if (!details || typeof details !== "object") return undefined;
2451
+ const direct = "totalLines" in details ? details.totalLines : undefined;
2452
+ if (typeof direct === "number" && Number.isFinite(direct)) return direct;
2453
+ const meta = "meta" in details ? details.meta : undefined;
2454
+ if (!meta || typeof meta !== "object") return undefined;
2455
+ const truncation = "truncation" in meta ? meta.truncation : undefined;
2456
+ if (!truncation || typeof truncation !== "object") return undefined;
2457
+ const totalLines = "totalLines" in truncation ? truncation.totalLines : undefined;
2458
+ return typeof totalLines === "number" && Number.isFinite(totalLines) ? totalLines : undefined;
2459
+ }
2460
+
2461
+ function readFileSizeFromDetails(toolResult: ToolResultMessage): number | undefined {
2462
+ const details = toolResult.details;
2463
+ if (!details || typeof details !== "object" || !("fileSize" in details)) return undefined;
2464
+ const { fileSize } = details;
2465
+ return typeof fileSize === "number" && Number.isSafeInteger(fileSize) && fileSize >= 0 ? fileSize : undefined;
2466
+ }
2467
+
2468
+ function buildReadResultFromToolResult(path: string, toolResult: ToolResultMessage, rangeApplied = false) {
2469
+ const text = toolResultToText(toolResult);
2470
+ if (toolResult.isError) {
2471
+ return buildReadErrorResult(path, text || "Read failed");
2472
+ }
2473
+ // Counting the payload is only the file's length when the payload is the
2474
+ // whole file. Under a composed window it is the window's, and answering a
2475
+ // 20-line page of a 100-line file with `total_lines: 20` tells a paginating
2476
+ // server it has reached the end.
2477
+ const totalLines = readTotalLinesFromDetails(toolResult) ?? (rangeApplied ? 0 : text ? text.split("\n").length : 0);
2478
+ return create(ReadResultSchema, {
2479
+ result: {
2480
+ case: "success",
2481
+ value: create(ReadSuccessSchema, {
2482
+ path,
2483
+ totalLines,
2484
+ fileSize: BigInt(readFileSizeFromDetails(toolResult) ?? Buffer.byteLength(text, "utf-8")),
2485
+ truncated: toolResultWasTruncated(toolResult),
2486
+ output: { case: "content", value: text },
2487
+ // Set when this client composed the frame's window onto the read,
2488
+ // left false when it read the file whole. The proto names the
2489
+ // field but nothing here pins the server's use of it, so the only
2490
+ // safe contract is that it describes what we actually did.
2491
+ rangeApplied,
2492
+ }),
2493
+ },
2494
+ });
2495
+ }
2496
+
2497
+ function buildReadErrorResult(path: string, error: string) {
2498
+ return create(ReadResultSchema, {
2499
+ result: {
2500
+ case: "error",
2501
+ value: create(ReadErrorSchema, { path, error }),
2502
+ },
2503
+ });
2504
+ }
2505
+
2506
+ function buildReadRejectedResult(path: string, reason: string) {
2507
+ return create(ReadResultSchema, {
2508
+ result: {
2509
+ case: "rejected",
2510
+ value: create(ReadRejectedSchema, { path, reason }),
2511
+ },
2512
+ });
2513
+ }
2514
+
2515
+ function buildWriteResultFromToolResult(
2516
+ args: { path: string; fileText?: string; fileBytes?: Uint8Array; returnFileContentAfterWrite?: boolean },
2517
+ toolResult: ToolResultMessage,
2518
+ ) {
2519
+ const text = toolResultToText(toolResult);
2520
+ if (toolResult.isError) {
2521
+ return buildWriteErrorResult(args.path, text || "Write failed");
2522
+ }
2523
+ const fileText = args.fileText ?? "";
2524
+ const fileSize = args.fileBytes?.length ?? Buffer.byteLength(fileText, "utf-8");
2525
+ const linesCreated = fileText ? fileText.split("\n").length : 0;
2526
+ return create(WriteResultSchema, {
2527
+ result: {
2528
+ case: "success",
2529
+ value: create(WriteSuccessSchema, {
2530
+ path: args.path,
2531
+ linesCreated,
2532
+ fileSize,
2533
+ fileContentAfterWrite: args.returnFileContentAfterWrite ? fileText : undefined,
2534
+ }),
2535
+ },
2536
+ });
2537
+ }
2538
+
2539
+ function buildWriteErrorResult(path: string, error: string) {
2540
+ return create(WriteResultSchema, {
2541
+ result: {
2542
+ case: "error",
2543
+ value: create(WriteErrorSchema, { path, error }),
2544
+ },
2545
+ });
2546
+ }
2547
+
2548
+ function buildWriteRejectedResult(path: string, reason: string) {
2549
+ return create(WriteResultSchema, {
2550
+ result: {
2551
+ case: "rejected",
2552
+ value: create(WriteRejectedSchema, { path, reason }),
2553
+ },
2554
+ });
2555
+ }
2556
+
2557
+ function buildDeleteResultFromToolResult(path: string, toolResult: ToolResultMessage) {
2558
+ const text = toolResultToText(toolResult);
2559
+ if (toolResult.isError) {
2560
+ return buildDeleteErrorResult(path, text || "Delete failed");
2561
+ }
2562
+ return create(DeleteResultSchema, {
2563
+ result: {
2564
+ case: "success",
2565
+ value: create(DeleteSuccessSchema, {
2566
+ path,
2567
+ deletedFile: path,
2568
+ fileSize: BigInt(0),
2569
+ prevContent: "",
2570
+ }),
2571
+ },
2572
+ });
2573
+ }
2574
+
2575
+ function buildDeleteErrorResult(path: string, error: string) {
2576
+ return create(DeleteResultSchema, {
2577
+ result: {
2578
+ case: "error",
2579
+ value: create(DeleteErrorSchema, { path, error }),
2580
+ },
2581
+ });
2582
+ }
2583
+
2584
+ function buildDeleteRejectedResult(path: string, reason: string) {
2585
+ return create(DeleteResultSchema, {
2586
+ result: {
2587
+ case: "rejected",
2588
+ value: create(DeleteRejectedSchema, { path, reason }),
2589
+ },
2590
+ });
2591
+ }
2592
+
2593
+ function buildShellResultFromToolResult(
2594
+ args: { command: string; workingDirectory: string },
2595
+ toolResult: ToolResultMessage,
2596
+ ) {
2597
+ const output = toolResultToText(toolResult);
2598
+ if (toolResult.isError) {
2599
+ return buildShellFailureResult(args.command, args.workingDirectory, output || "Shell failed");
2600
+ }
2601
+ return create(ShellResultSchema, {
2602
+ result: {
2603
+ case: "success",
2604
+ value: create(ShellSuccessSchema, {
2605
+ command: args.command,
2606
+ workingDirectory: args.workingDirectory,
2607
+ exitCode: 0,
2608
+ signal: "",
2609
+ stdout: output,
2610
+ stderr: "",
2611
+ executionTime: 0,
2612
+ }),
2613
+ },
2614
+ });
2615
+ }
2616
+
2617
+ function buildShellFailureResult(command: string, workingDirectory: string, error: string) {
2618
+ return create(ShellResultSchema, {
2619
+ result: {
2620
+ case: "failure",
2621
+ value: create(ShellFailureSchema, {
2622
+ command,
2623
+ workingDirectory,
2624
+ exitCode: 1,
2625
+ signal: "",
2626
+ stdout: "",
2627
+ stderr: error,
2628
+ executionTime: 0,
2629
+ aborted: false,
2630
+ }),
2631
+ },
2632
+ });
2633
+ }
2634
+
2635
+ function buildShellRejectedResult(command: string, workingDirectory: string, reason: string) {
2636
+ return create(ShellResultSchema, {
2637
+ result: {
2638
+ case: "rejected",
2639
+ value: create(ShellRejectedSchema, {
2640
+ command,
2641
+ workingDirectory,
2642
+ reason,
2643
+ isReadonly: false,
2644
+ }),
2645
+ },
2646
+ });
2647
+ }
2648
+
2649
+ function buildLsResultFromToolResult(path: string, toolResult: ToolResultMessage) {
2650
+ const text = toolResultToText(toolResult);
2651
+ if (toolResult.isError) {
2652
+ return buildLsErrorResult(path, text || "Ls failed");
2653
+ }
2654
+ const rootPath = path || ".";
2655
+ const entries = text
2656
+ .split("\n")
2657
+ .map(line => line.trim())
2658
+ .filter(line => line.length > 0 && !line.startsWith("["));
2659
+ const childrenDirs: LsDirectoryTreeNode[] = [];
2660
+ const childrenFiles: LsDirectoryTreeNode_File[] = [];
2661
+
2662
+ for (const entry of entries) {
2663
+ const name = entry.split(" (")[0];
2664
+ if (name.endsWith("/")) {
2665
+ const dirName = name.slice(0, -1);
2666
+ childrenDirs.push(
2667
+ create(LsDirectoryTreeNodeSchema, {
2668
+ absPath: `${rootPath.replace(/\/$/, "")}/${dirName}`,
2669
+ childrenDirs: [],
2670
+ childrenFiles: [],
2671
+ childrenWereProcessed: false,
2672
+ fullSubtreeExtensionCounts: {},
2673
+ numFiles: 0,
2674
+ }),
2675
+ );
2676
+ } else {
2677
+ childrenFiles.push(create(LsDirectoryTreeNode_FileSchema, { name }));
2678
+ }
2679
+ }
2680
+
2681
+ const root = create(LsDirectoryTreeNodeSchema, {
2682
+ absPath: rootPath,
2683
+ childrenDirs,
2684
+ childrenFiles,
2685
+ childrenWereProcessed: true,
2686
+ fullSubtreeExtensionCounts: {},
2687
+ numFiles: childrenFiles.length,
2688
+ });
2689
+
2690
+ return create(LsResultSchema, {
2691
+ result: {
2692
+ case: "success",
2693
+ value: create(LsSuccessSchema, { directoryTreeRoot: root }),
2694
+ },
2695
+ });
2696
+ }
2697
+
2698
+ function buildLsErrorResult(path: string, error: string) {
2699
+ return create(LsResultSchema, {
2700
+ result: {
2701
+ case: "error",
2702
+ value: create(LsErrorSchema, { path, error }),
2703
+ },
2704
+ });
2705
+ }
2706
+
2707
+ function buildLsRejectedResult(path: string, reason: string) {
2708
+ return create(LsResultSchema, {
2709
+ result: {
2710
+ case: "rejected",
2711
+ value: create(LsRejectedSchema, { path, reason }),
2712
+ },
2713
+ });
2714
+ }
2715
+
2716
+ function buildGrepResultFromToolResult(
2717
+ args: { pattern: string; path?: string; outputMode?: string; offset?: number },
2718
+ toolResult: ToolResultMessage,
2719
+ ) {
2720
+ const text = toolResultToText(toolResult);
2721
+ if (toolResult.isError) {
2722
+ return buildGrepErrorResult(text || "Grep failed");
2723
+ }
2724
+
2725
+ const outputMode = args.outputMode || "content";
2726
+ const clientTruncated = toolResultDetailBoolean(toolResult, "truncated");
2727
+ const lines = text
2728
+ .split("\n")
2729
+ .map(line => line.trimEnd())
2730
+ .filter(line => line.length > 0 && !line.startsWith("[") && !line.toLowerCase().startsWith("no matches"));
2731
+
2732
+ const workspaceKey = args.path || ".";
2733
+ let unionResult: GrepUnionResult;
2734
+
2735
+ if (outputMode === "files_with_matches") {
2736
+ const files = lines;
2737
+ unionResult = create(GrepUnionResultSchema, {
2738
+ result: {
2739
+ case: "files",
2740
+ value: create(GrepFilesResultSchema, {
2741
+ files,
2742
+ totalFiles: files.length,
2743
+ clientTruncated,
2744
+ ripgrepTruncated: false,
2745
+ // Echoes the offset this client actually applied; absent when
2746
+ // the frame requested none. The proto names the field but
2747
+ // nothing here pins the server's use of it, so it reports what
2748
+ // we did rather than asserting a pagination protocol.
2749
+ offsetApplied: args.offset,
2750
+ }),
2751
+ },
2752
+ });
2753
+ } else if (outputMode === "count") {
2754
+ const counts = lines
2755
+ .map(line => {
2756
+ const separatorIndex = line.lastIndexOf(":");
2757
+ if (separatorIndex === -1) {
2758
+ return null;
2759
+ }
2760
+ const file = line.slice(0, separatorIndex);
2761
+ const count = Number.parseInt(line.slice(separatorIndex + 1), 10);
2762
+ if (!file || Number.isNaN(count)) {
2763
+ return null;
2764
+ }
2765
+ return create(GrepFileCountSchema, { file, count });
2766
+ })
2767
+ .filter((entry): entry is GrepFileCount => entry !== null);
2768
+ const totalMatches = counts.reduce((sum, entry) => sum + entry.count, 0);
2769
+ unionResult = create(GrepUnionResultSchema, {
2770
+ result: {
2771
+ case: "count",
2772
+ value: create(GrepCountResultSchema, {
2773
+ counts,
2774
+ totalFiles: counts.length,
2775
+ totalMatches,
2776
+ clientTruncated,
2777
+ ripgrepTruncated: false,
2778
+ offsetApplied: args.offset,
2779
+ }),
2780
+ },
2781
+ });
2782
+ } else {
2783
+ const matchMap = new Map<string, Array<{ line: number; content: string; isContextLine: boolean }>>();
2784
+ let totalMatchedLines = 0;
2785
+
2786
+ for (const line of lines) {
2787
+ const matchLine = line.match(/^(.+?):(\d+):\s?(.*)$/);
2788
+ const contextLine = line.match(/^(.+?)-(\d+)-\s?(.*)$/);
2789
+ const match = matchLine ?? contextLine;
2790
+ if (!match) {
2791
+ continue;
2792
+ }
2793
+ const [, file, lineNumber, content] = match;
2794
+ const isContextLine = Boolean(contextLine);
2795
+ const list = matchMap.get(file) ?? [];
2796
+ list.push({ line: Number(lineNumber), content, isContextLine });
2797
+ matchMap.set(file, list);
2798
+ if (!isContextLine) {
2799
+ totalMatchedLines += 1;
2800
+ }
2801
+ }
2802
+
2803
+ const matches = Array.from(matchMap.entries()).map(([file, matches]) =>
2804
+ create(GrepFileMatchSchema, {
2805
+ file,
2806
+ matches: matches.map(entry =>
2807
+ create(GrepContentMatchSchema, {
2808
+ lineNumber: entry.line,
2809
+ content: entry.content,
2810
+ contentTruncated: false,
2811
+ isContextLine: entry.isContextLine,
2812
+ }),
2813
+ ),
2814
+ }),
2815
+ );
2816
+ const totalLines = matches.reduce((sum, entry) => sum + entry.matches.length, 0);
2817
+ unionResult = create(GrepUnionResultSchema, {
2818
+ result: {
2819
+ case: "content",
2820
+ value: create(GrepContentResultSchema, {
2821
+ matches,
2822
+ totalLines,
2823
+ totalMatchedLines,
2824
+ clientTruncated,
2825
+ ripgrepTruncated: false,
2826
+ offsetApplied: args.offset,
2827
+ }),
2828
+ },
2829
+ });
2830
+ }
2831
+
2832
+ return create(GrepResultSchema, {
2833
+ result: {
2834
+ case: "success",
2835
+ value: create(GrepSuccessSchema, {
2836
+ pattern: args.pattern,
2837
+ path: args.path || "",
2838
+ outputMode,
2839
+ workspaceResults: { [workspaceKey]: unionResult },
2840
+ }),
2841
+ },
2842
+ });
2843
+ }
2844
+
2845
+ function buildGrepErrorResult(error: string) {
2846
+ return create(GrepResultSchema, {
2847
+ result: {
2848
+ case: "error",
2849
+ value: create(GrepErrorSchema, { error }),
2850
+ },
2851
+ });
2852
+ }
2853
+
2854
+ /**
2855
+ * Reject a Cursor exec-channel `grepArgs` frame whose `pattern` is empty or
2856
+ * whitespace-only. Returns an actionable error message when the pattern is
2857
+ * unusable (with a `glob`-aware hint when the model likely meant to list
2858
+ * files), or `null` when the pattern is valid and grep should run.
2859
+ *
2860
+ * Exported for tests. Cursor's model sometimes sends `pattern=""` together
2861
+ * with a non-empty `glob`, expecting grep to enumerate matching files; the
2862
+ * downstream coding-agent `grep` tool rejects that with a bare "Pattern must
2863
+ * not be empty", which the TUI renders as `?` in the tool preview (issue
2864
+ * #4574). Handling it at the Cursor exec dispatch keeps the synthesized
2865
+ * `toolCall` block off the persisted assistant message and gives the model a
2866
+ * specific recovery hint.
2867
+ */
2868
+ export function emptyGrepPatternRejection(pattern: string | undefined, glob: string | undefined): string | null {
2869
+ if (pattern && pattern.trim().length > 0) return null;
2870
+ if (glob && glob.length > 0) {
2871
+ return (
2872
+ `grep pattern is required (received an empty pattern). To list files matching "${glob}", ` +
2873
+ `pass a non-empty regex (e.g. ".") and set path to that glob, or use the ls/read tool instead.`
2874
+ );
2875
+ }
2876
+ return "grep pattern is required (received an empty pattern).";
2877
+ }
2878
+
2879
+ function buildDiagnosticsResultFromToolResult(path: string, toolResult: ToolResultMessage) {
2880
+ const text = toolResultToText(toolResult);
2881
+ if (toolResult.isError) {
2882
+ return buildDiagnosticsErrorResult(path, text || "Diagnostics failed");
2883
+ }
2884
+ return create(DiagnosticsResultSchema, {
2885
+ result: {
2886
+ case: "success",
2887
+ value: create(DiagnosticsSuccessSchema, {
2888
+ path,
2889
+ diagnostics: [],
2890
+ totalDiagnostics: 0,
2891
+ }),
2892
+ },
2893
+ });
2894
+ }
2895
+
2896
+ function buildDiagnosticsErrorResult(_path: string, error: string) {
2897
+ return create(DiagnosticsResultSchema, {
2898
+ result: {
2899
+ case: "error",
2900
+ value: create(DiagnosticsErrorSchema, { error }),
2901
+ },
2902
+ });
2903
+ }
2904
+
2905
+ function buildDiagnosticsRejectedResult(path: string, reason: string) {
2906
+ return create(DiagnosticsResultSchema, {
2907
+ result: {
2908
+ case: "rejected",
2909
+ value: create(DiagnosticsRejectedSchema, { path, reason }),
2910
+ },
2911
+ });
2912
+ }
2913
+
2914
+ function parseToolArgsJson(text: string): unknown {
2915
+ const trimmed = text.trim();
2916
+ if (!trimmed) {
2917
+ return text;
2918
+ }
2919
+ try {
2920
+ return parseJsonWithRepair<unknown>(trimmed);
2921
+ } catch {
2922
+ return text;
2923
+ }
2924
+ }
2925
+
2926
+ function decodeMcpArgValue(value: Uint8Array): unknown {
2927
+ try {
2928
+ const parsedValue = fromBinary(ValueSchema, value);
2929
+ const jsonValue = toJson(ValueSchema, parsedValue) as JsonValue;
2930
+ if (typeof jsonValue === "string") {
2931
+ return parseToolArgsJson(jsonValue);
2932
+ }
2933
+ return jsonValue;
2934
+ } catch {}
2935
+ const text = new TextDecoder().decode(value);
2936
+ return parseToolArgsJson(text);
2937
+ }
2938
+
2939
+ function decodeMcpArgsMap(args?: Record<string, Uint8Array>): Record<string, unknown> | undefined {
2940
+ if (!args) {
2941
+ return undefined;
2942
+ }
2943
+ const decoded: Record<string, unknown> = {};
2944
+ for (const [key, value] of Object.entries(args)) {
2945
+ decoded[key] = decodeMcpArgValue(value);
2946
+ }
2947
+ return decoded;
2948
+ }
2949
+
2950
+ function decodeMcpCall(args: {
2951
+ name: string;
2952
+ args: Record<string, Uint8Array>;
2953
+ toolCallId: string;
2954
+ providerIdentifier: string;
2955
+ toolName: string;
2956
+ smartModeApprovalOnly?: boolean;
2957
+ }): CursorMcpCall {
2958
+ const decodedArgs: Record<string, unknown> = {};
2959
+ for (const [key, value] of Object.entries(args.args ?? {})) {
2960
+ decodedArgs[key] = decodeMcpArgValue(value);
2961
+ }
2962
+ return {
2963
+ name: args.name,
2964
+ providerIdentifier: args.providerIdentifier,
2965
+ toolName: args.toolName || args.name,
2966
+ toolCallId: args.toolCallId,
2967
+ args: decodedArgs,
2968
+ rawArgs: args.args ?? {},
2969
+ approvalOnly: args.smartModeApprovalOnly === true,
2970
+ };
2971
+ }
2972
+
2973
+ /**
2974
+ * Map Cursor's `TodoStatus` enum (agent.proto) onto the local todo statuses.
2975
+ *
2976
+ * `TODO_STATUS_CANCELLED` (4) maps to `abandoned` rather than collapsing to
2977
+ * `pending`, which would resurrect a task the model explicitly cancelled.
2978
+ */
2979
+ function mapTodoStatusValue(status?: number): CursorTodoSnapshotItem["status"] {
2980
+ switch (status) {
2981
+ case 2:
2982
+ return "in_progress";
2983
+ case 3:
2984
+ return "completed";
2985
+ case 4:
2986
+ return "abandoned";
2987
+ default:
2988
+ return "pending";
2989
+ }
2990
+ }
2991
+
2992
+ interface CursorTodoItem {
2993
+ id?: string;
2994
+ content?: string;
2995
+ status?: number;
2996
+ /** IDs of other todos this one waits on (agent.proto `TodoItem.dependencies`). */
2997
+ dependencies?: string[];
2998
+ }
2999
+
3000
+ interface CursorTodoResult {
3001
+ result?: {
3002
+ case?: "success" | "error";
3003
+ value?: { todos?: CursorTodoItem[]; totalCount?: number; wasMerge?: boolean; error?: string };
3004
+ };
3005
+ }
3006
+
3007
+ interface CursorReadTodosArgs {
3008
+ statusFilter?: number[];
3009
+ idFilter?: string[];
3010
+ }
3011
+
3012
+ interface CursorUpdateTodosCall {
3013
+ args?: { todos?: CursorTodoItem[]; merge?: boolean };
3014
+ result?: CursorTodoResult;
3015
+ }
3016
+
3017
+ interface CursorReadTodosCall {
3018
+ args?: CursorReadTodosArgs;
3019
+ result?: CursorTodoResult;
3020
+ }
3021
+
3022
+ /**
3023
+ * `ToolCall` is a protobuf oneof, so a decoded message exposes the selected
3024
+ * variant as `tool: { case, value }` — NOT as a named property. Hand-built
3025
+ * fixtures and some call sites still use the flattened form, so both are
3026
+ * accepted here.
3027
+ */
3028
+ interface CursorTodoToolCall {
3029
+ tool?: { case?: string; value?: unknown };
3030
+ updateTodosToolCall?: CursorUpdateTodosCall;
3031
+ readTodosToolCall?: CursorReadTodosCall;
3032
+ }
3033
+
3034
+ function selectTodoCalls(toolCall: CursorTodoToolCall): {
3035
+ update?: CursorUpdateTodosCall;
3036
+ read?: CursorReadTodosCall;
3037
+ } {
3038
+ const oneof = toolCall.tool;
3039
+ if (oneof?.case === "updateTodosToolCall") return { update: oneof.value as CursorUpdateTodosCall };
3040
+ if (oneof?.case === "readTodosToolCall") return { read: oneof.value as CursorReadTodosCall };
3041
+ return { update: toolCall.updateTodosToolCall, read: toolCall.readTodosToolCall };
3042
+ }
3043
+
3044
+ function mapTodoSnapshot(todos: CursorTodoItem[]): CursorTodoSnapshotItem[] {
3045
+ return todos.map(todo => ({
3046
+ content: typeof todo.content === "string" ? todo.content : "",
3047
+ status: mapTodoStatusValue(typeof todo.status === "number" ? todo.status : undefined),
3048
+ }));
3049
+ }
3050
+
3051
+ interface CursorMcpToolCall {
3052
+ args?: {
3053
+ name?: string;
3054
+ toolName?: string;
3055
+ toolCallId?: string;
3056
+ args?: Record<string, Uint8Array>;
3057
+ };
3058
+ }
3059
+
3060
+ interface CursorMcpToolCallCarrier {
3061
+ tool?: { case?: string; value?: unknown };
3062
+ mcpToolCall?: CursorMcpToolCall;
3063
+ }
3064
+
3065
+ /**
3066
+ * `ToolCall.tool` is a protobuf oneof: a wire-decoded message exposes the
3067
+ * variant as `{ case, value }` and NEVER as a flattened `mcpToolCall`
3068
+ * property. Reading the flat property alone is what made native todo calls
3069
+ * invisible on the wire while hand-shaped test fixtures kept passing, so MCP
3070
+ * goes through the same selector. The flat fallback is kept for those fixtures.
3071
+ */
3072
+ function selectMcpCall(toolCall: CursorMcpToolCallCarrier | undefined): CursorMcpToolCall | undefined {
3073
+ const oneof = toolCall?.tool;
3074
+ if (oneof?.case === "mcpToolCall") return oneof.value as CursorMcpToolCall;
3075
+ return toolCall?.mcpToolCall;
3076
+ }
3077
+
3078
+ /**
3079
+ * The streamed `ToolCall` variants whose block the exec channel owns.
3080
+ *
3081
+ * Each of these is announced on the interaction stream AND dispatched as its
3082
+ * own `ExecServerMessage` frame — the Pi family (45-51), plus the two MCP
3083
+ * resource frames — so the block is synthesized once, by the exec handler,
3084
+ * which is the side that has the result.
3085
+ *
3086
+ * `connect_scm` is deliberately NOT here: `ExecServerMessage` has no
3087
+ * connect-SCM case at all (field 44 is `git_diff_request`), so nothing on the
3088
+ * exec channel ever answers it and the streamed announcement is the only
3089
+ * signal. `search_conversations` is not here either: frame 53 answers it, but
3090
+ * it carries its own `tool_call_id` on the streamed envelope and pairs there,
3091
+ * so the exec branch does not synthesize a block for it.
3092
+ */
3093
+ const EXEC_OWNED_TOOL_CALL_CASES: ReadonlySet<string> = new Set([
3094
+ "piReadToolCall",
3095
+ "piBashToolCall",
3096
+ "piEditToolCall",
3097
+ "piWriteToolCall",
3098
+ "piGrepToolCall",
3099
+ "piFindToolCall",
3100
+ "piLsToolCall",
3101
+ "listMcpResourcesToolCall",
3102
+ "readMcpResourceToolCall",
3103
+ ]);
3104
+
3105
+ function isExecOwnedToolCall(toolCall: { tool?: { case?: string } } | undefined): boolean {
3106
+ const variant = toolCall?.tool?.case;
3107
+ return variant !== undefined && EXEC_OWNED_TOOL_CALL_CASES.has(variant);
3108
+ }
3109
+
3110
+ /**
3111
+ * Retain a freshly opened streamed tool-call block.
3112
+ *
3113
+ * Keyed by the interaction envelope's `call_id`, which is the only key every
3114
+ * `ToolCall*Update` for that call shares. The block's own `id` is deliberately
3115
+ * not the key: MCP, Pi and connect-SCM blocks are filed under the id carried
3116
+ * inside the call's `args`, because that is what the exec channel pairs its
3117
+ * result under and what the transcript files the visible block under.
3118
+ *
3119
+ * `currentToolCall` is still set, as the fallback for frames that carry no
3120
+ * `call_id` (proto3-optional, and unset on what older builds send).
3121
+ */
3122
+ /**
3123
+ * Close every tool-call block still open when the stream ends.
3124
+ *
3125
+ * Not just the last one started: with interleaved calls several can be open at
3126
+ * once, and an unclosed block leaves its live card animating and its call
3127
+ * unpaired.
3128
+ *
3129
+ * Only blocks fed by a streamed argument buffer get reparsed. Todo,
3130
+ * connect-SCM and MCP-settled frames arrive with complete `arguments` and
3131
+ * never set the partial buffer; `parseStreamingJson(undefined)` returns `{}`,
3132
+ * so reparsing unconditionally would erase the arguments of every such block
3133
+ * caught open by a truncated stream.
3134
+ *
3135
+ * Server-owned blocks are also paired here. `connect-scm` and `todo` are
3136
+ * stamped {@link kCursorExecResolved} the moment they open, so `agent-loop.ts`
3137
+ * synthesizes no placeholder for them and only their `toolCallCompleted` frame
3138
+ * pairs a result. A transport that closes before that frame would leave the
3139
+ * call unpaired, and `buildSessionContext` strips a dangling call from every
3140
+ * rebuilt transcript — the interaction disappears. An interrupted result is
3141
+ * emitted instead.
3142
+ *
3143
+ * MCP blocks are excluded even when resolved: the exec dispatch that marked
3144
+ * them owns their result, and `drainInFlightDispatches` awaits it before this
3145
+ * runs, so pairing here would duplicate one against the same `toolCallId`.
3146
+ */
3147
+ export function flushOpenToolCalls(
3148
+ output: AssistantMessage,
3149
+ stream: AssistantMessageEventStream,
3150
+ state: BlockState,
3151
+ ): void {
3152
+ const openBlocks = new Set<ToolCallState>(state.openToolCalls.values());
3153
+ if (state.currentToolCall) openBlocks.add(state.currentToolCall);
3154
+ for (const block of openBlocks) {
3155
+ const idx = output.content.indexOf(block);
3156
+ const partialJson = block[kStreamingPartialJson];
3157
+ if (partialJson !== undefined) {
3158
+ block.arguments = parseStreamingJson(partialJson);
3159
+ clearStreamingPartialJson(block);
3160
+ }
3161
+ const kind = block[kStreamingBlockKind];
3162
+ if (kind === "connect-scm" || kind === "todo") {
3163
+ state.onToolResult?.({
3164
+ role: "toolResult",
3165
+ toolCallId: block.id,
3166
+ toolName: block.name,
3167
+ content: [{ type: "text", text: "The connection to Cursor closed before this call completed." }],
3168
+ isError: true,
3169
+ timestamp: Date.now(),
3170
+ });
3171
+ }
3172
+ stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output });
3173
+ }
3174
+ state.openToolCalls.clear();
3175
+ state.setToolCall(null);
3176
+ }
3177
+
3178
+ function retainStreamedCall(state: BlockState, block: ToolCallState, envelopeId: string | undefined): void {
3179
+ if (envelopeId) state.openToolCalls.set(envelopeId, block);
3180
+ state.setToolCall(block);
3181
+ }
3182
+
3183
+ /**
3184
+ * The open block a streamed update addresses, or `null` to ignore the update.
3185
+ *
3186
+ * Cursor interleaves calls: `start A, start B, complete A` is legal, so the
3187
+ * update must reach block A even though B opened last. An id naming no open
3188
+ * block is ignored rather than misapplied — settling the wrong block would pair
3189
+ * it with another call's result.
3190
+ *
3191
+ * A missing id falls back to the current block: the correlation key is
3192
+ * optional, and dropping those updates would strand a block stamped
3193
+ * {@link kCursorExecResolved}, which nothing else settles and whose whole
3194
+ * interaction is then stripped from every rebuilt transcript.
3195
+ */
3196
+ function resolveStreamedCall(state: BlockState, envelopeId: string | undefined): ToolCallState | null {
3197
+ if (!envelopeId) return state.currentToolCall;
3198
+ const keyed = state.openToolCalls.get(envelopeId);
3199
+ if (keyed) return keyed;
3200
+ // Blocks opened before this build tracked envelope ids, and blocks opened
3201
+ // from a frame that carried none, are only reachable as `currentToolCall`.
3202
+ const current = state.currentToolCall;
3203
+ return current && current[kStreamingEnvelopeId] === undefined ? current : null;
3204
+ }
3205
+
3206
+ /** Release a settled block from both the keyed map and the current slot. */
3207
+ function releaseStreamedCall(state: BlockState, block: ToolCallState): void {
3208
+ const envelopeId = block[kStreamingEnvelopeId];
3209
+ if (envelopeId) state.openToolCalls.delete(envelopeId);
3210
+ if (state.currentToolCall === block) state.setToolCall(null);
3211
+ }
3212
+
3213
+ interface CursorConnectScmRepository {
3214
+ owner?: string;
3215
+ repo?: string;
3216
+ }
3217
+
3218
+ interface CursorConnectScmCall {
3219
+ args?: {
3220
+ toolCallId?: string;
3221
+ /** `ConnectScmArgs.target` oneof; `github` is its only member today. */
3222
+ target?: { case?: string; value?: { repository?: CursorConnectScmRepository } };
3223
+ github?: { repository?: CursorConnectScmRepository };
3224
+ };
3225
+ /** `ConnectScmResult.result` oneof: `success` | `error` | `rejected`. */
3226
+ result?: { result?: { case?: string; value?: { error?: string; reason?: string } } };
3227
+ }
3228
+
3229
+ interface CursorConnectScmCarrier {
3230
+ tool?: { case?: string; value?: unknown };
3231
+ connectScmToolCall?: CursorConnectScmCall;
3232
+ }
3233
+
3234
+ /**
3235
+ * The streamed `connect_scm_tool_call` variant, if this update carries one.
3236
+ *
3237
+ * Same oneof-vs-flattened handling as {@link selectMcpCall}: a wire-decoded
3238
+ * `ToolCall` exposes its variant as `{ case, value }`, while hand-shaped test
3239
+ * fixtures use the flat property.
3240
+ */
3241
+ function selectConnectScmCall(toolCall: CursorConnectScmCarrier | undefined): CursorConnectScmCall | undefined {
3242
+ const oneof = toolCall?.tool;
3243
+ if (oneof?.case === "connectScmToolCall") return oneof.value as CursorConnectScmCall;
3244
+ return toolCall?.connectScmToolCall;
3245
+ }
3246
+
3247
+ /** The repository a connect-SCM call targets, across the `target` oneof. */
3248
+ function selectConnectScmRepository(call: CursorConnectScmCall | undefined): CursorConnectScmRepository | undefined {
3249
+ const target = call?.args?.target;
3250
+ if (target?.case === "github") return target.value?.repository;
3251
+ return call?.args?.github?.repository;
3252
+ }
3253
+
3254
+ /**
3255
+ * Render a settled `ConnectScmResult` as the text of its paired tool result.
3256
+ *
3257
+ * Returns `isError` because the three outcomes are not interchangeable: only
3258
+ * `success` means the repository was connected, and reporting a rejection as
3259
+ * success would tell the model to proceed against a repo it cannot reach.
3260
+ */
3261
+ function describeConnectScmResult(call: CursorConnectScmCall | undefined): { text: string; isError: boolean } {
3262
+ const result = call?.result?.result;
3263
+ switch (result?.case) {
3264
+ case "success":
3265
+ return { text: "SCM connected", isError: false };
3266
+ case "error":
3267
+ return { text: result.value?.error || "SCM connection failed", isError: true };
3268
+ case "rejected":
3269
+ return { text: result.value?.reason || "SCM connection rejected", isError: true };
3270
+ default:
3271
+ // A completion carrying no result settles the block anyway: it is
3272
+ // stamped resolved, so nothing downstream would ever pair it.
3273
+ return { text: "SCM connection reported no result", isError: true };
3274
+ }
3275
+ }
3276
+
3277
+ /**
3278
+ * Extract the authoritative full todo list from a completed native todo call.
3279
+ *
3280
+ * Cursor owns this list server-side: `update_todos` / `read_todos` are resolved
3281
+ * remotely and the settled state rides on the tool call's `result`, never on
3282
+ * the exec channel (`ExecServerMessage` has no todo case). Only
3283
+ * `result.success.todos` is authoritative — the request `args` may differ from
3284
+ * what the server actually stored after a merge or normalization, and on
3285
+ * `UpdateTodosError` nothing was stored at all.
3286
+ *
3287
+ * A `read_todos` call carrying `status_filter` / `id_filter` (agent.proto
3288
+ * `ReadTodosArgs`) returns a SUBSET, not the list, and its `total_count`
3289
+ * reports the full size. Mirroring a partial response would delete every task
3290
+ * it omitted, so filtered and short reads are refused here. An empty read is
3291
+ * refused too: proto3 defaults unset `total_count` to 0, so `todos=[]` cannot
3292
+ * be told from a missing count.
3293
+ *
3294
+ * A snapshot whose rows are not unique by content is refused for a different
3295
+ * reason: Cursor keys todos by `id`, the local list is keyed by content, and
3296
+ * the collision is unrepresentable rather than merely partial.
3297
+ *
3298
+ * Returns `null` when no usable full snapshot is available, which the caller
3299
+ * MUST treat as "leave local state untouched".
3300
+ */
3301
+ function extractTodoSnapshot(toolCall: CursorTodoToolCall): CursorTodoSnapshot | null {
3302
+ const { update, read } = selectTodoCalls(toolCall);
3303
+ if (read && ((read.args?.statusFilter?.length ?? 0) > 0 || (read.args?.idFilter?.length ?? 0) > 0)) {
3304
+ return null;
3305
+ }
3306
+ const call = update ?? read;
3307
+ if (!call) return null;
3308
+ const result = call.result?.result;
3309
+ if (result?.case !== "success") return null;
3310
+ const todos = result.value?.todos;
3311
+ if (!todos) return null;
3312
+ // A response that disagrees with the server's own count is partial; treating
3313
+ // it as the list would drop whatever it left out. This applies to BOTH call
3314
+ // kinds and to the empty case: a size-limited or partial `update_todos`
3315
+ // merge response is just as incomplete as a filtered read, and an empty one
3316
+ // whose `total_count` is nonzero is the most destructive shape of all —
3317
+ // mirroring it would delete every local task at once.
3318
+ //
3319
+ // `total_count` is a proto3 scalar, so an unset field arrives as `0`. That
3320
+ // makes `todos=[]` + `total_count=0` ambiguous: a genuine clear, or a
3321
+ // filtered read that matched nothing with the count omitted. An empty READ
3322
+ // is therefore refused outright, while an empty UPDATE with a matching zero
3323
+ // count remains the authoritative clear path.
3324
+ const totalCount = result.value?.totalCount;
3325
+ if (typeof totalCount === "number" && totalCount !== todos.length) {
3326
+ return null;
3327
+ }
3328
+ if (read && todos.length === 0) {
3329
+ return null;
3330
+ }
3331
+ const mapped = mapTodoSnapshot(todos);
3332
+ // A row whose `content` is missing or proto-default lands as `""`. The local
3333
+ // list is keyed by content and `resolveTaskOrError` rejects a falsy one
3334
+ // before lookup, so the task would be permanently unreachable to every
3335
+ // task-targeted `done`/`drop`/`rm` — the same unrepresentable shape as a
3336
+ // content collision, refused for the same reason.
3337
+ if (mapped.some(todo => todo.content.length === 0)) return null;
3338
+ // The wire model identifies rows by `id` and can represent two rows sharing
3339
+ // `content`; the local list is keyed by content alone (`findTaskByContent`)
3340
+ // and `todo` rejects a duplicate outright. Importing such a snapshot would
3341
+ // leave every task-targeted `done`/`drop`/`rm` resolving to the first row and
3342
+ // the second unreachable (phase-wide and untargeted ops still hit both), so
3343
+ // it is refused like any other snapshot that cannot be represented locally.
3344
+ const seen = new Set<string>();
3345
+ for (const todo of mapped) {
3346
+ if (seen.has(todo.content)) return null;
3347
+ seen.add(todo.content);
3348
+ }
3349
+ // `TodoItem.dependencies` carries the IDs a row waits on. The local model can
3350
+ // express *that* a task is blocked (`TodoStatus` has `blocked`, `TodoItem`
3351
+ // has `blocker`), but not the graph: it has no ids, so an edge cannot be
3352
+ // stored, replayed, or re-evaluated when the blocker later completes.
3353
+ //
3354
+ // Dropping the edge silently is the harmful part. `nextActionableTask`
3355
+ // (`todo.ts:164`) returns the first `pending` row with no notion of
3356
+ // blockage, so the panel, the idle recap, and the completion reminders
3357
+ // would all steer toward work the server says is not ready yet — and a
3358
+ // reload loses the constraint for good.
3359
+ //
3360
+ // Only *unresolved* edges are refused: a dependency on an already
3361
+ // finished row imposes nothing, which keeps late-session snapshots
3362
+ // syncing normally.
3363
+ //
3364
+ // Projecting unresolved edges onto `blocked` + a `blocker` note is the
3365
+ // lossy alternative — it preserves the warning but not the graph, and
3366
+ // nothing would ever unblock the row, since the local engine has no id to
3367
+ // match when the dependency completes. Refusing keeps this consistent with
3368
+ // the collision case above: decline what cannot be represented rather than
3369
+ // import an approximation.
3370
+ const finished = new Set<string>();
3371
+ for (const todo of todos) {
3372
+ const status = mapTodoStatusValue(typeof todo.status === "number" ? todo.status : undefined);
3373
+ if (todo.id && (status === "completed" || status === "abandoned")) finished.add(todo.id);
3374
+ }
3375
+ for (const todo of todos) {
3376
+ for (const dependency of todo.dependencies ?? []) {
3377
+ if (!finished.has(dependency)) return null;
3378
+ }
3379
+ }
3380
+ return {
3381
+ todos: mapped,
3382
+ // Presentation-only: the snapshot is already the settled full list.
3383
+ merged: result.value?.wasMerge === true,
3384
+ };
3385
+ }
3386
+
3387
+ /**
3388
+ * Error text when the server itself rejected the call.
3389
+ *
3390
+ * Distinct from {@link extractTodoSnapshot} returning `null`: a filtered read, a
3391
+ * truncated or empty one (proto3 cannot tell unset `total_count` from zero), or
3392
+ * a snapshot the local model cannot represent are all benign refusals (the call
3393
+ * succeeded, we just decline to mirror it), whereas an `UpdateTodosError` /
3394
+ * `ReadTodosError` is a real failure that must not replay as a successful no-op.
3395
+ */
3396
+ function extractTodoError(toolCall: CursorTodoToolCall): string | null {
3397
+ const { update, read } = selectTodoCalls(toolCall);
3398
+ const result = (update ?? read)?.result?.result;
3399
+ if (result?.case !== "error") return null;
3400
+ const error = result.value?.error;
3401
+ return typeof error === "string" && error.length > 0 ? error : "Todo operation failed";
3402
+ }
3403
+
3404
+ /** Args echoed onto the synthesized display block, for rendering only. */
3405
+ function buildTodoDisplayArgs(toolCall: CursorTodoToolCall): { todos: CursorTodoSnapshotItem[]; merge?: boolean } {
3406
+ const args = selectTodoCalls(toolCall).update?.args;
3407
+ return {
3408
+ todos: args?.todos ? mapTodoSnapshot(args.todos) : [],
3409
+ merge: args?.merge === true ? true : undefined,
3410
+ };
3411
+ }
3412
+
3413
+ /**
3414
+ * Paired result for a server-resolved native todo call.
3415
+ *
3416
+ * The bridge never runs a local `todo` tool for these, so nothing else would
3417
+ * produce a `toolResult` for the block — and `buildSessionContext` strips any
3418
+ * `toolCall` left unpaired, taking the interaction out of every rebuilt
3419
+ * transcript.
3420
+ *
3421
+ * Three outcomes, kept distinct: a server error replays as a failure, a benign
3422
+ * refusal (a filtered, truncated, or empty read, or a snapshot the local model
3423
+ * cannot represent) replays as `"Todo snapshot not mirrored"`, and a settled
3424
+ * snapshot replays as its summary. Collapsing the first into the second would
3425
+ * hide the failure and let downstream lifecycle logic treat it as success. The
3426
+ * refusal text must not say `"No todo changes"`: an `update_todos` the server
3427
+ * accepted may still be declined locally, and that is not "no changes".
3428
+ */
3429
+ function buildTodoToolResult(
3430
+ toolCallId: string,
3431
+ snapshot: CursorTodoSnapshot | null,
3432
+ error: string | null,
3433
+ ): ToolResultMessage {
3434
+ const text = error ?? (snapshot ? formatTodoSnapshotSummary(snapshot.todos) : "Todo snapshot not mirrored");
3435
+ return {
3436
+ role: "toolResult",
3437
+ toolCallId,
3438
+ toolName: "todo",
3439
+ content: [{ type: "text", text }],
3440
+ isError: error !== null,
3441
+ timestamp: Date.now(),
3442
+ };
3443
+ }
3444
+
3445
+ function formatTodoSnapshotSummary(todos: CursorTodoSnapshotItem[]): string {
3446
+ if (todos.length === 0) return "No todos";
3447
+ const done = todos.filter(todo => todo.status === "completed").length;
3448
+ return `${done}/${todos.length} tasks completed`;
3449
+ }
3450
+
3451
+ function buildMcpResultFromToolResult(_mcpCall: CursorMcpCall, toolResult: ToolResultMessage) {
3452
+ if (toolResult.isError) {
3453
+ return buildMcpErrorResult(toolResultToText(toolResult) || "MCP tool failed");
3454
+ }
3455
+ const content = toolResult.content.map(item => {
3456
+ if (item.type === "image") {
3457
+ return create(McpToolResultContentItemSchema, {
3458
+ content: {
3459
+ case: "image",
3460
+ value: create(McpImageContentSchema, {
3461
+ data: Uint8Array.from(Buffer.from(item.data, "base64")),
3462
+ mimeType: item.mimeType,
3463
+ }),
3464
+ },
3465
+ });
3466
+ }
3467
+ return create(McpToolResultContentItemSchema, {
3468
+ content: {
3469
+ case: "text",
3470
+ value: create(McpTextContentSchema, { text: item.text }),
3471
+ },
3472
+ });
3473
+ });
3474
+
3475
+ return create(McpResultSchema, {
3476
+ result: {
3477
+ case: "success",
3478
+ value: create(McpSuccessSchema, {
3479
+ content,
3480
+ isError: false,
3481
+ }),
3482
+ },
3483
+ });
3484
+ }
3485
+
3486
+ function buildMcpToolNotFoundResult(mcpCall: CursorMcpCall) {
3487
+ return create(McpResultSchema, {
3488
+ result: {
3489
+ case: "toolNotFound",
3490
+ value: create(McpToolNotFoundSchema, { name: mcpCall.toolName, availableTools: [] }),
3491
+ },
3492
+ });
3493
+ }
3494
+
3495
+ function buildMcpErrorResult(error: string) {
3496
+ return create(McpResultSchema, {
3497
+ result: {
3498
+ case: "error",
3499
+ value: create(McpErrorSchema, { error }),
3500
+ },
3501
+ });
3502
+ }
3503
+
3504
+ /**
3505
+ * Merge the decoded completion-frame `McpArgs` map into the args assembled
3506
+ * from streamed `args_text_delta` snapshots.
3507
+ *
3508
+ * The completion frame is authoritative for the scalars it carries — but it
3509
+ * can omit oversized parameters entirely and can downgrade a structured value
3510
+ * to its raw string fallback when `decodeMcpArgValue` cannot parse it as
3511
+ * JSON. Overwriting the streamed args wholesale therefore loses data (e.g.
3512
+ * the task tool's `tasks` array on multi-subagent dispatches, issue #2615).
3513
+ *
3514
+ * Rules per key:
3515
+ * - completion key absent → keep the streamed value.
3516
+ * - completion is a string while the streamed value is structured (object or
3517
+ * array) → keep the streamed value (the completion frame downgraded it).
3518
+ * - otherwise → completion wins.
3519
+ */
3520
+ export function mergeCursorMcpToolCallArgs(
3521
+ streamed: Record<string, unknown> | undefined,
3522
+ completion: Record<string, unknown> | undefined,
3523
+ ): Record<string, unknown> {
3524
+ const merged: Record<string, unknown> = { ...(streamed ?? {}) };
3525
+ if (!completion) return merged;
3526
+ for (const [key, completionValue] of Object.entries(completion)) {
3527
+ const streamedValue = merged[key];
3528
+ if (typeof completionValue === "string" && streamedValue !== null && typeof streamedValue === "object") {
3529
+ continue;
3530
+ }
3531
+ merged[key] = completionValue;
3532
+ }
3533
+ return merged;
3534
+ }
3535
+
3536
+ function endCurrentTextBlock(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState): void {
3537
+ const block = state.currentTextBlock;
3538
+ if (!block) return;
3539
+ const idx = output.content.indexOf(block);
3540
+ stream.push({
3541
+ type: "text_end",
3542
+ contentIndex: idx,
3543
+ content: block.text,
3544
+ partial: output,
3545
+ });
3546
+ state.setTextBlock(null);
3547
+ }
3548
+
3549
+ function endCurrentThinkingBlock(
3550
+ output: AssistantMessage,
3551
+ stream: AssistantMessageEventStream,
3552
+ state: BlockState,
3553
+ ): void {
3554
+ const block = state.currentThinkingBlock;
3555
+ if (!block) return;
3556
+ const idx = output.content.indexOf(block);
3557
+ stream.push({
3558
+ type: "thinking_end",
3559
+ contentIndex: idx,
3560
+ content: block.thinking,
3561
+ partial: output,
3562
+ });
3563
+ state.setThinkingBlock(null);
3564
+ }
3565
+
3566
+ /**
3567
+ * Synthesize a completed `toolCall` content block for a Cursor exec-channel
3568
+ * native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`)
3569
+ * or for an MCP exec frame whose corresponding interaction block is absent.
3570
+ *
3571
+ * Args arrive complete on the exec message, so the block opens and closes in
3572
+ * one step — no partial-JSON streaming path. Without this the persisted
3573
+ * assistant message carries only text/thinking blocks, and on replay the
3574
+ * following `toolResult` messages have no matching `toolCall.id` in
3575
+ * `renderSessionContext`, so they render beneath the final answer or disappear.
3576
+ *
3577
+ * The block is stamped with {@link kCursorExecResolved} so the shared
3578
+ * `agent-loop.ts` execution pass skips it — Cursor's server-driven exec
3579
+ * channel already ran the tool via the bridge and buffered the result, so
3580
+ * treating this block as runnable would re-execute the same side-effecting
3581
+ * tool a second time.
3582
+ *
3583
+ * Exported for tests to exercise ordering with adjacent text/thinking blocks.
3584
+ */
3585
+ export function synthesizeCursorExecToolCall(
3586
+ output: AssistantMessage,
3587
+ stream: AssistantMessageEventStream,
3588
+ state: BlockState,
3589
+ toolCallId: string,
3590
+ toolName: string,
3591
+ args: Record<string, unknown>,
3592
+ ): void {
3593
+ endCurrentTextBlock(output, stream, state);
3594
+ endCurrentThinkingBlock(output, stream, state);
3595
+ const block: ToolCallState = {
3596
+ type: "toolCall",
3597
+ id: toolCallId,
3598
+ name: toolName,
3599
+ arguments: args,
3600
+ [kStreamingBlockIndex]: output.content.length,
3601
+ [kStreamingBlockKind]: "cursor-exec",
3602
+ [kCursorExecResolved]: true,
3603
+ };
3604
+ output.content.push(block);
3605
+ const idx = output.content.length - 1;
3606
+ stream.push({ type: "toolcall_start", contentIndex: idx, partial: output });
3607
+ stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: output });
3608
+ }
3609
+
3610
+ /**
3611
+ * Pair a `toolResult` for a synthesized block the client answered itself,
3612
+ * without ever consulting a handler.
3613
+ *
3614
+ * {@link resolveExecHandler} does this for every frame backed by a local tool.
3615
+ * Frames answered from a fixed verdict — no handler, no local execution — still
3616
+ * need the pair for the same reason: the block was stamped
3617
+ * {@link kCursorExecResolved}, so `agent-loop.ts` emits no placeholder for it
3618
+ * and `buildSessionContext` strips an unpaired call, taking the whole
3619
+ * interaction out of every rebuilt transcript.
3620
+ *
3621
+ * `isError` defaults true because most such verdicts are refusals; the MCP
3622
+ * resource frames run locally and can genuinely succeed, and a success filed
3623
+ * as an error would render as a failed call in every rebuilt transcript.
3624
+ */
3625
+ async function pairSynthesizedExecResult(
3626
+ state: BlockState,
3627
+ onToolResult: CursorToolResultHandler | undefined,
3628
+ toolCallId: string,
3629
+ toolName: string,
3630
+ text: string,
3631
+ isError = true,
3632
+ ): Promise<void> {
3633
+ const synthesized: ToolResultMessage = {
3634
+ role: "toolResult",
3635
+ toolCallId,
3636
+ toolName,
3637
+ content: [{ type: "text", text }],
3638
+ isError,
3639
+ timestamp: Date.now(),
3640
+ };
3641
+ const sink = onToolResult ?? state.onToolResult;
3642
+ if (!sink) return;
3643
+ await sink(synthesized);
3644
+ }
3645
+
3646
+ /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
3647
+ export function processInteractionUpdate(
3648
+ update: any,
3649
+ output: AssistantMessage,
3650
+ stream: AssistantMessageEventStream,
3651
+ state: BlockState,
3652
+ usageState: UsageState,
3653
+ ): void {
3654
+ const updateCase = update.message?.case;
3655
+
3656
+ log("interactionUpdate", updateCase, update.message?.value);
3657
+
3658
+ if (updateCase === "textDelta") {
3659
+ state.setFirstTokenTime();
3660
+ const delta = update.message.value.text || "";
3661
+ if (!state.currentTextBlock) {
3662
+ const block: TextContent & { [kStreamingBlockIndex]: number } = {
3663
+ type: "text",
3664
+ text: "",
3665
+ [kStreamingBlockIndex]: output.content.length,
3666
+ };
3667
+ output.content.push(block);
3668
+ state.setTextBlock(block);
3669
+ stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
3670
+ }
3671
+ state.currentTextBlock!.text += delta;
3672
+ const idx = output.content.indexOf(state.currentTextBlock!);
3673
+ stream.push({ type: "text_delta", contentIndex: idx, delta, partial: output });
3674
+ } else if (updateCase === "thinkingDelta") {
3675
+ state.setFirstTokenTime();
3676
+ const delta = update.message.value.text || "";
3677
+ if (!state.currentThinkingBlock) {
3678
+ const block: ThinkingContent & { [kStreamingBlockIndex]: number } = {
3679
+ type: "thinking",
3680
+ thinking: "",
3681
+ [kStreamingBlockIndex]: output.content.length,
3682
+ };
3683
+ output.content.push(block);
3684
+ state.setThinkingBlock(block);
3685
+ stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });
3686
+ }
3687
+ state.currentThinkingBlock!.thinking += delta;
3688
+ const idx = output.content.indexOf(state.currentThinkingBlock!);
3689
+ stream.push({ type: "thinking_delta", contentIndex: idx, delta, partial: output });
3690
+ } else if (updateCase === "thinkingCompleted") {
3691
+ endCurrentThinkingBlock(output, stream, state);
3692
+ } else if (updateCase === "toolCallStarted" && selectConnectScmCall(update.message.value.toolCall)) {
3693
+ // `connect_scm` is resolved entirely server-side and has NO exec frame:
3694
+ // `ExecServerMessage` carries no connect-SCM case (field 44 is
3695
+ // `git_diff_request`), so the streamed pair is the only signal this client
3696
+ // sees. The authoritative outcome rides on the COMPLETION's `result`
3697
+ // oneof, so the block is opened here and settled there — answering now
3698
+ // would persist a verdict before the server has given one.
3699
+ //
3700
+ // Stamped resolved so `agent-loop.ts` runs no local tool for it: there is
3701
+ // no local `connect_scm`, and the completion pairs the result itself.
3702
+ endCurrentTextBlock(output, stream, state);
3703
+ endCurrentThinkingBlock(output, stream, state);
3704
+ const scmCall = selectConnectScmCall(update.message.value.toolCall);
3705
+ const repository = selectConnectScmRepository(scmCall);
3706
+ const block: ToolCallState = {
3707
+ type: "toolCall",
3708
+ id: scmCall?.args?.toolCallId || update.message.value.callId || crypto.randomUUID(),
3709
+ name: "connect_scm",
3710
+ arguments: repository ? { owner: repository.owner, repo: repository.repo } : {},
3711
+ [kStreamingBlockIndex]: output.content.length,
3712
+ [kStreamingBlockKind]: "connect-scm",
3713
+ [kStreamingEnvelopeId]: update.message.value.callId || undefined,
3714
+ [kCursorExecResolved]: true,
3715
+ };
3716
+ output.content.push(block);
3717
+ retainStreamedCall(state, block, update.message.value.callId);
3718
+ stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
3719
+ } else if (updateCase === "toolCallStarted" && isExecOwnedToolCall(update.message.value.toolCall)) {
3720
+ // The exec channel already synthesized this block (and marked it resolved)
3721
+ // when it ran the tool locally, so the streamed announcement must not
3722
+ // create a second one. Modern builds stream a `pi_*_tool_call` envelope
3723
+ // alongside every `ExecServerMessage` 45-51 frame; before this branch the
3724
+ // duplicate was avoided only because the decoder recognised neither, which
3725
+ // would silently start double-rendering the moment a variant was added.
3726
+ endCurrentTextBlock(output, stream, state);
3727
+ endCurrentThinkingBlock(output, stream, state);
3728
+ log("exec", "streamedToolCallOwnedByExec", { case: update.message.value.toolCall?.tool?.case });
3729
+ } else if (updateCase === "toolCallStarted") {
3730
+ endCurrentTextBlock(output, stream, state);
3731
+ endCurrentThinkingBlock(output, stream, state);
3732
+ const toolCall = update.message.value.toolCall;
3733
+ if (toolCall) {
3734
+ const mcpCall = selectMcpCall(toolCall);
3735
+ if (mcpCall) {
3736
+ const args = mcpCall.args || {};
3737
+ const id = args.toolCallId || crypto.randomUUID();
3738
+ const resolvedByExec = state.resolvedMcpToolCallIds.delete(id);
3739
+ if (resolvedByExec && output.content.some(block => block.type === "toolCall" && block.id === id)) {
3740
+ return;
3741
+ }
3742
+ const block: ToolCallState = {
3743
+ type: "toolCall",
3744
+ id,
3745
+ // Same precedence as `decodeMcpCall` (`toolName || name`), which is
3746
+ // what the exec channel pairs its result under. Diverging here would
3747
+ // name the block one thing and its result another.
3748
+ name: args.toolName || args.name || "",
3749
+ arguments: {},
3750
+ [kStreamingBlockIndex]: output.content.length,
3751
+ [kStreamingPartialJson]: "",
3752
+ [kStreamingBlockKind]: "mcp",
3753
+ [kStreamingEnvelopeId]: update.message.value.callId || undefined,
3754
+ };
3755
+ if (resolvedByExec) {
3756
+ markCursorExecResolved(block);
3757
+ }
3758
+ output.content.push(block);
3759
+ retainStreamedCall(state, block, update.message.value.callId);
3760
+ stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
3761
+ return;
3762
+ }
3763
+
3764
+ // Cursor resolves `update_todos` / `read_todos` server-side and settles
3765
+ // them on the tool call's `result`. Both blocks are stamped resolved so
3766
+ // `agent-loop.ts` never runs them locally: there is no local tool behind
3767
+ // them, and executing one would emit a spurious toolResult and drive an
3768
+ // extra continuation turn. Local state is mirrored on completion, from
3769
+ // the server's success snapshot only.
3770
+ const todoCalls = selectTodoCalls(toolCall);
3771
+ if (todoCalls.update || todoCalls.read) {
3772
+ const callId = update.message.value.callId || crypto.randomUUID();
3773
+ const block: ToolCallState = {
3774
+ type: "toolCall",
3775
+ id: callId,
3776
+ name: "todo",
3777
+ arguments: buildTodoDisplayArgs(toolCall),
3778
+ [kStreamingBlockIndex]: output.content.length,
3779
+ [kStreamingBlockKind]: "todo",
3780
+ // Only the real envelope id is a correlation key; the minted
3781
+ // fallback below names no frame the server will ever send back.
3782
+ [kStreamingEnvelopeId]: update.message.value.callId || undefined,
3783
+ [kCursorExecResolved]: true,
3784
+ };
3785
+ output.content.push(block);
3786
+ retainStreamedCall(state, block, update.message.value.callId);
3787
+ stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
3788
+ }
3789
+ }
3790
+ } else if (updateCase === "toolCallDelta" || updateCase === "partialToolCall") {
3791
+ // Same correlation rule as the completion path below: an argument delta
3792
+ // belonging to a different call must not be appended to this block's
3793
+ // buffer, which would corrupt the JSON both of them parse.
3794
+ const target = resolveStreamedCall(state, update.message.value.callId);
3795
+ if (target?.[kStreamingBlockKind] === "mcp") {
3796
+ // Cursor's `args_text_delta` is "aggregated args text so far" per agent.proto: each
3797
+ // delta is a cumulative snapshot of the JSON-text args. Strip the prefix we already
3798
+ // have to recover the new suffix; fall back to treating the value as an incremental
3799
+ // fragment when it doesn't extend the buffer.
3800
+ const snapshot: string = update.message.value.argsTextDelta || "";
3801
+ const current = target[kStreamingPartialJson] ?? "";
3802
+ const chunk = snapshot.startsWith(current) ? snapshot.slice(current.length) : snapshot;
3803
+ if (chunk.length === 0) {
3804
+ return;
3805
+ }
3806
+ const nextBuffer = current + chunk;
3807
+ target[kStreamingPartialJson] = nextBuffer;
3808
+ // Throttle mid-stream parses to keep total parse work O(N) instead of O(N²)
3809
+ // in the argument-buffer length; the authoritative full parse runs in
3810
+ // `toolCallCompleted` (mcp branch) and the fallback end-of-stream path.
3811
+ const throttled = parseStreamingJsonThrottled(nextBuffer, target[kStreamingLastParseLen] ?? 0);
3812
+ if (throttled) {
3813
+ target.arguments = throttled.value;
3814
+ target[kStreamingLastParseLen] = throttled.parsedLen;
3815
+ }
3816
+ const idx = output.content.indexOf(target);
3817
+ stream.push({ type: "toolcall_delta", contentIndex: idx, delta: chunk, partial: output });
3818
+ }
3819
+ } else if (updateCase === "toolCallCompleted") {
3820
+ // Correlate on the envelope's `call_id`, NOT the block id: MCP, Pi and SCM
3821
+ // blocks are filed under the id inside the call's `args` (which is what
3822
+ // the exec channel pairs its result under), and that need not equal the
3823
+ // envelope id. Cursor also interleaves calls, so the block this settles
3824
+ // is looked up by id rather than assumed to be the last one opened —
3825
+ // otherwise an unrelated completion closes whichever block is current and
3826
+ // pairs it with the wrong result.
3827
+ const settled = resolveStreamedCall(state, update.message.value.callId);
3828
+ if (settled) {
3829
+ const toolCall = update.message.value.toolCall;
3830
+ if (settled[kStreamingBlockKind] === "mcp") {
3831
+ // Authoritative full parse of the accumulated argument buffer; the delta
3832
+ // path throttles mid-stream parses, so `arguments` may lag the buffer.
3833
+ const partial = settled[kStreamingPartialJson];
3834
+ if (partial !== undefined) {
3835
+ settled.arguments = parseStreamingJson(partial);
3836
+ }
3837
+ const decodedArgs = decodeMcpArgsMap(selectMcpCall(toolCall)?.args?.args);
3838
+ settled.arguments = mergeCursorMcpToolCallArgs(
3839
+ settled.arguments as Record<string, unknown> | undefined,
3840
+ decodedArgs,
3841
+ );
3842
+ } else if (settled[kStreamingBlockKind] === "connect-scm") {
3843
+ // The authoritative outcome arrives only here, on the completion's
3844
+ // `ConnectScmResult` oneof. The block was stamped resolved at start,
3845
+ // so nothing downstream pairs it: settling is this branch's job, and
3846
+ // a completion with no `toolCall` still settles rather than leaking a
3847
+ // dangling call into every rebuilt transcript.
3848
+ //
3849
+ // Late args are merged too — a start frame may announce the call
3850
+ // before the target repository is known.
3851
+ const scmCall = selectConnectScmCall(toolCall);
3852
+ const repository = selectConnectScmRepository(scmCall);
3853
+ if (repository) {
3854
+ settled.arguments = { owner: repository.owner, repo: repository.repo };
3855
+ }
3856
+ const { text, isError } = describeConnectScmResult(scmCall);
3857
+ state.onToolResult?.({
3858
+ role: "toolResult",
3859
+ toolCallId: settled.id,
3860
+ toolName: "connect_scm",
3861
+ content: [{ type: "text", text }],
3862
+ isError,
3863
+ timestamp: Date.now(),
3864
+ });
3865
+ } else if (settled[kStreamingBlockKind] === "todo") {
3866
+ // Only the server's success snapshot is authoritative: the request args
3867
+ // may differ from what was actually stored after a merge, and on
3868
+ // `UpdateTodosError` nothing was stored at all. No snapshot => leave
3869
+ // both the rendered args and local session state untouched.
3870
+ //
3871
+ // A completion frame whose optional `toolCall` is absent carries
3872
+ // neither, but must still settle: the block is already marked
3873
+ // `kCursorExecResolved`, so `agent-loop.ts` emits no placeholder for
3874
+ // it and an unpaired call is stripped from every rebuilt transcript.
3875
+ // It reads as "nothing to mirror", the same as a refused snapshot.
3876
+ const snapshot = toolCall ? extractTodoSnapshot(toolCall) : null;
3877
+ const error = toolCall ? extractTodoError(toolCall) : null;
3878
+ if (snapshot) {
3879
+ settled.arguments = { todos: snapshot.todos, merged: snapshot.merged };
3880
+ }
3881
+ // The host settles EVERY completed native todo call, successful or
3882
+ // not: the interactive card only resolves on a matching
3883
+ // `tool_execution_end`, so staying silent on a refusal or a server
3884
+ // error would leave it animating for the rest of the session. The
3885
+ // streamed call id is reused because the transcript filed the block
3886
+ // under it.
3887
+ //
3888
+ // Exactly one result is persisted. The host's is preferred — only it
3889
+ // carries the `details.phases` the todo renderer replays the list
3890
+ // from — with the provider's summary standing in when the host has
3891
+ // nothing to add.
3892
+ let persisted: ToolResultMessage | undefined;
3893
+ let hostError: string | null = null;
3894
+ try {
3895
+ persisted = state.onTodoSnapshot?.(snapshot, settled.id, error) ?? undefined;
3896
+ } catch (callbackError) {
3897
+ // A throwing host callback (e.g. session persistence failing on
3898
+ // disk error) must not leave the resolved block unpaired: the
3899
+ // exception would skip both the paired result and `toolcall_end`,
3900
+ // stranding the live card and stripping the call from every
3901
+ // rebuilt transcript. Settle it as a failure instead.
3902
+ hostError = callbackError instanceof Error ? callbackError.message : String(callbackError);
3903
+ log("error", "onTodoSnapshot", { error: hostError });
3904
+ }
3905
+ state.onToolResult?.(persisted ?? buildTodoToolResult(settled.id, snapshot, hostError ?? error));
3906
+ }
3907
+ const idx = output.content.indexOf(settled);
3908
+ clearStreamingPartialJson(settled);
3909
+ stream.push({ type: "toolcall_end", contentIndex: idx, toolCall: settled, partial: output });
3910
+ releaseStreamedCall(state, settled);
3911
+ }
3912
+ } else if (updateCase === "turnEnded") {
3913
+ output.stopReason = "stop";
3914
+ if (
3915
+ isKimiK3ModelId(output.model) &&
3916
+ !output.content.some(item => item.type === "thinking" && item.thinking.length > 0)
3917
+ ) {
3918
+ logger.warn(
3919
+ "Cursor kimi-k3 turn completed without thinking blocks; persisted history will replay this turn without reasoning",
3920
+ { model: output.model, messageTimestamp: output.timestamp },
3921
+ );
3922
+ }
3923
+ } else if (updateCase === "tokenDelta") {
3924
+ const tokenDelta = update.message.value;
3925
+ usageState.sawTokenDelta = true;
3926
+ output.usage.output += tokenDelta.tokens || 0;
3927
+ output.usage.totalTokens = output.usage.input + output.usage.output;
3928
+ }
3929
+ }
3930
+
3931
+ function handleConversationCheckpointUpdate(
3932
+ checkpoint: ConversationStateStructure,
3933
+ output: AssistantMessage,
3934
+ usageState: UsageState,
3935
+ onConversationCheckpoint?: (checkpoint: ConversationStateStructure) => void,
3936
+ ): void {
3937
+ onConversationCheckpoint?.(checkpoint);
3938
+ if (usageState.sawTokenDelta) {
3939
+ return;
3940
+ }
3941
+ const usedTokens = checkpoint.tokenDetails?.usedTokens ?? 0;
3942
+ if (usedTokens <= 0) {
3943
+ return;
3944
+ }
3945
+ if (output.usage.contextTokens !== usedTokens) {
3946
+ output.usage.contextTokens = usedTokens;
3947
+ }
3948
+ }
3949
+
3950
+ function createBlobId(data: Uint8Array): Uint8Array {
3951
+ return new Uint8Array(createHash("sha256").update(data).digest());
3952
+ }
3953
+
3954
+ function storeCursorBlob(blobStore: Map<string, Uint8Array>, data: Uint8Array): Uint8Array {
3955
+ const blobId = createBlobId(data);
3956
+ blobStore.set(Buffer.from(blobId).toString("hex"), data);
3957
+ return blobId;
3958
+ }
3959
+
3960
+ function readCursorBlob(blobStore: Map<string, Uint8Array>, blobId: Uint8Array): Uint8Array {
3961
+ const data = blobStore.get(Buffer.from(blobId).toString("hex"));
3962
+ if (!data) {
3963
+ throw new AIError.ValidationError("Cursor blob not found");
3964
+ }
3965
+ return data;
3966
+ }
3967
+
3968
+ /**
3969
+ * Local tools Cursor already drives natively over the exec channel, so
3970
+ * advertising them again as MCP tools would give the model two ways to call the
3971
+ * same thing.
3972
+ *
3973
+ * `lsp` is deliberately NOT here. The native `diagnosticsArgs` frame covers
3974
+ * exactly one of the tool's actions (`action: "diagnostics"`); the rest —
3975
+ * `definition`, `references`, `rename`, `code_actions`, `hover`,
3976
+ * `implementation`, `type_definition`, `symbols`, ... — have no native frame at
3977
+ * all, so filtering the whole tool out hid every one of them from the model.
3978
+ */
3979
+ const CURSOR_NATIVE_TOOL_NAMES = new Set(["bash", "read", "write", "delete", "ls", "grep", "todo"]);
3980
+
3981
+ export function buildMcpToolDefinitions(tools: Tool[] | undefined): McpToolDefinition[] {
3982
+ if (!tools || tools.length === 0) {
3983
+ return [];
3984
+ }
3985
+
3986
+ const advertisedTools = tools.filter(tool => !CURSOR_NATIVE_TOOL_NAMES.has(tool.name));
3987
+ if (advertisedTools.length === 0) {
3988
+ return [];
3989
+ }
3990
+
3991
+ // The `write` tool doubles as the xd:// transport: forwarded devices such as
3992
+ // `ast_edit` stage previews finalized only by writing a reason to xd://resolve
3993
+ // or xd://reject. Cursor's native catalog may expose no write path, so
3994
+ // re-include the built-in `write` (dropped as native above) whenever pi-agent
3995
+ // devices are advertised — otherwise a staged preview can never be resolved
3996
+ // and the SoftToolRequirement('write') escalation aborts the turn.
3997
+ const writeTool = tools.find(tool => tool.name === "write");
3998
+ const forwarded = writeTool ? [...advertisedTools, writeTool] : advertisedTools;
3999
+
4000
+ return forwarded.map(tool => {
4001
+ const jsonSchema = toolWireSchema(tool);
4002
+ const schemaValue: JsonValue =
4003
+ jsonSchema && typeof jsonSchema === "object"
4004
+ ? (jsonSchema as JsonValue)
4005
+ : { type: "object", properties: {}, required: [] };
4006
+ const inputSchema = toBinary(ValueSchema, fromJson(ValueSchema, schemaValue));
4007
+ return create(McpToolDefinitionSchema, {
4008
+ name: tool.name,
4009
+ description: tool.description || "",
4010
+ providerIdentifier: "pi-agent",
4011
+ toolName: tool.name,
4012
+ inputSchema,
4013
+ });
4014
+ });
4015
+ }
4016
+
4017
+ /**
4018
+ * Extract text content from a user or developer message.
4019
+ */
4020
+ function extractUserMessageText(msg: Message): string {
4021
+ if (msg.role !== "user" && msg.role !== "developer") return "";
4022
+ const content = msg.content;
4023
+ if (typeof content === "string") return content.trim();
4024
+ const text = content
4025
+ .filter((c): c is TextContent => c.type === "text")
4026
+ .map(c => c.text)
4027
+ .join("\n");
4028
+ return text.trim();
4029
+ }
4030
+
4031
+ function hasUserMessageImages(msg: Message): boolean {
4032
+ return (
4033
+ (msg.role === "user" || msg.role === "developer") &&
4034
+ Array.isArray(msg.content) &&
4035
+ msg.content.some(item => item.type === "image")
4036
+ );
4037
+ }
4038
+
4039
+ type CursorRootPromptContentPart = { type: "text"; text: string } | { type: "image"; image: string; mediaType: string };
4040
+
4041
+ function buildCursorRootPromptContent(content: string | (TextContent | ImageContent)[]): CursorRootPromptContentPart[] {
4042
+ if (typeof content === "string") {
4043
+ const text = content.trim();
4044
+ return text ? [{ type: "text", text }] : [];
4045
+ }
4046
+ const parts: CursorRootPromptContentPart[] = [];
4047
+ for (const item of content) {
4048
+ if (item.type === "text") {
4049
+ const text = item.text.trim();
4050
+ if (text) {
4051
+ parts.push({ type: "text", text });
4052
+ }
4053
+ } else {
4054
+ parts.push({ type: "image", image: `data:${item.mimeType};base64,${item.data}`, mediaType: item.mimeType });
4055
+ }
4056
+ }
4057
+ return parts;
4058
+ }
4059
+
4060
+ function cursorUserContentKey(content: string | (TextContent | ImageContent)[]): string {
4061
+ if (typeof content === "string") {
4062
+ return content.trim();
4063
+ }
4064
+ const hash = createHash("sha256");
4065
+ for (const item of content) {
4066
+ hash.update(item.type);
4067
+ if (item.type === "text") {
4068
+ hash.update(item.text);
4069
+ } else {
4070
+ hash.update(item.mimeType);
4071
+ hash.update(item.data);
4072
+ }
4073
+ }
4074
+ return hash.digest("hex");
4075
+ }
4076
+
4077
+ type CursorRootPromptAssistantContentPart =
4078
+ | { type: "text"; text: string }
4079
+ | {
4080
+ type: "reasoning";
4081
+ text: string;
4082
+ providerOptions: { cursor: { modelName: string } };
4083
+ signature?: string;
4084
+ }
4085
+ | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> };
4086
+
4087
+ function canReplayCursorThinking(msg: AssistantMessage, targetModelId: string | undefined): boolean {
4088
+ return (
4089
+ targetModelId !== undefined &&
4090
+ isKimiK3ModelId(targetModelId) &&
4091
+ msg.api === "cursor-agent" &&
4092
+ msg.provider === "cursor" &&
4093
+ msg.model === targetModelId
4094
+ );
4095
+ }
4096
+
4097
+ function buildCursorAssistantContent(
4098
+ msg: AssistantMessage,
4099
+ targetModelId: string | undefined,
4100
+ ): CursorRootPromptAssistantContentPart[] {
4101
+ const content: CursorRootPromptAssistantContentPart[] = [];
4102
+ const replayThinking = canReplayCursorThinking(msg, targetModelId);
4103
+ for (const item of msg.content) {
4104
+ if (item.type === "text") {
4105
+ if (item.text) content.push({ type: "text", text: item.text });
4106
+ } else if (item.type === "thinking") {
4107
+ if (replayThinking && item.thinking) {
4108
+ content.push({
4109
+ type: "reasoning",
4110
+ text: item.thinking,
4111
+ providerOptions: { cursor: { modelName: msg.model } },
4112
+ ...(item.thinkingSignature ? { signature: item.thinkingSignature } : {}),
4113
+ });
4114
+ }
4115
+ } else if (item.type === "toolCall") {
4116
+ content.push({
4117
+ type: "tool-call",
4118
+ toolCallId: item.id,
4119
+ toolName: item.name,
4120
+ args: item.arguments,
4121
+ });
4122
+ }
4123
+ }
4124
+ return content;
4125
+ }
4126
+
4127
+ function assertCursorKimiK3HistoryReplayable(
4128
+ messages: Message[],
4129
+ activeUserMessageIndex: number,
4130
+ targetModelId: string | undefined,
4131
+ ): void {
4132
+ if (!targetModelId || !isKimiK3ModelId(targetModelId)) return;
4133
+ const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length;
4134
+ const missingThinkingTurns: number[] = [];
4135
+ const newlyWarnedKeys: string[] = [];
4136
+ let assistantTurn = 0;
4137
+ for (let i = 0; i < historyEnd; i++) {
4138
+ const msg = messages[i];
4139
+ if (msg.role !== "assistant") continue;
4140
+ assistantTurn++;
4141
+ const isSameCursorModel = msg.api === "cursor-agent" && msg.provider === "cursor" && msg.model === targetModelId;
4142
+ if (!isSameCursorModel) {
4143
+ // Foreign history genuinely cannot replay K3 thinking: another model's
4144
+ // turns carry no K3-signed reasoning to reconstruct.
4145
+ throw new AIError.ValidationError(
4146
+ `Cursor ${targetModelId} cannot continue history from a different model (${msg.provider}/${msg.model}); start a new session.`,
4147
+ );
4148
+ }
4149
+ const hasThinking = msg.content.some(item => item.type === "thinking" && item.thinking.length > 0);
4150
+ if (hasThinking) continue;
4151
+ const warningKey = `${msg.api}\0${msg.provider}\0${msg.model}\0${msg.timestamp}`;
4152
+ if (warnedCursorKimiK3ReplayMessages.has(warningKey)) continue;
4153
+ missingThinkingTurns.push(assistantTurn);
4154
+ newlyWarnedKeys.push(warningKey);
4155
+ }
4156
+ if (missingThinkingTurns.length === 0) return;
4157
+ for (const key of newlyWarnedKeys) warnedCursorKimiK3ReplayMessages.add(key);
4158
+ logger.warn(
4159
+ `Cursor kimi-k3 history contains same-model assistant turn(s) ${missingThinkingTurns.join(", ")} without thinking blocks; replaying those spans without reasoning may make generation less stable`,
4160
+ { model: targetModelId, assistantTurns: missingThinkingTurns },
4161
+ );
4162
+ }
4163
+
4164
+ /**
4165
+ * Index of the last user/developer message in `messages`, or -1 if none.
4166
+ * Used to exclude the current user turn from history builders — it goes in
4167
+ * `ConversationActionSchema.userMessageAction`, not in history structures.
4168
+ */
4169
+ function findLastUserMessageIndex(messages: Message[]): number {
4170
+ for (let i = messages.length - 1; i >= 0; i--) {
4171
+ const role = messages[i].role;
4172
+ if (role === "user" || role === "developer") {
4173
+ return i;
4174
+ }
4175
+ }
4176
+ return -1;
4177
+ }
4178
+
4179
+ /**
4180
+ * Build `ConversationStateStructure.rootPromptMessagesJson` blob IDs for the
4181
+ * system prompt plus prior conversation history, as JSON blobs matching
4182
+ * Cursor's internal Vercel-AI-SDK-shaped message format.
4183
+ *
4184
+ * Cursor's server uses `rootPromptMessagesJson` (not `turns[]`) to build the
4185
+ * actual model prompt. `turns[]` is UI/display metadata. Without populating
4186
+ * this field, multi-turn conversations lose prior context — the model sees
4187
+ * only an empty placeholder where historical user turns should be.
4188
+ * The active user message is excluded because it is sent in the action.
4189
+ */
4190
+ /**
4191
+ * Build one Cursor system-message JSON blob per ordered system prompt. Emitting separate blobs
4192
+ * (rather than a single `\n\n`-joined string) lets Cursor's blob cache hit independently per
4193
+ * entry: changing only the last prompt does not invalidate earlier blob ids, so the prefix
4194
+ * up to the changed prompt remains cached on the server side.
4195
+ *
4196
+ * When no system prompts are provided, returns a single default greeting so we never emit
4197
+ * an empty `rootPromptMessagesJson` head.
4198
+ */
4199
+ export function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined): string[] {
4200
+ const systemPrompts = normalizeSystemPrompts(systemPrompt);
4201
+ if (systemPrompts.length === 0) {
4202
+ return [JSON.stringify({ role: "system", content: "You are a helpful assistant." })];
4203
+ }
4204
+ return systemPrompts.map(content => JSON.stringify({ role: "system", content }));
4205
+ }
4206
+
4207
+ function buildRootPromptMessagesJson(
4208
+ messages: Message[],
4209
+ systemPromptIds: Uint8Array[],
4210
+ blobStore: Map<string, Uint8Array>,
4211
+ activeUserMessageIndex = findLastUserMessageIndex(messages),
4212
+ targetModelId?: string,
4213
+ ): Uint8Array[] {
4214
+ assertCursorKimiK3HistoryReplayable(messages, activeUserMessageIndex, targetModelId);
4215
+ const entries: Uint8Array[] = [...systemPromptIds];
4216
+ const pushJson = (obj: unknown) => {
4217
+ const bytes = new TextEncoder().encode(JSON.stringify(obj));
4218
+ entries.push(storeCursorBlob(blobStore, bytes));
4219
+ };
4220
+
4221
+ for (let i = 0; i < messages.length; i++) {
4222
+ if (i === activeUserMessageIndex) break;
4223
+ const msg = messages[i];
4224
+ if (msg.role === "user" || msg.role === "developer") {
4225
+ const content = buildCursorRootPromptContent(msg.content);
4226
+ if (content.length === 0) continue;
4227
+ pushJson({ role: "user", content });
4228
+ } else if (msg.role === "assistant") {
4229
+ const content = buildCursorAssistantContent(msg, targetModelId);
4230
+ if (content.length === 0) continue;
4231
+ pushJson({ role: "assistant", content });
4232
+ } else if (msg.role === "toolResult") {
4233
+ // Emit even when the result text is empty: the assistant `tool-call` is
4234
+ // already in history, so dropping the pair would replay an orphaned call.
4235
+ pushJson({
4236
+ role: "tool",
4237
+ id: msg.toolCallId,
4238
+ content: [
4239
+ {
4240
+ type: "tool-result",
4241
+ toolName: msg.toolName,
4242
+ toolCallId: msg.toolCallId,
4243
+ result: toolResultToText(msg),
4244
+ ...(msg.isError ? { isError: true } : {}),
4245
+ },
4246
+ ],
4247
+ });
4248
+ }
4249
+ }
4250
+
4251
+ return entries;
4252
+ }
4253
+
4254
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
4255
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
4256
+ const prototype = Object.getPrototypeOf(value);
4257
+ return prototype === Object.prototype || prototype === null;
4258
+ }
4259
+
4260
+ function isJsonValue(value: unknown): value is JsonValue {
4261
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
4262
+ if (typeof value === "number") return Number.isFinite(value);
4263
+ if (Array.isArray(value)) return value.every(isJsonValue);
4264
+ if (!isPlainRecord(value)) return false;
4265
+ for (const key in value) {
4266
+ if (!isJsonValue(value[key])) return false;
4267
+ }
4268
+ return true;
4269
+ }
4270
+
4271
+ function encodeCursorMcpArguments(toolCall: ToolCall): Record<string, Uint8Array> {
4272
+ const encoded: Record<string, Uint8Array> = {};
4273
+ for (const name in toolCall.arguments) {
4274
+ const value = toolCall.arguments[name];
4275
+ if (value === undefined) continue;
4276
+ if (!isJsonValue(value)) {
4277
+ throw new AIError.ValidationError(`Cursor tool argument ${toolCall.name}.${name} is not JSON-serializable`);
4278
+ }
4279
+ encoded[name] = toBinary(ValueSchema, fromJson(ValueSchema, value));
4280
+ }
4281
+ return encoded;
4282
+ }
4283
+
4284
+ function createCursorMcpResult(result: ToolResultMessage) {
4285
+ if (result.isError) {
4286
+ return create(McpToolResultSchema, {
4287
+ result: {
4288
+ case: "error",
4289
+ value: create(McpToolErrorSchema, { error: toolResultToText(result) }),
4290
+ },
4291
+ });
4292
+ }
4293
+ return create(McpToolResultSchema, {
4294
+ result: {
4295
+ case: "success",
4296
+ value: create(McpSuccessSchema, {
4297
+ content: result.content.map(item =>
4298
+ item.type === "text"
4299
+ ? create(McpToolResultContentItemSchema, {
4300
+ content: { case: "text", value: create(McpTextContentSchema, { text: item.text }) },
4301
+ })
4302
+ : create(McpToolResultContentItemSchema, {
4303
+ content: {
4304
+ case: "image",
4305
+ value: create(McpImageContentSchema, {
4306
+ data: Uint8Array.from(Buffer.from(item.data, "base64")),
4307
+ mimeType: item.mimeType,
4308
+ }),
4309
+ },
4310
+ }),
4311
+ ),
4312
+ }),
4313
+ },
4314
+ });
4315
+ }
4316
+
4317
+ function createCursorToolCallStep(toolCall: ToolCall, result: ToolResultMessage | undefined) {
4318
+ const mcpCall = create(McpToolCallSchema, {
4319
+ args: create(McpArgsSchema, {
4320
+ name: toolCall.name,
4321
+ args: encodeCursorMcpArguments(toolCall),
4322
+ toolCallId: toolCall.id,
4323
+ providerIdentifier: "pi-agent",
4324
+ toolName: toolCall.name,
4325
+ }),
4326
+ ...(result ? { result: createCursorMcpResult(result) } : {}),
4327
+ });
4328
+ return create(ConversationStepSchema, {
4329
+ message: {
4330
+ case: "toolCall",
4331
+ value: create(ToolCallSchema, {
4332
+ tool: { case: "mcpToolCall", value: mcpCall },
4333
+ toolCallId: toolCall.id,
4334
+ }),
4335
+ },
4336
+ });
4337
+ }
4338
+
4339
+ /**
4340
+ * Convert context.messages to Cursor's ConversationTurnStructure blob IDs.
4341
+ * Groups messages into turns: each turn is a user message followed by the assistant's response.
4342
+ * Excludes the active user message (which goes in the action).
4343
+ *
4344
+ * Each `AgentConversationTurnStructure.user_message`, `steps[]`, and the outer
4345
+ * `ConversationStateStructure.turns[]` entry is a blob ID into `blobStore`.
4346
+ */
4347
+ function buildConversationTurns(
4348
+ messages: Message[],
4349
+ blobStore: Map<string, Uint8Array>,
4350
+ activeUserMessageIndex = findLastUserMessageIndex(messages),
4351
+ targetModelId?: string,
4352
+ ): Uint8Array[] {
4353
+ const turns: Uint8Array[] = [];
4354
+ const historyEnd = activeUserMessageIndex >= 0 ? activeUserMessageIndex : messages.length;
4355
+ const toolResults = new Map<string, ToolResultMessage>();
4356
+ const pairedToolCallIds = new Set<string>();
4357
+ for (let index = 0; index < historyEnd; index++) {
4358
+ const message = messages[index];
4359
+ if (message.role === "toolResult") {
4360
+ toolResults.set(message.toolCallId, message);
4361
+ } else if (message.role === "assistant") {
4362
+ for (const item of message.content) {
4363
+ if (item.type === "toolCall") pairedToolCallIds.add(item.id);
4364
+ }
4365
+ }
4366
+ }
4367
+
4368
+ let i = 0;
4369
+ while (i < messages.length) {
4370
+ const msg = messages[i];
4371
+ if (msg.role !== "user" && msg.role !== "developer") {
4372
+ i++;
4373
+ continue;
4374
+ }
4375
+ if (i === activeUserMessageIndex) break;
4376
+
4377
+ const userText = extractUserMessageText(msg);
4378
+ if (userText.length === 0 && !hasUserMessageImages(msg)) {
4379
+ i++;
4380
+ continue;
4381
+ }
4382
+
4383
+ const userMessage = createCursorUserMessage(
4384
+ msg.content,
4385
+ userText,
4386
+ deterministicUuid(`u:${turns.length}:${cursorUserContentKey(msg.content)}`),
4387
+ );
4388
+ const userMessageBlobId = storeCursorBlob(blobStore, toBinary(UserMessageSchema, userMessage));
4389
+ const stepBlobIds: Uint8Array[] = [];
4390
+ i++;
4391
+
4392
+ while (i < messages.length && messages[i].role !== "user" && messages[i].role !== "developer") {
4393
+ const stepMsg = messages[i];
4394
+ if (stepMsg.role === "assistant") {
4395
+ for (const item of stepMsg.content) {
4396
+ let step: ConversationStep;
4397
+ if (item.type === "text") {
4398
+ if (!item.text) continue;
4399
+ step = create(ConversationStepSchema, {
4400
+ message: {
4401
+ case: "assistantMessage",
4402
+ value: create(AssistantMessageSchema, { text: item.text }),
4403
+ },
4404
+ });
4405
+ } else if (item.type === "thinking") {
4406
+ // Same guard as root-prompt replay: only same-model Cursor K3
4407
+ // thinking is replayed, so foreign/hidden reasoning never leaks
4408
+ // into Cursor's turn history as native thinking.
4409
+ if (!item.thinking || !canReplayCursorThinking(stepMsg, targetModelId)) continue;
4410
+ step = create(ConversationStepSchema, {
4411
+ message: {
4412
+ case: "thinkingMessage",
4413
+ value: create(ThinkingMessageSchema, { text: item.thinking }),
4414
+ },
4415
+ });
4416
+ } else if (item.type === "toolCall") {
4417
+ step = createCursorToolCallStep(item, toolResults.get(item.id));
4418
+ } else {
4419
+ continue;
4420
+ }
4421
+ stepBlobIds.push(storeCursorBlob(blobStore, toBinary(ConversationStepSchema, step)));
4422
+ }
4423
+ } else if (stepMsg.role === "toolResult" && !pairedToolCallIds.has(stepMsg.toolCallId)) {
4424
+ const text = toolResultToText(stepMsg);
4425
+ if (text) {
4426
+ const prefix = stepMsg.isError ? "[Tool Error]" : "[Tool Result]";
4427
+ const step = create(ConversationStepSchema, {
4428
+ message: {
4429
+ case: "assistantMessage",
4430
+ value: create(AssistantMessageSchema, { text: `${prefix}\n${text}` }),
4431
+ },
4432
+ });
4433
+ stepBlobIds.push(storeCursorBlob(blobStore, toBinary(ConversationStepSchema, step)));
4434
+ }
4435
+ }
4436
+ i++;
4437
+ }
4438
+
4439
+ const agentTurn = create(AgentConversationTurnStructureSchema, {
4440
+ userMessage: userMessageBlobId,
4441
+ steps: stepBlobIds,
4442
+ });
4443
+ const turn = create(ConversationTurnStructureSchema, {
4444
+ turn: {
4445
+ case: "agentConversationTurn",
4446
+ value: agentTurn,
4447
+ },
4448
+ });
4449
+ turns.push(storeCursorBlob(blobStore, toBinary(ConversationTurnStructureSchema, turn)));
4450
+ }
4451
+
4452
+ return turns;
4453
+ }
4454
+
4455
+ /** Exported for tests: decodes Cursor history blobs built from conversation messages. */
4456
+ export function buildCursorHistoryForTest(
4457
+ messages: Message[],
4458
+ activeUserMessageIndex = findLastUserMessageIndex(messages),
4459
+ targetModelId?: string,
4460
+ ): {
4461
+ rootPromptMessagesJson: unknown[];
4462
+ turnUserMessagesJson: JsonValue[];
4463
+ turnStepMessagesJson: JsonValue[][];
4464
+ } {
4465
+ const blobStore = new Map<string, Uint8Array>();
4466
+ const rootPromptMessagesJson = buildRootPromptMessagesJson(
4467
+ messages,
4468
+ [],
4469
+ blobStore,
4470
+ activeUserMessageIndex,
4471
+ targetModelId,
4472
+ ).map(blobId => JSON.parse(new TextDecoder().decode(readCursorBlob(blobStore, blobId))));
4473
+ const turnUserMessagesJson: JsonValue[] = [];
4474
+ const turnStepMessagesJson: JsonValue[][] = [];
4475
+ for (const turnBlobId of buildConversationTurns(messages, blobStore, activeUserMessageIndex, targetModelId)) {
4476
+ const turn = fromBinary(ConversationTurnStructureSchema, readCursorBlob(blobStore, turnBlobId));
4477
+ if (turn.turn.case !== "agentConversationTurn") {
4478
+ continue;
4479
+ }
4480
+ const userMessage = fromBinary(UserMessageSchema, readCursorBlob(blobStore, turn.turn.value.userMessage));
4481
+ turnUserMessagesJson.push(toJson(UserMessageSchema, userMessage));
4482
+ turnStepMessagesJson.push(
4483
+ turn.turn.value.steps.map(stepBlobId => {
4484
+ const step = fromBinary(ConversationStepSchema, readCursorBlob(blobStore, stepBlobId));
4485
+ return toJson(ConversationStepSchema, step);
4486
+ }),
4487
+ );
4488
+ }
4489
+ return { rootPromptMessagesJson, turnUserMessagesJson, turnStepMessagesJson };
4490
+ }
4491
+ function createCursorUserMessage(
4492
+ content: string | (TextContent | ImageContent)[],
4493
+ text: string,
4494
+ messageId = crypto.randomUUID(),
4495
+ ) {
4496
+ const images = typeof content === "string" ? [] : extractImages(content);
4497
+ return create(UserMessageSchema, {
4498
+ text,
4499
+ messageId,
4500
+ ...(images.length > 0
4501
+ ? {
4502
+ selectedContext: create(SelectedContextSchema, {
4503
+ selectedImages: images,
4504
+ }),
4505
+ }
4506
+ : {}),
4507
+ });
4508
+ }
4509
+
4510
+ function extractImages(content: (TextContent | ImageContent)[]) {
4511
+ return content
4512
+ .filter((item): item is ImageContent => item.type === "image")
4513
+ .map(image =>
4514
+ create(SelectedImageSchema, {
4515
+ uuid: crypto.randomUUID(),
4516
+ mimeType: image.mimeType,
4517
+ dataOrBlobId: {
4518
+ case: "data",
4519
+ value: Uint8Array.from(Buffer.from(image.data, "base64")),
4520
+ },
4521
+ }),
4522
+ );
4523
+ }
4524
+
4525
+ function buildGrpcRequest(
4526
+ model: Model<"cursor-agent">,
4527
+ context: Context,
4528
+ options: CursorOptions | undefined,
4529
+ state: {
4530
+ conversationId: string;
4531
+ blobStore: Map<string, Uint8Array>;
4532
+ conversationState?: ConversationStateStructure;
4533
+ },
4534
+ ): {
4535
+ requestBytes: Uint8Array;
4536
+ blobStore: Map<string, Uint8Array>;
4537
+ conversationState: ConversationStateStructure;
4538
+ } {
4539
+ const blobStore = state.blobStore;
4540
+
4541
+ const systemPromptIds = buildCursorSystemPromptJsons(context.systemPrompt).map(json =>
4542
+ storeCursorBlob(blobStore, new TextEncoder().encode(json)),
4543
+ );
4544
+
4545
+ const activeUserMessageIndex = context.messages.length - 1;
4546
+ const activeMessage = context.messages[activeUserMessageIndex];
4547
+ const activeUserMessage =
4548
+ activeMessage?.role === "user" || activeMessage?.role === "developer" ? activeMessage : undefined;
4549
+ let userContent: string | (TextContent | ImageContent)[] | undefined;
4550
+ let userText = "";
4551
+ let hasUserImages = false;
4552
+ if (activeUserMessage?.role === "user" || activeUserMessage?.role === "developer") {
4553
+ userContent = activeUserMessage.content;
4554
+ if (typeof userContent === "string") {
4555
+ userText = userContent.trim();
4556
+ } else {
4557
+ userText = extractText(userContent);
4558
+ hasUserImages = hasImages(userContent);
4559
+ }
4560
+ }
4561
+
4562
+ const action = create(ConversationActionSchema, {
4563
+ action:
4564
+ userContent && (userText.trim().length > 0 || hasUserImages)
4565
+ ? {
4566
+ case: "userMessageAction",
4567
+ value: create(UserMessageActionSchema, {
4568
+ userMessage: createCursorUserMessage(userContent, userText),
4569
+ }),
4570
+ }
4571
+ : {
4572
+ case: "resumeAction",
4573
+ value: create(ResumeActionSchema, {}),
4574
+ },
4575
+ });
4576
+
4577
+ // Build conversation turns from prior messages, excluding only the active user message
4578
+ // when the request is sending one. Resume actions must preserve trailing tool results.
4579
+ const turns = buildConversationTurns(
4580
+ context.messages,
4581
+ blobStore,
4582
+ activeUserMessage ? activeUserMessageIndex : -1,
4583
+ model.id,
4584
+ );
4585
+
4586
+ // Build `rootPromptMessagesJson` from prior messages. Cursor's server uses this
4587
+ // field (not `turns[]`) to construct the actual model prompt; if we only send the
4588
+ // system prompt here, multi-turn conversations lose prior context and the model
4589
+ // sees only the current user message.
4590
+ const rootPromptMessagesJson = buildRootPromptMessagesJson(
4591
+ context.messages,
4592
+ systemPromptIds,
4593
+ blobStore,
4594
+ activeUserMessage ? activeUserMessageIndex : -1,
4595
+ model.id,
4596
+ );
4597
+
4598
+ // Preserve cached non-history state fields (todos, file states, summaries, etc.)
4599
+ // when the system prompt is unchanged; otherwise start fresh.
4600
+ const cachedPromptHead = state.conversationState?.rootPromptMessagesJson?.slice(0, systemPromptIds.length) ?? [];
4601
+ const hasMatchingPrompt =
4602
+ cachedPromptHead.length === systemPromptIds.length &&
4603
+ systemPromptIds.every((id, idx) => Buffer.from(cachedPromptHead[idx]).equals(id));
4604
+ const baseState =
4605
+ state.conversationState && hasMatchingPrompt
4606
+ ? state.conversationState
4607
+ : create(ConversationStateStructureSchema, {
4608
+ rootPromptMessagesJson: systemPromptIds,
4609
+ turns: [],
4610
+ todos: [],
4611
+ pendingToolCalls: [],
4612
+ previousWorkspaceUris: [],
4613
+ fileStates: {},
4614
+ fileStatesV2: {},
4615
+ summaryArchives: [],
4616
+ turnTimings: [],
4617
+ subagentStates: {},
4618
+ selfSummaryCount: 0,
4619
+ readPaths: [],
4620
+ });
4621
+
4622
+ // Always override `rootPromptMessagesJson` and `turns` with content freshly built from
4623
+ // `context.messages`. The server-echoed checkpoint replaces historical user entries
4624
+ // with empty placeholders, so we cannot rely on the cached `rootPromptMessagesJson`.
4625
+ const conversationState = create(ConversationStateStructureSchema, {
4626
+ ...baseState,
4627
+ rootPromptMessagesJson,
4628
+ turns,
4629
+ });
4630
+
4631
+ const wireModelId = model.requestModelId ?? model.id;
4632
+ const cursorMaxMode = model.cursorMaxMode === true;
4633
+ const modelDetails = create(ModelDetailsSchema, {
4634
+ modelId: wireModelId,
4635
+ displayModelId: model.id,
4636
+ displayName: model.name,
4637
+ ...(cursorMaxMode ? { maxMode: true } : undefined),
4638
+ });
4639
+ const requestedModel = create(RequestedModelSchema, {
4640
+ modelId: wireModelId,
4641
+ maxMode: cursorMaxMode,
4642
+ });
4643
+
4644
+ const runRequest = create(AgentRunRequestSchema, {
4645
+ conversationState,
4646
+ action,
4647
+ modelDetails,
4648
+ requestedModel,
4649
+ conversationId: state.conversationId,
4650
+ });
4651
+
4652
+ options?.onPayload?.(runRequest, model);
4653
+
4654
+ // Tools are sent later via requestContext (exec handshake)
4655
+
4656
+ if (options?.customSystemPrompt) {
4657
+ runRequest.customSystemPrompt = options.customSystemPrompt;
4658
+ }
4659
+
4660
+ const clientMessage = create(AgentClientMessageSchema, {
4661
+ message: { case: "runRequest", value: runRequest },
4662
+ });
4663
+
4664
+ const requestBytes = toBinary(AgentClientMessageSchema, clientMessage);
4665
+
4666
+ const toolNames = context.tools?.map(tool => tool.name) ?? [];
4667
+ const detail =
4668
+ $env.DEBUG_CURSOR === "2"
4669
+ ? ` ${JSON.stringify(clientMessage.message.value, debugReplacer, 2)?.slice(0, 2000)}`
4670
+ : "";
4671
+ log("info", "builtRunRequest", {
4672
+ bytes: requestBytes.length,
4673
+ tools: toolNames.length,
4674
+ toolNames: toolNames.slice(0, 20),
4675
+ detail: detail || undefined,
4676
+ });
4677
+
4678
+ return { requestBytes, blobStore, conversationState };
4679
+ }
4680
+
4681
+ function hasImages(content: (TextContent | ImageContent)[]): boolean {
4682
+ return content.some(item => item.type === "image");
4683
+ }
4684
+ function extractText(content: (TextContent | ImageContent)[]): string {
4685
+ return content
4686
+ .filter((c): c is TextContent => c.type === "text")
4687
+ .map(c => c.text)
4688
+ .join("\n");
4689
+ }