@stigmer/runner 3.0.9-dev.20260615153829 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/__test-utils__/approval-contract/types.d.ts +174 -0
  3. package/dist/__test-utils__/approval-contract/types.js +24 -0
  4. package/dist/__test-utils__/approval-contract/types.js.map +1 -0
  5. package/dist/activities/call-agent-status.d.ts +19 -1
  6. package/dist/activities/call-agent-status.js +64 -5
  7. package/dist/activities/call-agent-status.js.map +1 -1
  8. package/dist/activities/call-llm.js +19 -53
  9. package/dist/activities/call-llm.js.map +1 -1
  10. package/dist/activities/classify-tool-approvals.d.ts +24 -0
  11. package/dist/activities/classify-tool-approvals.js +69 -17
  12. package/dist/activities/classify-tool-approvals.js.map +1 -1
  13. package/dist/activities/discover-mcp-server.d.ts +7 -0
  14. package/dist/activities/discover-mcp-server.js +11 -1
  15. package/dist/activities/discover-mcp-server.js.map +1 -1
  16. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +140 -0
  17. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +130 -0
  18. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -0
  19. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.d.ts +18 -0
  20. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js +123 -0
  21. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js.map +1 -0
  22. package/dist/activities/execute-cursor/approval-policy.d.ts +17 -61
  23. package/dist/activities/execute-cursor/approval-policy.js +8 -120
  24. package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
  25. package/dist/activities/execute-cursor/approval-state.d.ts +219 -9
  26. package/dist/activities/execute-cursor/approval-state.js +233 -17
  27. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  28. package/dist/activities/execute-cursor/capture-flow.d.ts +127 -0
  29. package/dist/activities/execute-cursor/capture-flow.js +234 -0
  30. package/dist/activities/execute-cursor/capture-flow.js.map +1 -0
  31. package/dist/activities/execute-cursor/cas-observations.d.ts +96 -0
  32. package/dist/activities/execute-cursor/cas-observations.js +184 -0
  33. package/dist/activities/execute-cursor/cas-observations.js.map +1 -0
  34. package/dist/activities/execute-cursor/command-provenance.d.ts +62 -0
  35. package/dist/activities/execute-cursor/command-provenance.js +128 -0
  36. package/dist/activities/execute-cursor/command-provenance.js.map +1 -0
  37. package/dist/activities/execute-cursor/exact-apply.d.ts +110 -0
  38. package/dist/activities/execute-cursor/exact-apply.js +204 -0
  39. package/dist/activities/execute-cursor/exact-apply.js.map +1 -0
  40. package/dist/activities/execute-cursor/hook-script.d.ts +53 -24
  41. package/dist/activities/execute-cursor/hook-script.js +310 -47
  42. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  43. package/dist/activities/execute-cursor/index.d.ts +8 -1
  44. package/dist/activities/execute-cursor/index.js +500 -64
  45. package/dist/activities/execute-cursor/index.js.map +1 -1
  46. package/dist/activities/execute-cursor/message-translator.d.ts +258 -11
  47. package/dist/activities/execute-cursor/message-translator.js +836 -90
  48. package/dist/activities/execute-cursor/message-translator.js.map +1 -1
  49. package/dist/activities/execute-cursor/prompt-builder.d.ts +20 -23
  50. package/dist/activities/execute-cursor/prompt-builder.js +74 -15
  51. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  52. package/dist/activities/execute-cursor/workspace-setup.d.ts +17 -0
  53. package/dist/activities/execute-cursor/workspace-setup.js +212 -33
  54. package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.d.ts +19 -0
  56. package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js +143 -0
  57. package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js.map +1 -0
  58. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +88 -0
  59. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +81 -0
  60. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -0
  61. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +47 -0
  62. package/dist/activities/execute-deep-agent/approval-file-change.js +68 -0
  63. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -0
  64. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +8 -1
  65. package/dist/activities/execute-deep-agent/attachment-injector.js +7 -7
  66. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  67. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +42 -0
  68. package/dist/activities/execute-deep-agent/cas-capture-backend.js +47 -0
  69. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -0
  70. package/dist/activities/execute-deep-agent/cas-capture-observer.d.ts +79 -0
  71. package/dist/activities/execute-deep-agent/cas-capture-observer.js +112 -0
  72. package/dist/activities/execute-deep-agent/cas-capture-observer.js.map +1 -0
  73. package/dist/activities/execute-deep-agent/hitl.d.ts +16 -3
  74. package/dist/activities/execute-deep-agent/hitl.js +11 -3
  75. package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
  76. package/dist/activities/execute-deep-agent/index.d.ts +2 -1
  77. package/dist/activities/execute-deep-agent/index.js +393 -33
  78. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  79. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +7 -1
  80. package/dist/activities/execute-deep-agent/inline-publisher.js +23 -2
  81. package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
  82. package/dist/activities/execute-deep-agent/setup.d.ts +53 -2
  83. package/dist/activities/execute-deep-agent/setup.js +149 -92
  84. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  85. package/dist/activities/execute-deep-agent/stamp-flowed-rows.d.ts +36 -0
  86. package/dist/activities/execute-deep-agent/stamp-flowed-rows.js +56 -0
  87. package/dist/activities/execute-deep-agent/stamp-flowed-rows.js.map +1 -0
  88. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +34 -1
  89. package/dist/activities/execute-deep-agent/status-builder-shared.js +26 -25
  90. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
  91. package/dist/activities/execute-deep-agent/status-builder.d.ts +17 -5
  92. package/dist/activities/execute-deep-agent/status-builder.js +44 -3
  93. package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
  94. package/dist/activities/execute-deep-agent/streaming-side-effects.js +2 -19
  95. package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
  96. package/dist/activities/execute-deep-agent/streaming.js +3 -15
  97. package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
  98. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +25 -7
  99. package/dist/activities/execute-deep-agent/subagent-transformer.js +23 -7
  100. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  101. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +30 -3
  102. package/dist/activities/execute-deep-agent/subagent-wiring.js +29 -3
  103. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  104. package/dist/activities/execute-deep-agent/v3-status-builder.js +32 -2
  105. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
  106. package/dist/claimcheck/payload-codec.js +9 -5
  107. package/dist/claimcheck/payload-codec.js.map +1 -1
  108. package/dist/client/stigmer-client.d.ts +2 -0
  109. package/dist/client/stigmer-client.js +2 -0
  110. package/dist/client/stigmer-client.js.map +1 -1
  111. package/dist/middleware/approval-gate.d.ts +85 -4
  112. package/dist/middleware/approval-gate.js +165 -38
  113. package/dist/middleware/approval-gate.js.map +1 -1
  114. package/dist/middleware/types.d.ts +2 -5
  115. package/dist/shared/activity-input.d.ts +43 -0
  116. package/dist/shared/activity-input.js +17 -0
  117. package/dist/shared/activity-input.js.map +1 -0
  118. package/dist/shared/approval-canonicalize.d.ts +19 -0
  119. package/dist/shared/approval-canonicalize.js +119 -0
  120. package/dist/shared/approval-canonicalize.js.map +1 -0
  121. package/dist/shared/approval-fingerprint.d.ts +106 -0
  122. package/dist/shared/approval-fingerprint.js +113 -0
  123. package/dist/shared/approval-fingerprint.js.map +1 -0
  124. package/dist/shared/approval-policy.d.ts +182 -12
  125. package/dist/shared/approval-policy.js +213 -27
  126. package/dist/shared/approval-policy.js.map +1 -1
  127. package/dist/shared/args-preview.d.ts +52 -0
  128. package/dist/shared/args-preview.js +93 -0
  129. package/dist/shared/args-preview.js.map +1 -0
  130. package/dist/shared/artifact-storage.d.ts +19 -1
  131. package/dist/shared/artifact-storage.js +48 -11
  132. package/dist/shared/artifact-storage.js.map +1 -1
  133. package/dist/shared/file-change.d.ts +44 -0
  134. package/dist/shared/file-change.js +57 -0
  135. package/dist/shared/file-change.js.map +1 -0
  136. package/dist/shared/file-tools.d.ts +107 -0
  137. package/dist/shared/file-tools.js +168 -0
  138. package/dist/shared/file-tools.js.map +1 -0
  139. package/dist/shared/filereview/capture.d.ts +202 -0
  140. package/dist/shared/filereview/capture.js +498 -0
  141. package/dist/shared/filereview/capture.js.map +1 -0
  142. package/dist/shared/filereview/cas-substrate.d.ts +190 -0
  143. package/dist/shared/filereview/cas-substrate.js +284 -0
  144. package/dist/shared/filereview/cas-substrate.js.map +1 -0
  145. package/dist/shared/filereview/digest.d.ts +40 -0
  146. package/dist/shared/filereview/digest.js +66 -0
  147. package/dist/shared/filereview/digest.js.map +1 -0
  148. package/dist/shared/filereview/events.d.ts +170 -0
  149. package/dist/shared/filereview/events.js +298 -0
  150. package/dist/shared/filereview/events.js.map +1 -0
  151. package/dist/shared/filereview/git-substrate.d.ts +175 -0
  152. package/dist/shared/filereview/git-substrate.js +439 -0
  153. package/dist/shared/filereview/git-substrate.js.map +1 -0
  154. package/dist/shared/filereview/index.d.ts +11 -0
  155. package/dist/shared/filereview/index.js +12 -0
  156. package/dist/shared/filereview/index.js.map +1 -0
  157. package/dist/shared/filereview/secret-paths.d.ts +63 -0
  158. package/dist/shared/filereview/secret-paths.js +105 -0
  159. package/dist/shared/filereview/secret-paths.js.map +1 -0
  160. package/dist/shared/fingerprint-secret.d.ts +26 -0
  161. package/dist/shared/fingerprint-secret.js +47 -0
  162. package/dist/shared/fingerprint-secret.js.map +1 -0
  163. package/dist/shared/model-client.d.ts +51 -0
  164. package/dist/shared/model-client.js +77 -0
  165. package/dist/shared/model-client.js.map +1 -0
  166. package/dist/shared/plan-artifact.js +0 -2
  167. package/dist/shared/plan-artifact.js.map +1 -1
  168. package/dist/shared/status-offload.d.ts +83 -9
  169. package/dist/shared/status-offload.js +399 -79
  170. package/dist/shared/status-offload.js.map +1 -1
  171. package/dist/shared/status.js +14 -1
  172. package/dist/shared/status.js.map +1 -1
  173. package/dist/shared/tool-kind.d.ts +19 -0
  174. package/dist/shared/tool-kind.js +13 -0
  175. package/dist/shared/tool-kind.js.map +1 -1
  176. package/dist/shared/tool-row.d.ts +88 -0
  177. package/dist/shared/tool-row.js +127 -0
  178. package/dist/shared/tool-row.js.map +1 -0
  179. package/dist/shared/workspace/platform-dir.d.ts +25 -0
  180. package/dist/shared/workspace/platform-dir.js +38 -2
  181. package/dist/shared/workspace/platform-dir.js.map +1 -1
  182. package/dist/workflows/call-agent-orchestrator.js +56 -7
  183. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  184. package/dist/workflows/connect-mcp-server.d.ts +50 -0
  185. package/dist/workflows/connect-mcp-server.js +136 -15
  186. package/dist/workflows/connect-mcp-server.js.map +1 -1
  187. package/dist/workflows/types.d.ts +8 -0
  188. package/package.json +2 -2
  189. package/src/__test-utils__/approval-contract/contract.ts +224 -0
  190. package/src/__test-utils__/approval-contract/types.ts +179 -0
  191. package/src/__test-utils__/fake-artifact-storage.ts +72 -0
  192. package/src/__tests__/approval-gateway-contract.test.ts +29 -0
  193. package/src/__tests__/claimcheck-codec.test.ts +16 -53
  194. package/src/__tests__/golden-e2e.test.ts +2 -0
  195. package/src/__tests__/runner-token-coordinator.test.ts +3 -3
  196. package/src/activities/__tests__/call-agent-status.test.ts +135 -0
  197. package/src/activities/__tests__/call-llm.test.ts +1 -1
  198. package/src/activities/__tests__/classify-tool-approvals.test.ts +208 -1
  199. package/src/activities/__tests__/discover-mcp-server.test.ts +30 -0
  200. package/src/activities/__tests__/workflow-event-activities.test.ts +2 -1
  201. package/src/activities/call-agent-status.ts +74 -4
  202. package/src/activities/call-llm.ts +18 -63
  203. package/src/activities/classify-tool-approvals.ts +101 -19
  204. package/src/activities/discover-mcp-server.ts +29 -1
  205. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +216 -0
  206. package/src/activities/execute-cursor/__test-utils__/gateway-substrate.ts +148 -0
  207. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +41 -9
  208. package/src/activities/execute-cursor/__tests__/approval-state.test.ts +292 -0
  209. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +68 -1
  210. package/src/activities/execute-cursor/__tests__/capture-flow.test.ts +1005 -0
  211. package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +187 -0
  212. package/src/activities/execute-cursor/__tests__/coarse-fingerprint.test.ts +97 -0
  213. package/src/activities/execute-cursor/__tests__/command-provenance.test.ts +240 -0
  214. package/src/activities/execute-cursor/__tests__/deny-gate-exact-apply.test.ts +203 -0
  215. package/src/activities/execute-cursor/__tests__/exact-apply.test.ts +375 -0
  216. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +1294 -24
  217. package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +446 -0
  218. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +384 -110
  219. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +171 -25
  220. package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +189 -0
  221. package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +44 -23
  222. package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +190 -10
  223. package/src/activities/execute-cursor/approval-policy.ts +28 -159
  224. package/src/activities/execute-cursor/approval-state.ts +366 -18
  225. package/src/activities/execute-cursor/capture-flow.ts +323 -0
  226. package/src/activities/execute-cursor/cas-observations.ts +204 -0
  227. package/src/activities/execute-cursor/command-provenance.ts +168 -0
  228. package/src/activities/execute-cursor/exact-apply.ts +253 -0
  229. package/src/activities/execute-cursor/hook-script.ts +317 -51
  230. package/src/activities/execute-cursor/index.ts +575 -67
  231. package/src/activities/execute-cursor/message-translator.ts +963 -89
  232. package/src/activities/execute-cursor/prompt-builder.ts +80 -14
  233. package/src/activities/execute-cursor/workspace-setup.ts +257 -42
  234. package/src/activities/execute-deep-agent/__test-utils__/gateway-substrate.ts +180 -0
  235. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +134 -0
  236. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +84 -0
  237. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +11 -24
  238. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +64 -0
  239. package/src/activities/execute-deep-agent/__tests__/cas-capture-observer.test.ts +163 -0
  240. package/src/activities/execute-deep-agent/__tests__/hitl-integration.test.ts +8 -5
  241. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +342 -0
  242. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +284 -0
  243. package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +14 -36
  244. package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +31 -13
  245. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +349 -0
  246. package/src/activities/execute-deep-agent/__tests__/stamp-flowed-rows.test.ts +119 -0
  247. package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +91 -10
  248. package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +9 -9
  249. package/src/activities/execute-deep-agent/__tests__/subagent-approval-propagation.test.ts +160 -0
  250. package/src/activities/execute-deep-agent/__tests__/subagent-gitignored-capture.test.ts +213 -0
  251. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +3 -6
  252. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +84 -1
  253. package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +4 -1
  254. package/src/activities/execute-deep-agent/approval-file-change.ts +80 -0
  255. package/src/activities/execute-deep-agent/attachment-injector.ts +20 -11
  256. package/src/activities/execute-deep-agent/cas-capture-backend.ts +66 -0
  257. package/src/activities/execute-deep-agent/cas-capture-observer.ts +125 -0
  258. package/src/activities/execute-deep-agent/hitl.ts +23 -7
  259. package/src/activities/execute-deep-agent/index.ts +463 -40
  260. package/src/activities/execute-deep-agent/inline-publisher.ts +27 -4
  261. package/src/activities/execute-deep-agent/setup.ts +223 -125
  262. package/src/activities/execute-deep-agent/stamp-flowed-rows.ts +64 -0
  263. package/src/activities/execute-deep-agent/status-builder-shared.ts +62 -23
  264. package/src/activities/execute-deep-agent/status-builder.ts +63 -8
  265. package/src/activities/execute-deep-agent/streaming-side-effects.ts +2 -16
  266. package/src/activities/execute-deep-agent/streaming.ts +3 -13
  267. package/src/activities/execute-deep-agent/subagent-transformer.ts +53 -13
  268. package/src/activities/execute-deep-agent/subagent-wiring.ts +50 -3
  269. package/src/activities/execute-deep-agent/v3-status-builder.ts +36 -2
  270. package/src/claimcheck/payload-codec.ts +8 -8
  271. package/src/client/stigmer-client.ts +9 -1
  272. package/src/middleware/__tests__/approval-gate.test.ts +488 -4
  273. package/src/middleware/approval-gate.ts +247 -38
  274. package/src/middleware/types.ts +5 -5
  275. package/src/shared/__tests__/activity-input.test.ts +78 -0
  276. package/src/shared/__tests__/approval-canonicalize.test.ts +106 -0
  277. package/src/shared/__tests__/approval-fingerprint.test.ts +115 -0
  278. package/src/shared/__tests__/approval-policy.test.ts +274 -40
  279. package/src/shared/__tests__/args-preview.test.ts +78 -0
  280. package/src/shared/__tests__/artifact-storage-extended.test.ts +62 -10
  281. package/src/shared/__tests__/artifact-storage.test.ts +123 -11
  282. package/src/shared/__tests__/file-change.test.ts +85 -0
  283. package/src/shared/__tests__/file-tools.test.ts +90 -0
  284. package/src/shared/__tests__/fingerprint-secret.test.ts +51 -0
  285. package/src/shared/__tests__/lease-scope-corpus.test.ts +56 -0
  286. package/src/shared/__tests__/model-client.test.ts +162 -0
  287. package/src/shared/__tests__/plan-artifact.test.ts +11 -26
  288. package/src/shared/__tests__/policy-source-corpus.test.ts +58 -0
  289. package/src/shared/__tests__/status-offload.test.ts +573 -16
  290. package/src/shared/__tests__/status.test.ts +4 -5
  291. package/src/shared/__tests__/tool-kind.test.ts +24 -1
  292. package/src/shared/__tests__/tool-row.test.ts +221 -0
  293. package/src/shared/activity-input.ts +57 -0
  294. package/src/shared/approval-canonicalize.ts +159 -0
  295. package/src/shared/approval-fingerprint.ts +148 -0
  296. package/src/shared/approval-policy.ts +303 -27
  297. package/src/shared/args-preview.ts +98 -0
  298. package/src/shared/artifact-storage.ts +62 -11
  299. package/src/shared/checkpointer/__tests__/http-saver.test.ts +1 -2
  300. package/src/shared/file-change.ts +64 -0
  301. package/src/shared/file-tools.ts +169 -0
  302. package/src/shared/filereview/__tests__/capture.test.ts +856 -0
  303. package/src/shared/filereview/__tests__/cas-substrate.test.ts +404 -0
  304. package/src/shared/filereview/__tests__/digest.test.ts +100 -0
  305. package/src/shared/filereview/__tests__/events.test.ts +245 -0
  306. package/src/shared/filereview/__tests__/git-substrate.test.ts +362 -0
  307. package/src/shared/filereview/__tests__/proxy-reconcile.test.ts +286 -0
  308. package/src/shared/filereview/__tests__/secret-paths.test.ts +121 -0
  309. package/src/shared/filereview/capture.ts +727 -0
  310. package/src/shared/filereview/cas-substrate.ts +401 -0
  311. package/src/shared/filereview/digest.ts +83 -0
  312. package/src/shared/filereview/events.ts +449 -0
  313. package/src/shared/filereview/git-substrate.ts +555 -0
  314. package/src/shared/filereview/index.ts +60 -0
  315. package/src/shared/filereview/secret-paths.ts +121 -0
  316. package/src/shared/fingerprint-secret.ts +53 -0
  317. package/src/shared/model-client.ts +122 -0
  318. package/src/shared/plan-artifact.ts +0 -2
  319. package/src/shared/status-offload.ts +433 -77
  320. package/src/shared/status.ts +13 -0
  321. package/src/shared/tool-kind.ts +33 -0
  322. package/src/shared/tool-row.ts +135 -0
  323. package/src/shared/workspace/platform-dir.ts +41 -2
  324. package/src/workflow-engine/__tests__/golden-execution.test.ts +35 -18
  325. package/src/workflow-engine/__tests__/tasks/try.test.ts +1 -1
  326. package/src/workflows/__tests__/connect-mcp-server.test.ts +304 -29
  327. package/src/workflows/call-agent-orchestrator.ts +53 -6
  328. package/src/workflows/connect-mcp-server.ts +179 -24
  329. package/src/workflows/types.ts +8 -0
@@ -31,17 +31,28 @@
31
31
  */
32
32
 
33
33
  import { create } from "@bufbuild/protobuf";
34
+ import type { JsonObject } from "@bufbuild/protobuf";
34
35
  import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
35
36
  import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
36
37
  import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
37
38
  import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
38
- import { MessageType, ToolCallStatus, SubAgentStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
39
+ import { MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
39
40
  import type { SDKMessage } from "@cursor/sdk";
40
41
  import type { MergedToolPolicy } from "./approval-policy.js";
41
- import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage } from "./approval-policy.js";
42
- import { grantToken, toolIdentity, type DeniedLedgerEntry } from "./approval-state.js";
42
+ import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage, SALIENT_ARG_FIELDS } from "./approval-policy.js";
43
+ import {
44
+ POLICY_ENGINE_VERSION,
45
+ resolveApprovalProvenance,
46
+ toProtoPolicySource,
47
+ } from "../../shared/approval-policy.js";
48
+ import { grantToken, primaryToken, toolIdentity, type DeniedLedgerEntry } from "./approval-state.js";
43
49
  import { utcTimestamp } from "../../shared/status.js";
44
- import { classifyTool } from "../../shared/tool-kind.js";
50
+ import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
51
+ import { classifyTool, toolApprovalCategory, type ToolApprovalCategory } from "../../shared/tool-kind.js";
52
+ import { resolveWorkspacePath } from "../../shared/file-change.js";
53
+ import { contentDigest } from "../../shared/file-tools.js";
54
+ import { buildElidedArgsPreview } from "../../shared/args-preview.js";
55
+ import type { WorkspaceBackend } from "../../shared/workspace/types.js";
45
56
 
46
57
  export { utcTimestamp };
47
58
 
@@ -163,10 +174,17 @@ function translateToolCall(event: Extract<SDKMessage, { type: "tool_call" }>): A
163
174
  *
164
175
  * Approval fields are populated when mergedPolicies are provided.
165
176
  * Without policies, only basic fields are set (backward compatible).
177
+ *
178
+ * When mergedPolicies are provided, the tool call also carries its authorization
179
+ * provenance (approval_policy_source) — which policy layer gated or cleared it —
180
+ * derived from the same merged policy chain the gate uses, so the Cursor
181
+ * reconstruction is as auditable as the native harness. `provenance` supplies the
182
+ * run-scoped context (global bypass, active leases) the per-tool map cannot.
166
183
  */
167
184
  export function buildToolCallProto(
168
185
  event: Extract<SDKMessage, { type: "tool_call" }>,
169
186
  mergedPolicies?: Map<string, MergedToolPolicy>,
187
+ provenance?: ApprovalProvenanceContext,
170
188
  ): ToolCall {
171
189
  const status = mapToolCallStatus(event.status);
172
190
  const mcpDetails = extractMcpToolDetails(event);
@@ -231,9 +249,38 @@ export function buildToolCallProto(
231
249
  }
232
250
  }
233
251
 
252
+ // Stamp authorization provenance from the same merged policy chain the gate
253
+ // (the deny-oracle hook + this map) uses, so the persisted record explains WHY
254
+ // each tool was gated or cleared. Only when policies are present — the stateless
255
+ // path leaves it UNSPECIFIED, like an unclassified tool_kind.
256
+ if (mergedPolicies) {
257
+ const source = resolveApprovalProvenance(
258
+ actualName,
259
+ mcpServerSlug,
260
+ mergedPolicies,
261
+ provenance?.leasedCategories ?? NO_LEASED_CATEGORIES,
262
+ provenance?.globalBypass ?? false,
263
+ );
264
+ toolCall.approvalPolicySource = toProtoPolicySource(source);
265
+ if (source) toolCall.policyEngineVersion = POLICY_ENGINE_VERSION;
266
+ }
267
+
234
268
  return toolCall;
235
269
  }
236
270
 
271
+ /**
272
+ * Run-scoped approval context the Cursor reconstruction needs to attribute a tool
273
+ * call's provenance beyond the per-tool merged policy map: the pre-armed global
274
+ * bypass and the built-in categories holding a run-lifetime lease.
275
+ */
276
+ export interface ApprovalProvenanceContext {
277
+ readonly globalBypass: boolean;
278
+ readonly leasedCategories: ReadonlySet<ToolApprovalCategory>;
279
+ }
280
+
281
+ /** Shared empty set so a reconstruction without leases allocates nothing. */
282
+ const NO_LEASED_CATEGORIES: ReadonlySet<ToolApprovalCategory> = new Set();
283
+
237
284
  function translateTask(event: Extract<SDKMessage, { type: "task" }>): AgentMessage {
238
285
  return create(AgentMessageSchema, {
239
286
  type: MessageType.MESSAGE_SYSTEM,
@@ -432,14 +479,25 @@ function blockText(b: Record<string, unknown>): string | undefined {
432
479
  *
433
480
  * The Cursor SDK returns sub-agent work as a blob in the task tool's
434
481
  * completed event (not as streaming events with a distinct agent_id).
482
+ * Re-verified 2026-07-02 with live recordings on both the pinned SDK (1.0.13)
483
+ * and the latest (1.0.22): zero events reach the parent's run.stream() between
484
+ * the task tool's "running" and "completed" events, every event carries the
485
+ * parent's agent_id, and the child agentId visible in the task args at spawn
486
+ * is NOT queryable mid-run through any public read surface (Agent.listRuns /
487
+ * Agent.messages.list / Agent.getRun all return not-found for it; the SDK's
488
+ * on-disk sub-agent transcript is written only at completion). Live nested
489
+ * visibility is therefore an upstream SDK limitation — do not try to fake it
490
+ * here; the UI shows an elapsed-time affordance instead (SubAgentSection).
435
491
  * The result shape is:
436
492
  *
437
493
  * { status: "success", value: { conversationSteps: ConversationStep[] } }
438
494
  *
439
- * where ConversationStep is a discriminated union:
440
- * - { type: "thinkingMessage", message: { text, thinkingDurationMs? } }
441
- * - { type: "assistantMessage", message: { text } }
442
- * - { type: "toolCall", message: { type, args, result?, ... } }
495
+ * where each ConversationStep is a protobuf-oneof object keyed DIRECTLY by its
496
+ * kind (there is NO `{ type, message }` envelope — verified against production
497
+ * sub-agent blobs; see the `buildSubAgentToolCall` note):
498
+ * - { thinkingMessage: { text, thinkingDurationMs? } }
499
+ * - { assistantMessage: { text } }
500
+ * - { toolCall: { toolCallId, <kind>ToolCall: { args, result } } }
443
501
  *
444
502
  * This function defensively parses whatever steps are present and
445
503
  * appends corresponding AgentMessage protos to the output array.
@@ -484,56 +542,172 @@ export function extractConversationSteps(
484
542
  timestamp: utcTimestamp(),
485
543
  }));
486
544
  }
487
- } else if (type === "toolCall") {
488
- const msg = s.message as Record<string, unknown> | undefined;
489
- if (msg) {
490
- const toolName = typeof msg.type === "string" ? msg.type : "unknown";
491
- const toolArgs = msg.args != null ? JSON.stringify(msg.args) : "";
492
- let toolResult = "";
493
- if (msg.result != null) {
494
- const resultObj = msg.result as Record<string, unknown>;
495
- if (resultObj.status === "success" && resultObj.value != null) {
496
- // Normalize a sub-agent screenshot the same way as a top-level tool
497
- // result; fall back to the existing value serialization otherwise.
498
- toolResult = canonicalizeImageResult(resultObj.value)
499
- ?? (typeof resultObj.value === "string"
500
- ? resultObj.value
501
- : JSON.stringify(resultObj.value));
502
- } else if (resultObj.status === "error") {
503
- toolResult = typeof resultObj.error === "string"
504
- ? resultObj.error
505
- : JSON.stringify(resultObj);
506
- } else {
507
- toolResult = JSON.stringify(msg.result);
508
- }
509
- }
510
-
511
- const aiMsg = create(AgentMessageSchema, {
545
+ } else if (s.toolCall != null) {
546
+ const tc = buildSubAgentToolCall(s.toolCall, out.length);
547
+ if (tc) {
548
+ out.push(create(AgentMessageSchema, {
512
549
  type: MessageType.MESSAGE_AI,
513
550
  content: "",
514
551
  timestamp: utcTimestamp(),
515
- toolCalls: [create(ToolCallSchema, {
516
- id: `sub-${toolName}-${out.length}`,
517
- name: toolName,
518
- status: ToolCallStatus.TOOL_CALL_COMPLETED,
519
- argsPreview: toolArgs,
520
- result: toolResult,
521
- startedAt: utcTimestamp(),
522
- completedAt: utcTimestamp(),
523
- toolKind: classifyTool(toolName),
524
- })],
525
- });
526
- out.push(aiMsg);
552
+ toolCalls: [tc],
553
+ }));
527
554
  }
528
555
  }
529
556
  }
530
557
  }
531
558
 
559
+ /**
560
+ * The failure branches of a sub-agent tool call's `result` oneof. A completion
561
+ * is `{ success: ... }`; every other branch is a non-completion the UI must show
562
+ * as failed — an errored read/glob/grep (`error`), or a shell the approval gate
563
+ * stopped (`permissionDenied` / `rejected`).
564
+ */
565
+ const SUBAGENT_TOOL_RESULT_FAILURE_KEYS = new Set([
566
+ "error",
567
+ "permissionDenied",
568
+ "rejected",
569
+ ]);
570
+
571
+ /**
572
+ * Build a ToolCall proto from one sub-agent `toolCall` conversation step.
573
+ *
574
+ * The Cursor SDK serializes a sub-agent's tool call as protobuf-oneof JSON:
575
+ *
576
+ * { toolCallId, <kind>ToolCall: { args, result } }
577
+ *
578
+ * The tool family is the lone `<kind>ToolCall` sibling of `toolCallId` (e.g.
579
+ * `readToolCall`, `globToolCall`, `grepToolCall`, `shellToolCall`); the bare
580
+ * tool name (`read`) is the suffix-stripped key, which feeds the shared
581
+ * {@link classifyTool} exactly like a top-level call. `result` is itself a oneof
582
+ * `{ success | error | permissionDenied | rejected }` (see
583
+ * {@link interpretSubAgentToolResult}).
584
+ *
585
+ * This is deliberately key-driven rather than an enumerated switch, so a new
586
+ * tool family the SDK adds surfaces automatically instead of being dropped.
587
+ * Returns undefined when no `<kind>ToolCall` key is present (a malformed or
588
+ * forward-incompatible step), so the caller skips it rather than emitting a
589
+ * blank, nameless tool call.
590
+ *
591
+ * History: an earlier revision parsed a `{ type: "toolCall", message: { type,
592
+ * args, result: { status, value } } }` envelope. That shape never appears in the
593
+ * real task-result blob (confirmed against production sub-agent outputs and the
594
+ * WA03 capture), so every sub-agent tool call was silently discarded and the UI
595
+ * showed a sub-agent that "did nothing".
596
+ */
597
+ function buildSubAgentToolCall(
598
+ toolCall: unknown,
599
+ seq: number,
600
+ ): ToolCall | undefined {
601
+ if (toolCall == null || typeof toolCall !== "object") return undefined;
602
+ const obj = toolCall as Record<string, unknown>;
603
+
604
+ const kindKey = Object.keys(obj).find(
605
+ (k) => k !== "toolCallId" && k.endsWith("ToolCall"),
606
+ );
607
+ if (!kindKey) return undefined;
608
+
609
+ const name = kindKey.slice(0, -"ToolCall".length);
610
+ const inner =
611
+ obj[kindKey] != null && typeof obj[kindKey] === "object"
612
+ ? (obj[kindKey] as Record<string, unknown>)
613
+ : {};
614
+ // Prefer the SDK's real call id so the row is stable across resumes and never
615
+ // collides with a sibling; fall back to a per-step synthetic id only when the
616
+ // SDK omits one.
617
+ const id =
618
+ typeof obj.toolCallId === "string" && obj.toolCallId
619
+ ? obj.toolCallId
620
+ : `sub-${name}-${seq}`;
621
+
622
+ const { status, result, error } = interpretSubAgentToolResult(inner.result);
623
+
624
+ const tc = create(ToolCallSchema, {
625
+ id,
626
+ name,
627
+ status,
628
+ result,
629
+ error,
630
+ startedAt: utcTimestamp(),
631
+ completedAt: utcTimestamp(),
632
+ toolKind: classifyTool(name),
633
+ });
634
+
635
+ if (inner.args != null && typeof inner.args === "object") {
636
+ tc.args = inner.args as JsonObject;
637
+ tc.argsPreview = JSON.stringify(inner.args);
638
+ }
639
+ return tc;
640
+ }
641
+
642
+ /**
643
+ * Map a sub-agent tool call's `result` oneof to a (status, result, error)
644
+ * triple. `success` → COMPLETED with the serialized payload (a screenshot is
645
+ * canonicalized the same way as a top-level result); any failure branch (see
646
+ * {@link SUBAGENT_TOOL_RESULT_FAILURE_KEYS}) → FAILED with the serialized
647
+ * detail. An absent result is a COMPLETED call with no output — the SDK omits
648
+ * `result` for a call that reports nothing.
649
+ */
650
+ function interpretSubAgentToolResult(result: unknown): {
651
+ status: ToolCallStatus;
652
+ result: string;
653
+ error: string;
654
+ } {
655
+ if (result == null || typeof result !== "object") {
656
+ return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "", error: "" };
657
+ }
658
+ const r = result as Record<string, unknown>;
659
+
660
+ if ("success" in r) {
661
+ const val = r.success;
662
+ const str =
663
+ canonicalizeImageResult(val) ??
664
+ (typeof val === "string" ? val : JSON.stringify(val));
665
+ return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: str, error: "" };
666
+ }
667
+
668
+ const failKey = Object.keys(r).find((k) =>
669
+ SUBAGENT_TOOL_RESULT_FAILURE_KEYS.has(k),
670
+ );
671
+ if (failKey) {
672
+ const val = r[failKey];
673
+ const detail = typeof val === "string" ? val : JSON.stringify(val);
674
+ return { status: ToolCallStatus.TOOL_CALL_FAILED, result: "", error: detail };
675
+ }
676
+
677
+ // Unknown oneof branch — surface it as a completed result rather than drop it.
678
+ return {
679
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
680
+ result: JSON.stringify(r),
681
+ error: "",
682
+ };
683
+ }
684
+
532
685
  /**
533
686
  * Options for creating a MessageAccumulator with policy awareness.
534
687
  */
535
688
  export interface MessageAccumulatorOptions {
536
689
  mergedPolicies?: Map<string, MergedToolPolicy>;
690
+ /**
691
+ * Run-scoped approval context (global bypass + active leases) so reconstructed
692
+ * tool calls carry their authorization provenance. Omitted in unit tests that
693
+ * only assert basic translation; provenance then stays UNSPECIFIED.
694
+ */
695
+ provenance?: ApprovalProvenanceContext;
696
+ /**
697
+ * Absolute workspace root, used to render file-change paths relative to the
698
+ * workspace (with the absolute path retained). Omitted in unit tests, in
699
+ * which case raw tool-arg paths are used verbatim.
700
+ */
701
+ workspaceRoot?: string;
702
+ /**
703
+ * Sub-agent executions carried over from the persisted transcript on a
704
+ * durable resume (see seedCursorTranscriptFromExecution in index.ts). The
705
+ * accumulator re-registers them so a sub-agent's resumed lifecycle updates
706
+ * merge onto the seeded row instead of producing a duplicate, and so the row
707
+ * survives the round-trip rather than being dropped from the rebuilt status.
708
+ * Empty on a first run.
709
+ */
710
+ seededSubAgents?: SubAgentExecution[];
537
711
  }
538
712
 
539
713
  /**
@@ -596,12 +770,44 @@ export class MessageAccumulator {
596
770
  private readonly _subAgentExecutions: SubAgentExecution[] = [];
597
771
  private readonly subAgentMap = new Map<string, SubAgentExecution>();
598
772
  private readonly mergedPolicies?: Map<string, MergedToolPolicy>;
773
+ private readonly provenance?: ApprovalProvenanceContext;
774
+ private readonly workspaceRoot?: string;
599
775
  private readonly toolCallIndex = new Map<string, ToolCall>();
600
776
  private _dirty = false;
601
777
 
602
778
  constructor(messages: AgentMessage[], options?: MessageAccumulatorOptions) {
603
779
  this.messages = messages;
604
780
  this.mergedPolicies = options?.mergedPolicies;
781
+ this.provenance = options?.provenance;
782
+ this.workspaceRoot = options?.workspaceRoot;
783
+
784
+ // Resume seeding. When constructed over a pre-seeded transcript (a durable
785
+ // resume — see seedCursorTranscriptFromExecution in index.ts), rebuild the
786
+ // by-id tool-call index so a cross-message completion for a seeded call
787
+ // resolves onto the existing proto, and re-register seeded sub-agents so
788
+ // their resumed lifecycle updates merge in place. A first run carries an
789
+ // empty transcript and no seed, so both are no-ops. Mirrors the deep-agent
790
+ // ExecutionState.rebuildToolCallIndex + sub-agent re-registration on resume.
791
+ this.rebuildToolCallIndex();
792
+ for (const sub of options?.seededSubAgents ?? []) {
793
+ this._subAgentExecutions.push(sub);
794
+ if (sub.id) this.subAgentMap.set(sub.id, sub);
795
+ }
796
+ }
797
+
798
+ /**
799
+ * Index every tool call already present in the (seeded) transcript by its
800
+ * call_id. Called once at construction: on a first run the transcript is empty
801
+ * (no-op); on a resume it lets re-emitted lifecycle events for a previously
802
+ * committed call_id reconcile onto the existing proto instead of duplicating.
803
+ */
804
+ private rebuildToolCallIndex(): void {
805
+ this.toolCallIndex.clear();
806
+ for (const message of this.messages) {
807
+ for (const tc of message.toolCalls) {
808
+ if (tc.id) this.toolCallIndex.set(tc.id, tc);
809
+ }
810
+ }
605
811
  }
606
812
 
607
813
  get subAgentExecutions(): SubAgentExecution[] {
@@ -696,17 +902,62 @@ export class MessageAccumulator {
696
902
  if (SUPPRESSED_TOOL_NAMES.has(event.name)) return;
697
903
 
698
904
  const existing = this.toolCallIndex.get(event.call_id);
699
- if (!existing) {
700
- const tc = buildToolCallProto(event, this.mergedPolicies);
701
- this.findOrCreateLastAiMessage().toolCalls.push(tc);
702
- this.toolCallIndex.set(event.call_id, tc);
703
- // A new tool call is a discrete, user-visible event — force a prompt
704
- // flush so the live UI surfaces it the instant it starts.
705
- this._dirty = true;
905
+ if (existing) {
906
+ this.mergeToolCallEvent(existing, event);
907
+ return;
908
+ }
909
+
910
+ const tc = buildToolCallProto(event, this.mergedPolicies, this.provenance);
911
+
912
+ // Resume reconciliation. A resumed Cursor agent re-runs a previously
913
+ // approved tool with a BRAND-NEW call_id, so it misses the by-id index
914
+ // above. Reconcile it onto the seeded WAITING_APPROVAL call with the same
915
+ // canonical identity (the (category, salient)/MCP-name space the hook and
916
+ // grants already use — see toolCallIdentityToken) and keep the original id.
917
+ // Without this the seeded approved call and the re-run would both appear (a
918
+ // duplicate row) and dropping the seeded id would trip the backend's
919
+ // append-only-at-identity guard, stalling the run. This generalizes the v2
920
+ // deep-agent StatusBuilder.findResumableSeededToolCall (a tool-name match)
921
+ // to the full Cursor identity, reusing the single existing identity
922
+ // definition rather than introducing a parallel one.
923
+ const seeded = this.findResumableSeededToolCall(tc);
924
+ if (seeded) {
925
+ // Re-key the fresh call_id onto the seeded proto so this call_id's later
926
+ // lifecycle events resolve here, then merge in place. mergeToolCallEvent
927
+ // advances WAITING_APPROVAL (non-terminal) toward the event's status.
928
+ this.toolCallIndex.set(event.call_id, seeded);
929
+ this.mergeToolCallEvent(seeded, event);
706
930
  return;
707
931
  }
708
932
 
709
- this.mergeToolCallEvent(existing, event);
933
+ this.findOrCreateLastAiMessage().toolCalls.push(tc);
934
+ this.toolCallIndex.set(event.call_id, tc);
935
+ // A new tool call is a discrete, user-visible event — force a prompt
936
+ // flush so the live UI surfaces it the instant it starts.
937
+ this._dirty = true;
938
+ }
939
+
940
+ /**
941
+ * Find a seeded, still-gated tool call this resumed event should reconcile
942
+ * onto: the first tool call in the index that is still WAITING_APPROVAL and
943
+ * shares the candidate's canonical identity token. "First" (Map iteration =
944
+ * transcript order) mirrors the v2 deep-agent's ordered first-unreconciled
945
+ * match — once reconciled a call leaves WAITING_APPROVAL, so a second co-
946
+ * pending call with the same identity naturally reconciles onto the next one.
947
+ * Tool calls created during this turn are not WAITING_APPROVAL until the
948
+ * post-stream denial reconciliation runs, so they can never be matched here.
949
+ */
950
+ private findResumableSeededToolCall(candidate: ToolCall): ToolCall | undefined {
951
+ const wanted = toolCallIdentityToken(candidate);
952
+ for (const tc of this.toolCallIndex.values()) {
953
+ if (
954
+ tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL &&
955
+ toolCallIdentityToken(tc) === wanted
956
+ ) {
957
+ return tc;
958
+ }
959
+ }
960
+ return undefined;
710
961
  }
711
962
 
712
963
  /**
@@ -902,76 +1153,682 @@ export class MessageAccumulator {
902
1153
  * approved once should produce one approval regardless of how many times the
903
1154
  * agent re-attempted it within the turn.
904
1155
  *
905
- * If a ledger denial has no matching streamed tool call (rare Cursor normally
906
- * emits a tool_call event for every attempt), a placeholder WAITING_APPROVAL
907
- * tool call is synthesized so the gate still surfaces and never renders as a
908
- * silent success.
1156
+ * ONE GATE PER TURN (deny-only clean pause). The Cursor harness can only gate by
1157
+ * the hook returning `deny`, which Cursor surfaces to the model as a tool
1158
+ * *failure* so a blocked model frequently improvises a workaround (the
1159
+ * canonical case: a denied `edit notes.md` followed ~2.5s later by a
1160
+ * `shell: cat > notes.md`, in the SAME assistant message with no narration
1161
+ * between them — observed in production, exec aex_01kw4p0cqgk0j8vvxbs5t8gv59).
1162
+ * The first-denial stop (index.ts) tries to cancel the turn at that first
1163
+ * denial, but `run.cancel()` is async and races the SDK's auto-execution, so the
1164
+ * workaround can still stream and land a SECOND denial in the ledger. Two
1165
+ * denials of distinct identity would otherwise surface two approval cards for
1166
+ * one logical intent. Their identities differ (`write\nnotes.md` vs
1167
+ * `shell\ncat > notes.md`), so no same-identity twin collapse can join them, and
1168
+ * they share one message, so no positional rule can separate them; the only
1169
+ * honest signal that the second is a reaction is CAUSALITY — it was emitted
1170
+ * after the model saw the first denial. We therefore ANCHOR on the FIRST ledger
1171
+ * denial of the turn (the ledger is reset per turn and appended in denial order,
1172
+ * so ledger[0] is the original intent) and surface ONLY that identity. Every
1173
+ * other denied identity in the turn — a post-denial workaround, or a genuine
1174
+ * co-pending sibling the deny-only harness defers — is blanked in place to a
1175
+ * hidden SKIPPED row ({@link collapseNonAnchorDenials}). A deferred sibling is
1176
+ * not lost: on resume it re-attempts and gates again next turn (sequential
1177
+ * gating). The native (LangGraph) harness pauses BEFORE the model can react, so
1178
+ * it keeps full in-turn co-pending and is untouched by this rule. This is the
1179
+ * near-term, invariant-preserving stepping stone to the Tool Execution Gateway,
1180
+ * where an un-leased workaround is refused by construction.
1181
+ *
1182
+ * Correlation runs in two passes. The first matches the streamed token to a
1183
+ * ledger token byte-for-byte (the common case). The hook, however, records its
1184
+ * token from the RAW path Cursor hands it — a bash script cannot normalize a
1185
+ * path against the workspace root — so an ABSOLUTE hook `file_path` against a
1186
+ * RELATIVE stream `path` (or vice versa) yields two different raw tokens for one
1187
+ * edit and the exact pass misses. The runner CAN normalize, so a second pass
1188
+ * matches any still-unmatched FILE denial to a streamed call by (category,
1189
+ * workspace-normalized path) and overlays the REAL streamed call, never appending
1190
+ * a content-less placeholder beside it. This is the difference between one honest
1191
+ * gate and two cards, one of which reads "No preview available". It reuses the
1192
+ * single tool-identity definition + `resolveWorkspacePath`; it introduces no
1193
+ * parallel identity.
909
1194
  *
910
- * Returns the tool calls now marked WAITING_APPROVAL (overlaid + synthesized).
1195
+ * Only after BOTH passes miss is a placeholder WAITING_APPROVAL tool call
1196
+ * synthesized (rare — Cursor normally emits a tool_call event for every
1197
+ * attempt), so the gate still surfaces and never renders as a silent success.
1198
+ * Critically, every match overlays a call IN PLACE (the committed id is
1199
+ * preserved): the backend's append-only-at-identity transcript guard rejects a
1200
+ * finalize that drops a previously-committed tool-call id, so reconciliation may
1201
+ * only reconcile entries in place, never remove them.
1202
+ *
1203
+ * Every matched/synthesized call is enriched with the hook-captured authoritative
1204
+ * input (`ledger.input`) via {@link applyGateInput}: the full proposed args, a
1205
+ * compact `args_preview`, and the content digest — so the approval card renders
1206
+ * the proposed write/edit content from `args` and a resume re-gates a diverging
1207
+ * sibling edit. A missing capture (the hook's grep fallback) degrades to the
1208
+ * prior behavior.
1209
+ *
1210
+ * Returns the tool calls now marked WAITING_APPROVAL — the single anchor gate
1211
+ * for the turn (overlaid or, rarely, synthesized).
911
1212
  */
912
- export function reconcileDeniedToolCalls(
1213
+ export async function reconcileDeniedToolCalls(
913
1214
  messages: AgentMessage[],
914
1215
  ledger: DeniedLedgerEntry[],
915
1216
  mergedPolicies?: Map<string, MergedToolPolicy>,
916
- ): ToolCall[] {
1217
+ workspaceBackend?: WorkspaceBackend,
1218
+ ): Promise<ToolCall[]> {
917
1219
  if (ledger.length === 0) return [];
918
1220
 
919
- // One approval per denied identity; a resource re-attempted within the turn
920
- // is gated under the same token and collapses to a single approval.
1221
+ // The workspace the gated files live in; its rootDir normalizes paths for the
1222
+ // abs-vs-rel correlation fallback (normalizedFileSalient).
1223
+ const workspaceRoot = workspaceBackend?.rootDir;
1224
+
1225
+ // One gate per turn: anchor on the FIRST ledger denial. The ledger is reset
1226
+ // per turn and appended in denial order, so ledger[0] is the model's original
1227
+ // intent; any later denial of a DIFFERENT identity is a post-denial workaround
1228
+ // or a deferred co-pending sibling (see the doc comment). We surface ONLY the
1229
+ // anchor identity below and blank every other denied identity to a hidden
1230
+ // SKIPPED row. `deniedTokens` still carries every denied identity — it is the
1231
+ // scope for that collapse, never an additional gate.
1232
+ const anchorToken = ledger[0].token;
921
1233
  const deniedTokens = new Set(ledger.map((e) => e.token));
922
- const matched = new Set<string>();
1234
+ // The authoritative pre-execution args the hook captured for the anchor. A
1235
+ // resource re-attempted within the turn shares a token; last write wins (the
1236
+ // attempts carry the same proposed change).
1237
+ let anchorInput: Record<string, unknown> | undefined;
1238
+ for (const e of ledger) {
1239
+ if (e.token === anchorToken && e.input) anchorInput = e.input;
1240
+ }
1241
+ const matchedCalls = new Set<ToolCall>();
923
1242
  const result: ToolCall[] = [];
1243
+ let anchorMatched = false;
924
1244
 
925
- // 1. Overlay WAITING_APPROVAL onto the streamed tool calls that were denied.
1245
+ // 1. Exact overlay: a streamed call whose token equals the anchor denial token
1246
+ // byte-for-byte (the path form agreed on both sides). The anchor resource
1247
+ // re-attempted within the turn shares one token and collapses to a single
1248
+ // approval (the first match is the keeper; same-identity twins are blanked
1249
+ // by collapseRedundantToolCallTwins below).
926
1250
  for (const msg of messages) {
1251
+ if (anchorMatched) break;
927
1252
  for (const tc of msg.toolCalls) {
928
- const token = toolCallIdentityToken(tc);
929
- if (!deniedTokens.has(token) || matched.has(token)) continue;
930
- markWaitingApproval(tc, mergedPolicies);
931
- matched.add(token);
1253
+ if (toolCallIdentityToken(tc) !== anchorToken) continue;
1254
+ overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
1255
+ matchedCalls.add(tc);
932
1256
  result.push(tc);
1257
+ anchorMatched = true;
1258
+ break;
933
1259
  }
934
1260
  }
935
1261
 
936
- // 2. Synthesize a tool call for any denial that never produced a stream event.
937
- // Rare with correct correlation (Cursor emits a tool_call for every attempt),
938
- // so this is a defensive net that still surfaces the gate rather than letting
939
- // a denied tool render as a silent success.
940
- for (const entry of ledger) {
941
- if (matched.has(entry.token)) continue;
942
- const decoded = decodeIdentityToken(entry.token);
1262
+ // 2. Normalized-path fallback (the abs-vs-rel drift fix): if the anchor is a
1263
+ // FILE denial the exact pass missed, match a streamed call by (category,
1264
+ // workspace-normalized path) and overlay the REAL call never a content-
1265
+ // less placeholder beside it. Requires the workspace root to normalize;
1266
+ // shell/MCP denials (no path) and resumes without a root fall through to
1267
+ // synthesis.
1268
+ if (!anchorMatched && workspaceRoot) {
1269
+ const decoded = decodeIdentityToken(anchorToken);
1270
+ const wanted = decoded
1271
+ ? normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot)
1272
+ : undefined;
1273
+ if (wanted) {
1274
+ const tc = findUnmatchedStreamCallByNormalizedSalient(
1275
+ messages, matchedCalls, wanted, workspaceRoot,
1276
+ );
1277
+ if (tc) {
1278
+ overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
1279
+ matchedCalls.add(tc);
1280
+ result.push(tc);
1281
+ anchorMatched = true;
1282
+ }
1283
+ }
1284
+ }
1285
+
1286
+ // 2a. One gate per turn: blank every denied identity OTHER than the anchor to a
1287
+ // hidden SKIPPED row (the workaround shell, or a deferred co-pending
1288
+ // sibling). Runs BEFORE the WAITING_FOR_APPROVAL persist so a reaction is
1289
+ // never persisted as WAITING_APPROVAL — the backend authors an approval
1290
+ // REQUESTED event only from a WAITING_APPROVAL tool call, so collapsing
1291
+ // here keeps the append-only approval-event stream free of an orphan
1292
+ // REQUESTED that would need retraction.
1293
+ const nonAnchorCollapsed = collapseNonAnchorDenials(messages, deniedTokens, anchorToken);
1294
+ if (nonAnchorCollapsed > 0) {
1295
+ console.log(
1296
+ `ExecuteCursor reconcile collapsed ${nonAnchorCollapsed} non-anchor denied ` +
1297
+ `tool call(s) to hidden SKIPPED (one gate per turn; anchor is the first ` +
1298
+ `denial of the turn)`,
1299
+ );
1300
+ }
1301
+
1302
+ // 2b. Collapse same-turn duplicate edits. When the model emitted the SAME
1303
+ // resource twice in one turn (two call ids, one identity token), only the
1304
+ // FIRST same-token stream call was overlaid into the gate above; any OTHER
1305
+ // same-token call stays a committed row (RUNNING zombie, or a
1306
+ // denied-reported-as-success COMPLETED) that would render as a second,
1307
+ // content-less card beside the gate (the reported "No preview available"
1308
+ // duplicate). The overlaid gate is now WAITING_APPROVAL, so the shared
1309
+ // routine recognizes it as the keeper and blanks the twins IN PLACE to
1310
+ // hidden SKIPPED rows — we cannot drop them, since the backend's
1311
+ // append-only-at-identity guard rejects removing a previously-committed
1312
+ // tool-call id, but the id is preserved so the finalize stays append-only.
1313
+ const collapsed = collapseRedundantToolCallTwins(messages);
1314
+ if (collapsed > 0) {
1315
+ console.log(
1316
+ `ExecuteCursor reconcile collapsed ${collapsed} redundant tool-call twin(s) ` +
1317
+ `superseded by the approval gate (kept in place as hidden SKIPPED rows)`,
1318
+ );
1319
+ }
1320
+
1321
+ // 2c. Finalize interrupted rows. The first-denial stop cancelled the run, so
1322
+ // a tool call still PENDING/RUNNING here can never complete — no event
1323
+ // will ever deliver its result, and left alone it persists as a spinner
1324
+ // forever. The canonical victim is a post-denial workaround whose own
1325
+ // hook denial raced (or never reached) the final ledger read, so the
1326
+ // token-scoped collapse in 2a could not see it (production case
1327
+ // aex_01kwj07f7g23c3wp9sn8496z5g: a python-write shell reaction persisted
1328
+ // as RUNNING with requiresApproval=true). Whatever the cause, a
1329
+ // non-terminal row on a turn that is pausing is an interrupted attempt
1330
+ // with no output: collapse it to the same hidden SKIPPED shape as every
1331
+ // other superseded row (in place — the append-only-at-identity guard
1332
+ // forbids dropping a committed id). Runs AFTER the anchor overlay, so the
1333
+ // gate itself (now WAITING_APPROVAL) is never touched.
1334
+ const interrupted = finalizeInterruptedToolCalls(messages);
1335
+ if (interrupted > 0) {
1336
+ console.log(
1337
+ `ExecuteCursor reconcile collapsed ${interrupted} interrupted non-terminal ` +
1338
+ `tool call(s) that can never complete (run cancelled at first denial)`,
1339
+ );
1340
+ }
1341
+
1342
+ // 3. Synthesize the anchor gate if it matched NO streamed call in either pass
1343
+ // (rare — Cursor emits a tool_call event for every attempt), so the gate
1344
+ // still surfaces rather than rendering as a silent success. After the
1345
+ // normalized fallback this should be ~0; the caller logs a divergence when
1346
+ // it is not (a synthesized id is prefixed `approval:`). Only the anchor is
1347
+ // ever synthesized: non-anchor denials are deliberately collapsed, never
1348
+ // surfaced (one gate per turn).
1349
+ if (!anchorMatched) {
1350
+ const anchorEntry = ledger.find((e) => e.token === anchorToken) ?? ledger[0];
1351
+ const decoded = decodeIdentityToken(anchorToken);
943
1352
  // Display the hook's raw tool name; carry the decoded salient so the grant
944
1353
  // rebuilt from this tool call on reinvocation keys on the same resource.
945
- const displayName = entry.toolName || decoded?.key || "tool";
1354
+ const displayName = anchorEntry.toolName || decoded?.key || "tool";
946
1355
  const salient = decoded?.salient ?? "";
947
- const tc = synthesizeWaitingApprovalToolCall(displayName, salient, entry.token, mergedPolicies);
1356
+ const tc = synthesizeWaitingApprovalToolCall(
1357
+ displayName, salient, decoded?.digest ?? "", anchorToken, mergedPolicies,
1358
+ );
1359
+ // The hook-captured input upgrades the placeholder from a bare {path} to the
1360
+ // full proposed args, so even a synthesized gate shows the proposed change.
1361
+ applyGateInput(tc, anchorInput ?? anchorEntry.input);
948
1362
  appendToolCallToLastAiMessage(messages, tc);
949
- matched.add(entry.token);
950
1363
  result.push(tc);
951
1364
  }
952
1365
 
953
1366
  return result;
954
1367
  }
955
1368
 
1369
+ /**
1370
+ * Overlay WAITING_APPROVAL onto a streamed tool call the hook denied. Mutates
1371
+ * `tc` in place — the call keeps its committed id, so the backend's
1372
+ * append-only-at-identity transcript guard accepts the finalize (an in-place
1373
+ * status change is a reconcile, not a drop). The single overlay routine for both
1374
+ * the exact and the normalized correlation passes, so the gate diff can never
1375
+ * diverge between them. The hook-captured `input` (when present) is the
1376
+ * authoritative, complete proposed args — the stream may have carried only
1377
+ * partial args before the first-denial cancel — so it supplies the args preview
1378
+ * and the content digest (see {@link applyGateInput}).
1379
+ */
1380
+ function overlayDeniedStreamCall(
1381
+ tc: ToolCall,
1382
+ input: Record<string, unknown> | undefined,
1383
+ mergedPolicies: Map<string, MergedToolPolicy> | undefined,
1384
+ ): void {
1385
+ markWaitingApproval(tc, mergedPolicies);
1386
+ applyGateInput(tc, input);
1387
+ }
1388
+
1389
+ /**
1390
+ * Collapse every tool call still in a non-terminal state (PENDING / RUNNING)
1391
+ * to the hidden SKIPPED row shape, returning how many were collapsed.
1392
+ *
1393
+ * Called only on the pause-for-approval path, after the anchor gate has been
1394
+ * overlaid to WAITING_APPROVAL: the run was cancelled, so nothing will ever
1395
+ * complete these calls, and a permanently-RUNNING row would render as an
1396
+ * eternal spinner beside the approval card. This is the causality sibling of
1397
+ * {@link collapseNonAnchorDenials}: that collapse is token-scoped (it needs the
1398
+ * denial in the ledger), while this one catches the attempt whose hook denial
1399
+ * raced the final ledger read or whose execution the cancel interrupted
1400
+ * outright — either way an attempt with no output that the turn's end orphaned.
1401
+ */
1402
+ function finalizeInterruptedToolCalls(messages: AgentMessage[]): number {
1403
+ let finalized = 0;
1404
+ for (const msg of messages) {
1405
+ for (const tc of msg.toolCalls) {
1406
+ if (
1407
+ tc.status !== ToolCallStatus.TOOL_CALL_PENDING &&
1408
+ tc.status !== ToolCallStatus.TOOL_CALL_RUNNING
1409
+ ) {
1410
+ continue;
1411
+ }
1412
+ hideToolCallRow(tc);
1413
+ finalized++;
1414
+ }
1415
+ }
1416
+ return finalized;
1417
+ }
1418
+
1419
+ /**
1420
+ * Recognizes a tool call already blanked to a hidden collapsed row, so a second
1421
+ * pass never re-collapses it (and never miscounts). Mirrors the SDK's
1422
+ * `isCollapsedToolCall` shape without importing across the runner/SDK seam.
1423
+ */
1424
+ function isAlreadyCollapsed(tc: ToolCall): boolean {
1425
+ return (
1426
+ tc.status === ToolCallStatus.TOOL_CALL_SKIPPED &&
1427
+ !tc.requiresApproval &&
1428
+ !tc.result &&
1429
+ !tc.error &&
1430
+ !tc.argsPreview
1431
+ );
1432
+ }
1433
+
1434
+ /**
1435
+ * Whether a tool call carries a change/output of its own — the signal that it is
1436
+ * authoritative for its resource rather than a redundant denial/cancel twin.
1437
+ *
1438
+ * The notion of "change" is category-aware on purpose:
1439
+ * - A file mutation (`write`/`delete`) never carries an authoritative change on
1440
+ * the tool-call ROW: under apply-then-review its review lives in the
1441
+ * `FileChangeSet` ledger (capture mode), and under the no-storage deny-gate it
1442
+ * is the WAITING_APPROVAL gate itself (kept explicitly by the caller). So a
1443
+ * file row is authoritative only as that gate, never on its own — hence
1444
+ * `false` here. (Before Phase 5 Slice 4 this read `file_changes.length > 0`;
1445
+ * that field is gone, and the row was never the review surface.)
1446
+ * - Every other gated tool (shell, MCP) has no ledger; its "change" is its
1447
+ * execution output, so a genuine run carries a non-empty `result` while a
1448
+ * denied/cancelled attempt that never executed does not. This keeps two
1449
+ * distinct shell runs (each with output) both visible while still collapsing a
1450
+ * same-command denial twin.
1451
+ */
1452
+ function carriesOwnChange(tc: ToolCall): boolean {
1453
+ const category = toolApprovalCategory(tc.name);
1454
+ if (category === "write" || category === "delete") {
1455
+ return false;
1456
+ }
1457
+ return !!tc.result;
1458
+ }
1459
+
1460
+ /**
1461
+ * Collapse redundant same-identity tool-call twins to a single visible row.
1462
+ *
1463
+ * The model frequently emits the SAME gated action twice in one turn (two
1464
+ * tool-call ids, one identity). When the first attempt is gated and the run is
1465
+ * cancelled mid-flight, the extra attempt never receives a terminal event and
1466
+ * persists as a stuck `RUNNING` row ("No preview available"); other variants are
1467
+ * a denied-reported-as-success `COMPLETED` with an empty result, two no-change
1468
+ * `COMPLETED` attempts where neither carries a change, or — on the denial path —
1469
+ * a `FAILED` twin beside the overlaid gate. All render as a duplicate card beside
1470
+ * the real action (or the approval gate). This is the recurring duplicate-card
1471
+ * defect, most visible for file edits but shared by every gated tool family.
1472
+ *
1473
+ * The routine is harness-agnostic and a pure function of `messages`:
1474
+ *
1475
+ * 1. Scope to GATED identities — file mutations (`write`/`delete`) and shell key
1476
+ * on their cross-taxonomy category; MCP tools are recognized by their server
1477
+ * slug. A same-turn duplicate of a gated tool is a denial/cancel artifact, not
1478
+ * meaningful repetition. The category is name-derived (via {@link toolIdentity}
1479
+ * -> approvalCategory), so a twin cancelled before classification (empty
1480
+ * `toolKind`) is still scoped via its name (`edit` -> `write`). Ungated
1481
+ * read-only tools are left untouched.
1482
+ * 2. Group those calls by `toolCallIdentityToken` — the SAME `toolIdentity` used
1483
+ * for denial correlation and resume grants, so scope and grouping cannot drift.
1484
+ * 3. In each group the keepers carry authoritative state — a change/output of
1485
+ * their own (see {@link carriesOwnChange}) or the approval gate itself
1486
+ * (`WAITING_APPROVAL`). For a file mutation the gate is the sole authoritative
1487
+ * row: the row carries no diff (review lives in the `FileChangeSet` ledger, or
1488
+ * is the no-storage deny-gate itself), so a denied write's same-identity
1489
+ * siblings — a denied/zombie row or a stale snapshot from a second attempt —
1490
+ * collapse onto the gate. A shell/MCP twin keeps every distinct run with
1491
+ * output. If NO member qualifies (every attempt produced no change), keep
1492
+ * exactly ONE representative — preferring a terminal attempt over a stuck
1493
+ * `RUNNING` zombie — so the resource still shows a single card. Every
1494
+ * non-keeper is blanked in place to a hidden `SKIPPED` row (see
1495
+ * {@link collapseDenialTwin}).
1496
+ *
1497
+ * It is deliberately subtractive — it only ever HIDES a row, never invents a
1498
+ * terminal state. The committed `id` is preserved on every collapse, so the
1499
+ * finalize stays append-only by construction and the backend's
1500
+ * append-only-at-identity guard accepts it. Returns the number collapsed, for
1501
+ * observability.
1502
+ */
1503
+ export function collapseRedundantToolCallTwins(messages: AgentMessage[]): number {
1504
+ const groups = new Map<string, ToolCall[]>();
1505
+ for (const msg of messages) {
1506
+ for (const tc of msg.toolCalls) {
1507
+ const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1508
+ const gated = tc.mcpServerSlug
1509
+ ? true
1510
+ : id.key === "write" || id.key === "delete" || id.key === "shell";
1511
+ if (!gated) continue;
1512
+ const token = grantToken(id.key, id.salient);
1513
+ const bucket = groups.get(token);
1514
+ if (bucket) bucket.push(tc);
1515
+ else groups.set(token, [tc]);
1516
+ }
1517
+ }
1518
+
1519
+ let collapsed = 0;
1520
+ for (const group of groups.values()) {
1521
+ if (group.length < 2) continue; // a lone call is never a twin
1522
+
1523
+ // Keepers carry authoritative state: a change/output of their own
1524
+ // (carriesOwnChange) or the approval gate itself. For a file mutation the gate
1525
+ // is the sole authoritative row (the row carries no diff — review lives in the
1526
+ // ledger, or the row IS the no-storage deny-gate), so a denied write's
1527
+ // same-identity siblings collapse onto it; a shell/MCP twin keeps every
1528
+ // distinct run with output.
1529
+ const keepers = new Set<ToolCall>(
1530
+ group.filter(
1531
+ (tc) =>
1532
+ carriesOwnChange(tc) ||
1533
+ tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
1534
+ ),
1535
+ );
1536
+ // All attempts produced no change (e.g. denied-reported-as-success): keep one
1537
+ // representative, preferring a settled outcome over a stuck RUNNING zombie.
1538
+ if (keepers.size === 0) {
1539
+ const terminal = [...group].reverse().find((tc) => isTerminalToolStatus(tc.status));
1540
+ keepers.add(terminal ?? group[0]);
1541
+ }
1542
+
1543
+ for (const tc of group) {
1544
+ if (keepers.has(tc)) continue;
1545
+ if (isAlreadyCollapsed(tc)) continue;
1546
+ collapseDenialTwin(tc);
1547
+ collapsed++;
1548
+ }
1549
+ }
1550
+ return collapsed;
1551
+ }
1552
+
1553
+ /**
1554
+ * One gate per turn: blank every DENIED tool call whose identity differs from
1555
+ * the anchor (the first denial of the turn) to a hidden SKIPPED row.
1556
+ *
1557
+ * This is the cross-identity complement of {@link collapseRedundantToolCallTwins}
1558
+ * (which only joins SAME-identity duplicates). The canonical target is the
1559
+ * deny-only workaround — a denied `edit notes.md` followed by a `shell:
1560
+ * cat > notes.md` whose identity (`shell\n…`) differs from the edit's
1561
+ * (`write\nnotes.md`), so no twin collapse can join them and (since they share
1562
+ * one assistant message) no positional rule can separate them. The honest signal
1563
+ * that the shell is redundant is that it is a DIFFERENT denied identity in the
1564
+ * same turn as the anchor; under the one-gate-per-turn contract every such
1565
+ * identity is either a post-denial reaction or a co-pending sibling the harness
1566
+ * defers to the next turn, so it is hidden, not surfaced.
1567
+ *
1568
+ * Scoped strictly to identities present in `deniedTokens`: a non-denied tool
1569
+ * (an earlier read/glob, or an already-granted call that ran) is never touched.
1570
+ * Subtractive and id-preserving (via {@link collapseDenialTwin}), so the finalize
1571
+ * stays append-only. Returns the number collapsed, for observability.
1572
+ */
1573
+ function collapseNonAnchorDenials(
1574
+ messages: AgentMessage[],
1575
+ deniedTokens: ReadonlySet<string>,
1576
+ anchorToken: string,
1577
+ ): number {
1578
+ let collapsed = 0;
1579
+ for (const msg of messages) {
1580
+ for (const tc of msg.toolCalls) {
1581
+ const token = toolCallIdentityToken(tc);
1582
+ if (token === anchorToken) continue;
1583
+ if (!deniedTokens.has(token)) continue;
1584
+ if (isAlreadyCollapsed(tc)) continue;
1585
+ collapseDenialTwin(tc);
1586
+ collapsed++;
1587
+ }
1588
+ }
1589
+ return collapsed;
1590
+ }
1591
+
1592
+ /**
1593
+ * Blank a superseded denial twin in place to a hidden SKIPPED row. Keeps the
1594
+ * committed `id` (append-only), `name`, and `toolKind`; clears every renderable
1595
+ * surface and the approval flags so the SDK's `isCollapsedToolCall` predicate
1596
+ * recognizes it and renders nothing. The structured `args` are left as the honest
1597
+ * stored record of the redundant attempt (never rendered, since the row is
1598
+ * hidden; the gate carries the authoritative proposed change).
1599
+ */
1600
+ function collapseDenialTwin(tc: ToolCall): void {
1601
+ hideToolCallRow(tc);
1602
+ }
1603
+
1604
+ // `hideToolCallRow` (the "hidden row" shape, shared by the denial-twin collapse
1605
+ // and the capture flow) lives in shared/tool-row.ts so both harnesses collapse
1606
+ // rows identically; imported at the top of this module.
1607
+
1608
+ /**
1609
+ * Overlay the hook-captured authoritative tool input onto a gated tool call so
1610
+ * the approval card can show the proposed change before the user approves.
1611
+ *
1612
+ * When `input` is present it becomes the single source for the preview: the full
1613
+ * structured `args` (the approval card renders the proposed write/edit content
1614
+ * from these), a compact-but-always-valid `args_preview` (the field a resumed
1615
+ * turn parses to rebuild the grant salient — so salient fields are never elided),
1616
+ * and the content digest.
1617
+ *
1618
+ * The digest is the resume identity: it binds the grant to (category, path,
1619
+ * content) so a sibling edit to the same file re-gates rather than riding an
1620
+ * earlier approval through. It is also the identity the Cursor deny-gate's
1621
+ * exact-apply reads on resume — together with the whole-file bytes in `args` — to
1622
+ * write exactly what was approved (see exact-apply.ts). There is no separate
1623
+ * captured `file_changes` mirror; `args` is the single source for both the
1624
+ * preview and the applied bytes.
1625
+ *
1626
+ * With no `input` (the hook's grep fallback) there is nothing authoritative to
1627
+ * stamp and the call keeps its existing args.
1628
+ */
1629
+ function applyGateInput(
1630
+ tc: ToolCall,
1631
+ input: Record<string, unknown> | undefined,
1632
+ ): void {
1633
+ if (!input) return;
1634
+ tc.args = input as JsonObject;
1635
+ tc.argsPreview = buildElidedArgsPreview(input, SALIENT_ARG_FIELDS);
1636
+ // Stamp the content digest from the AUTHORITATIVE captured input, so the
1637
+ // approved edit's exact content survives to resume on a small, never-elided
1638
+ // field — the grant then binds to (category, path, content) and a sibling
1639
+ // edit to the same file re-gates. Empty for a non-content tool. This is the
1640
+ // one place the digest is authored; everything downstream reads the field.
1641
+ tc.approvalContentDigest = contentDigest(input);
1642
+ }
1643
+
1644
+ /**
1645
+ * The workspace-normalized identity of a FILE approval category's salient, or
1646
+ * undefined for a non-file category (shell, whose salient is a command, not a
1647
+ * path) or an empty salient. Both the hook-decoded denial salient and a streamed
1648
+ * call's salient pass through this, so an absolute-vs-relative path difference
1649
+ * collapses to one comparable key (`category + "\n" + relPath`). Restricting to
1650
+ * write/delete keeps a shell command from being mangled by path normalization.
1651
+ */
1652
+ function normalizedFileSalient(
1653
+ category: string,
1654
+ salient: string,
1655
+ workspaceRoot: string,
1656
+ ): string | undefined {
1657
+ if ((category !== "write" && category !== "delete") || !salient) return undefined;
1658
+ const { path } = resolveWorkspacePath(salient, workspaceRoot, /* virtualRoot */ false);
1659
+ return `${category}\n${path}`;
1660
+ }
1661
+
1662
+ /**
1663
+ * Find the first not-yet-overlaid streamed tool call whose workspace-normalized
1664
+ * (category, path) equals `wanted`. Skips calls already claimed by an earlier
1665
+ * denial so several concurrent file denials each overlay a distinct stream call.
1666
+ */
1667
+ function findUnmatchedStreamCallByNormalizedSalient(
1668
+ messages: AgentMessage[],
1669
+ matchedCalls: ReadonlySet<ToolCall>,
1670
+ wanted: string,
1671
+ workspaceRoot: string,
1672
+ ): ToolCall | undefined {
1673
+ for (const msg of messages) {
1674
+ for (const tc of msg.toolCalls) {
1675
+ if (matchedCalls.has(tc)) continue;
1676
+ const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1677
+ if (normalizedFileSalient(id.key, id.salient, workspaceRoot) === wanted) {
1678
+ return tc;
1679
+ }
1680
+ }
1681
+ }
1682
+ return undefined;
1683
+ }
1684
+
1685
+ /**
1686
+ * Redact provisional post-denial narration when a Cursor turn pauses for approval.
1687
+ *
1688
+ * THE PROBLEM. Unlike the native harness — which gates with a LangGraph
1689
+ * `interrupt()` *before* the tool runs, so the model never sees a denial — the
1690
+ * Cursor harness can only gate via the file-based `beforeMCPExecution`/
1691
+ * `preToolUse` hook returning `deny`. Cursor surfaces that deny to the model as
1692
+ * a tool *failure* (often its own generic "blocked by a hook" text; see the
1693
+ * Phase 0 ground-truth capture in cursor_hitl_test.go), and there is no
1694
+ * non-leaky SDK approval primitive to use instead (the `request` event is
1695
+ * opaque and carries no responder). So a well-behaved model frequently reacts by
1696
+ * narrating defeat — "I couldn't do this; enable the hook in your Cursor
1697
+ * settings" — which would otherwise be persisted as the assistant's verdict and
1698
+ * rendered right next to the approval card that is, in fact, asking the user to
1699
+ * approve. Contradictory and alarming.
1700
+ *
1701
+ * THE GUARANTEE. The runner's job is to simplify this data, not mirror its
1702
+ * complexity: a turn that pauses for approval must read the SAME shape the
1703
+ * native harness produces — `[pre-tool text][tool calls WAITING_APPROVAL]`, with
1704
+ * no post-denial verdict. We therefore BLANK (clear the `content` of, and mark
1705
+ * non-streaming) the trailing assistant/thinking messages that (a) appear
1706
+ * positionally AFTER the last message bearing a WAITING_APPROVAL tool call and
1707
+ * (b) carry no tool calls of their own. The approval card (projected from the
1708
+ * WAITING_APPROVAL tool-call status) becomes the single, unambiguous source of
1709
+ * truth. The blanked messages are already invisible on every surface via the
1710
+ * existing empty-message handling (`buildThreadItems` skips empty `MESSAGE_AI`;
1711
+ * `MessageEntry` renders nothing for empty `MESSAGE_THINKING`), so the shared
1712
+ * `@stigmer/react`/Ink components stay harness-agnostic with zero per-harness UI
1713
+ * special-casing — the cleanliness lives in the data, not in each consumer.
1714
+ *
1715
+ * WHY BLANK INSTEAD OF REMOVE. Removing the messages would make the persisted
1716
+ * WAITING_FOR_APPROVAL transcript SHORTER than the in-progress transcript the
1717
+ * runner already streamed. The backend's append-only message guard rejects a
1718
+ * shrink for a non-terminal execution (it protects against regressed/partial
1719
+ * writes). Blanking keeps the message COUNT identical, so the finalize is
1720
+ * append-only BY CONSTRUCTION and the guard accepts it with no special case —
1721
+ * which is why this phase deletes the backend's former `isApprovalFinalize`
1722
+ * shrink exception in both editions. The transcript is the authoritative *raw*
1723
+ * record; the verbatim narration text remains recoverable from the runner logs
1724
+ * and the recorded cursor-event stream.
1725
+ *
1726
+ * WHY THIS IS DETERMINISTIC. `attachToolCallToLastAi` calls
1727
+ * `finalizeStreaming(run_id)` before attaching a tool call, so any assistant
1728
+ * text the model emits *after* the denied tool call always starts a NEW message
1729
+ * — post-denial narration is never merged into the message that holds the gated
1730
+ * call. We stop at the first non-narration message — one bearing a VISIBLE tool
1731
+ * call — so legitimately-executed tools after the gate and any text around them
1732
+ * are never touched; only the contiguous trailing reaction block is blanked. A
1733
+ * message whose every tool call was collapsed to the hidden SKIPPED row (a
1734
+ * post-denial workaround or an interrupted attempt — see
1735
+ * collapseNonAnchorDenials / finalizeInterruptedToolCalls, which run first) IS
1736
+ * trailing narration: its rows render as absent, so only its text remains, and
1737
+ * that text is precisely the reaction this redaction exists to blank. Treating
1738
+ * it as a stop would strand every reaction message behind it (the production
1739
+ * shape in aex_01kwj07f7g23c3wp9sn8496z5g: [gate][thinking][narration+workaround
1740
+ * row] — the old walk stopped at the workaround message and redacted nothing).
1741
+ * The first-denial stop in index.ts is the primary mechanism that keeps this
1742
+ * block small (it ends the turn before the model produces inter-tool
1743
+ * narration); this redaction is the backstop for any token that streamed before
1744
+ * the cancel landed.
1745
+ *
1746
+ * Returns the blanked messages (for diagnostics); mutates `messages` in place.
1747
+ */
1748
+ export function clearProvisionalPostDenialNarration(
1749
+ messages: AgentMessage[],
1750
+ deniedToolCalls: ToolCall[],
1751
+ ): AgentMessage[] {
1752
+ if (deniedToolCalls.length === 0) return [];
1753
+
1754
+ // reconcileDeniedToolCalls returns the very ToolCall protos held inside
1755
+ // messages[].toolCalls (overlaid) or appended to the last AI message
1756
+ // (synthesized), so object identity is a stable, exact match.
1757
+ const denied = new Set(deniedToolCalls);
1758
+
1759
+ let lastGatedIdx = -1;
1760
+ for (let i = 0; i < messages.length; i++) {
1761
+ if (messages[i].toolCalls.some((tc) => denied.has(tc))) {
1762
+ lastGatedIdx = i;
1763
+ }
1764
+ }
1765
+ if (lastGatedIdx < 0) return [];
1766
+
1767
+ const redacted: AgentMessage[] = [];
1768
+ for (let i = messages.length - 1; i > lastGatedIdx; i--) {
1769
+ const msg = messages[i];
1770
+ const isProvisionalNarration =
1771
+ (msg.type === MessageType.MESSAGE_AI || msg.type === MessageType.MESSAGE_THINKING) &&
1772
+ msg.toolCalls.every((tc) => isToolCallRowHidden(tc));
1773
+ // Stop at the first message that is NOT trailing narration: a message
1774
+ // bearing a visible (non-collapsed) tool call marks real activity we must
1775
+ // preserve, and anything before it is no longer "trailing".
1776
+ if (!isProvisionalNarration) break;
1777
+ // Blank in place — keep the message so the transcript count never shrinks,
1778
+ // but drop its provisional content so no consumer renders the defeatist
1779
+ // verdict. Empty AI/THINKING messages are hidden by the SDK already; hidden
1780
+ // SKIPPED rows already render as absent.
1781
+ msg.content = "";
1782
+ msg.isStreaming = false;
1783
+ redacted.unshift(msg);
1784
+ }
1785
+ return redacted;
1786
+ }
1787
+
956
1788
  /**
957
1789
  * Compute a streamed tool call's identity token in the same canonical space the
958
- * preToolUse hook records denials in (see {@link toolIdentity} and grantToken).
959
- * The token keys on the cross-taxonomy category + salient resource, so a stream
960
- * `edit` (token `base64("write\n/path")`) correlates to the hook's `Write` deny
961
- * for the same path, even though the two layers name the tool differently.
1790
+ * preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
1791
+ * The token keys on the cross-taxonomy category + salient resource PLUS, for a
1792
+ * file edit/write, the {@link contentDigest} of the edit content — so a stream
1793
+ * `edit` correlates to the hook's `Write` deny for the same path AND content,
1794
+ * and an approval of one edit does not match a DIFFERENT edit to the same file.
1795
+ *
1796
+ * The digest is read from the persisted `approval_content_digest` field when
1797
+ * present (a seeded gate carries it, stable even if `args` was elided), and is
1798
+ * recomputed from the call's args otherwise (a freshly-streamed call). For a
1799
+ * shell/delete/MCP call (no content) it falls back to the coarse token, exactly
1800
+ * as before — so those identities are unchanged.
1801
+ *
1802
+ * Exported so the resume-grant round-trip can be locked against it: the grant a
1803
+ * resume mints for an approved tool (buildApprovalGrants -> primaryToken) must
1804
+ * equal THIS denial/overlay identity, or the re-issued call is re-gated forever
1805
+ * (the dual-path drift the approval-state round-trip suite guards against).
962
1806
  */
963
- function toolCallIdentityToken(tc: ToolCall): string {
1807
+ export function toolCallIdentityToken(tc: ToolCall): string {
964
1808
  const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
965
- return grantToken(id.key, id.salient);
1809
+ const digest = tc.approvalContentDigest || contentDigest(toolCallArgs(tc));
1810
+ return primaryToken(id.key, id.salient, digest);
966
1811
  }
967
1812
 
968
- /** Decode a grantToken back into its (key, salient) for the synthesis fallback. */
969
- function decodeIdentityToken(token: string): { key: string; salient: string } | undefined {
1813
+ /**
1814
+ * Decode a primary token back into its (key, salient, digest) for the synthesis
1815
+ * fallback. The token is `base64(key \n salient)` (coarse) or
1816
+ * `base64(key \n salient \n digest)` (content-exact); the digest is the optional
1817
+ * third segment. salient never contains a newline (a path or shell command), so
1818
+ * splitting on the first two newlines is unambiguous.
1819
+ */
1820
+ function decodeIdentityToken(
1821
+ token: string,
1822
+ ): { key: string; salient: string; digest: string } | undefined {
970
1823
  try {
971
1824
  const decoded = Buffer.from(token, "base64").toString("utf-8");
972
- const nl = decoded.indexOf("\n");
973
- if (nl < 0) return undefined;
974
- return { key: decoded.slice(0, nl), salient: decoded.slice(nl + 1) };
1825
+ const first = decoded.indexOf("\n");
1826
+ if (first < 0) return undefined;
1827
+ const key = decoded.slice(0, first);
1828
+ const rest = decoded.slice(first + 1);
1829
+ const second = rest.indexOf("\n");
1830
+ if (second < 0) return { key, salient: rest, digest: "" };
1831
+ return { key, salient: rest.slice(0, second), digest: rest.slice(second + 1) };
975
1832
  } catch {
976
1833
  return undefined;
977
1834
  }
@@ -1017,6 +1874,7 @@ function markWaitingApproval(
1017
1874
  function synthesizeWaitingApprovalToolCall(
1018
1875
  displayName: string,
1019
1876
  salient: string,
1877
+ digest: string,
1020
1878
  token: string,
1021
1879
  mergedPolicies?: Map<string, MergedToolPolicy>,
1022
1880
  ): ToolCall {
@@ -1028,6 +1886,10 @@ function synthesizeWaitingApprovalToolCall(
1028
1886
  startedAt: utcTimestamp(),
1029
1887
  approvalRequestedAt: utcTimestamp(),
1030
1888
  toolKind: classifyTool(displayName),
1889
+ // Carry the decoded digest so this placeholder's identity (and the grant
1890
+ // rebuilt from it on resume) equals the anchor's content token. applyGateInput
1891
+ // overwrites it from the authoritative input when one was captured.
1892
+ approvalContentDigest: digest,
1031
1893
  });
1032
1894
  // Carry the salient resource so reconstructAdjudicatedApprovals -> the grant
1033
1895
  // builder keys on the same resource the hook will see on the re-attempt.
@@ -1037,6 +1899,18 @@ function synthesizeWaitingApprovalToolCall(
1037
1899
  tc.approvalMessage = salient
1038
1900
  ? `Tool requires approval: ${displayName} (${salient})`
1039
1901
  : resolveDeniedApprovalMessage(displayName, "", {}, mergedPolicies);
1902
+ // A synthesized call is a ledger denial — it was gated, so it has a governing
1903
+ // layer. A denied call never occurs under a global bypass or a matching lease,
1904
+ // so empty leases + no bypass faithfully attribute it (a built-in resolves to
1905
+ // builtin_category; an MCP placeholder lacks a reconstructed slug and stays
1906
+ // UNSPECIFIED rather than be mislabeled).
1907
+ if (mergedPolicies) {
1908
+ const source = resolveApprovalProvenance(
1909
+ displayName, "", mergedPolicies, NO_LEASED_CATEGORIES, false,
1910
+ );
1911
+ tc.approvalPolicySource = toProtoPolicySource(source);
1912
+ if (source) tc.policyEngineVersion = POLICY_ENGINE_VERSION;
1913
+ }
1040
1914
  return tc;
1041
1915
  }
1042
1916