@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
@@ -33,10 +33,15 @@ import { create } from "@bufbuild/protobuf";
33
33
  import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
34
34
  import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
35
35
  import { MessageType, ToolCallStatus, SubAgentStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
36
- import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage } from "./approval-policy.js";
37
- import { grantToken, toolIdentity } from "./approval-state.js";
36
+ import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage, SALIENT_ARG_FIELDS } from "./approval-policy.js";
37
+ import { POLICY_ENGINE_VERSION, resolveApprovalProvenance, toProtoPolicySource, } from "../../shared/approval-policy.js";
38
+ import { grantToken, primaryToken, toolIdentity } from "./approval-state.js";
38
39
  import { utcTimestamp } from "../../shared/status.js";
39
- import { classifyTool } from "../../shared/tool-kind.js";
40
+ import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
41
+ import { classifyTool, toolApprovalCategory } from "../../shared/tool-kind.js";
42
+ import { resolveWorkspacePath } from "../../shared/file-change.js";
43
+ import { contentDigest } from "../../shared/file-tools.js";
44
+ import { buildElidedArgsPreview } from "../../shared/args-preview.js";
40
45
  export { utcTimestamp };
41
46
  const SUPPRESSED_TOOL_NAMES = new Set(["TodoWrite", "updateTodos"]);
42
47
  /**
@@ -132,8 +137,14 @@ function translateToolCall(event) {
132
137
  *
133
138
  * Approval fields are populated when mergedPolicies are provided.
134
139
  * Without policies, only basic fields are set (backward compatible).
140
+ *
141
+ * When mergedPolicies are provided, the tool call also carries its authorization
142
+ * provenance (approval_policy_source) — which policy layer gated or cleared it —
143
+ * derived from the same merged policy chain the gate uses, so the Cursor
144
+ * reconstruction is as auditable as the native harness. `provenance` supplies the
145
+ * run-scoped context (global bypass, active leases) the per-tool map cannot.
135
146
  */
136
- export function buildToolCallProto(event, mergedPolicies) {
147
+ export function buildToolCallProto(event, mergedPolicies, provenance) {
137
148
  const status = mapToolCallStatus(event.status);
138
149
  const mcpDetails = extractMcpToolDetails(event);
139
150
  const actualName = mcpDetails?.toolName ?? event.name;
@@ -186,8 +197,20 @@ export function buildToolCallProto(event, mergedPolicies) {
186
197
  }
187
198
  }
188
199
  }
200
+ // Stamp authorization provenance from the same merged policy chain the gate
201
+ // (the deny-oracle hook + this map) uses, so the persisted record explains WHY
202
+ // each tool was gated or cleared. Only when policies are present — the stateless
203
+ // path leaves it UNSPECIFIED, like an unclassified tool_kind.
204
+ if (mergedPolicies) {
205
+ const source = resolveApprovalProvenance(actualName, mcpServerSlug, mergedPolicies, provenance?.leasedCategories ?? NO_LEASED_CATEGORIES, provenance?.globalBypass ?? false);
206
+ toolCall.approvalPolicySource = toProtoPolicySource(source);
207
+ if (source)
208
+ toolCall.policyEngineVersion = POLICY_ENGINE_VERSION;
209
+ }
189
210
  return toolCall;
190
211
  }
212
+ /** Shared empty set so a reconstruction without leases allocates nothing. */
213
+ const NO_LEASED_CATEGORIES = new Set();
191
214
  function translateTask(event) {
192
215
  return create(AgentMessageSchema, {
193
216
  type: MessageType.MESSAGE_SYSTEM,
@@ -380,14 +403,25 @@ function blockText(b) {
380
403
  *
381
404
  * The Cursor SDK returns sub-agent work as a blob in the task tool's
382
405
  * completed event (not as streaming events with a distinct agent_id).
406
+ * Re-verified 2026-07-02 with live recordings on both the pinned SDK (1.0.13)
407
+ * and the latest (1.0.22): zero events reach the parent's run.stream() between
408
+ * the task tool's "running" and "completed" events, every event carries the
409
+ * parent's agent_id, and the child agentId visible in the task args at spawn
410
+ * is NOT queryable mid-run through any public read surface (Agent.listRuns /
411
+ * Agent.messages.list / Agent.getRun all return not-found for it; the SDK's
412
+ * on-disk sub-agent transcript is written only at completion). Live nested
413
+ * visibility is therefore an upstream SDK limitation — do not try to fake it
414
+ * here; the UI shows an elapsed-time affordance instead (SubAgentSection).
383
415
  * The result shape is:
384
416
  *
385
417
  * { status: "success", value: { conversationSteps: ConversationStep[] } }
386
418
  *
387
- * where ConversationStep is a discriminated union:
388
- * - { type: "thinkingMessage", message: { text, thinkingDurationMs? } }
389
- * - { type: "assistantMessage", message: { text } }
390
- * - { type: "toolCall", message: { type, args, result?, ... } }
419
+ * where each ConversationStep is a protobuf-oneof object keyed DIRECTLY by its
420
+ * kind (there is NO `{ type, message }` envelope — verified against production
421
+ * sub-agent blobs; see the `buildSubAgentToolCall` note):
422
+ * - { thinkingMessage: { text, thinkingDurationMs? } }
423
+ * - { assistantMessage: { text } }
424
+ * - { toolCall: { toolCallId, <kind>ToolCall: { args, result } } }
391
425
  *
392
426
  * This function defensively parses whatever steps are present and
393
427
  * appends corresponding AgentMessage protos to the output array.
@@ -431,51 +465,122 @@ export function extractConversationSteps(result, out) {
431
465
  }));
432
466
  }
433
467
  }
434
- else if (type === "toolCall") {
435
- const msg = s.message;
436
- if (msg) {
437
- const toolName = typeof msg.type === "string" ? msg.type : "unknown";
438
- const toolArgs = msg.args != null ? JSON.stringify(msg.args) : "";
439
- let toolResult = "";
440
- if (msg.result != null) {
441
- const resultObj = msg.result;
442
- if (resultObj.status === "success" && resultObj.value != null) {
443
- // Normalize a sub-agent screenshot the same way as a top-level tool
444
- // result; fall back to the existing value serialization otherwise.
445
- toolResult = canonicalizeImageResult(resultObj.value)
446
- ?? (typeof resultObj.value === "string"
447
- ? resultObj.value
448
- : JSON.stringify(resultObj.value));
449
- }
450
- else if (resultObj.status === "error") {
451
- toolResult = typeof resultObj.error === "string"
452
- ? resultObj.error
453
- : JSON.stringify(resultObj);
454
- }
455
- else {
456
- toolResult = JSON.stringify(msg.result);
457
- }
458
- }
459
- const aiMsg = create(AgentMessageSchema, {
468
+ else if (s.toolCall != null) {
469
+ const tc = buildSubAgentToolCall(s.toolCall, out.length);
470
+ if (tc) {
471
+ out.push(create(AgentMessageSchema, {
460
472
  type: MessageType.MESSAGE_AI,
461
473
  content: "",
462
474
  timestamp: utcTimestamp(),
463
- toolCalls: [create(ToolCallSchema, {
464
- id: `sub-${toolName}-${out.length}`,
465
- name: toolName,
466
- status: ToolCallStatus.TOOL_CALL_COMPLETED,
467
- argsPreview: toolArgs,
468
- result: toolResult,
469
- startedAt: utcTimestamp(),
470
- completedAt: utcTimestamp(),
471
- toolKind: classifyTool(toolName),
472
- })],
473
- });
474
- out.push(aiMsg);
475
+ toolCalls: [tc],
476
+ }));
475
477
  }
476
478
  }
477
479
  }
478
480
  }
481
+ /**
482
+ * The failure branches of a sub-agent tool call's `result` oneof. A completion
483
+ * is `{ success: ... }`; every other branch is a non-completion the UI must show
484
+ * as failed — an errored read/glob/grep (`error`), or a shell the approval gate
485
+ * stopped (`permissionDenied` / `rejected`).
486
+ */
487
+ const SUBAGENT_TOOL_RESULT_FAILURE_KEYS = new Set([
488
+ "error",
489
+ "permissionDenied",
490
+ "rejected",
491
+ ]);
492
+ /**
493
+ * Build a ToolCall proto from one sub-agent `toolCall` conversation step.
494
+ *
495
+ * The Cursor SDK serializes a sub-agent's tool call as protobuf-oneof JSON:
496
+ *
497
+ * { toolCallId, <kind>ToolCall: { args, result } }
498
+ *
499
+ * The tool family is the lone `<kind>ToolCall` sibling of `toolCallId` (e.g.
500
+ * `readToolCall`, `globToolCall`, `grepToolCall`, `shellToolCall`); the bare
501
+ * tool name (`read`) is the suffix-stripped key, which feeds the shared
502
+ * {@link classifyTool} exactly like a top-level call. `result` is itself a oneof
503
+ * `{ success | error | permissionDenied | rejected }` (see
504
+ * {@link interpretSubAgentToolResult}).
505
+ *
506
+ * This is deliberately key-driven rather than an enumerated switch, so a new
507
+ * tool family the SDK adds surfaces automatically instead of being dropped.
508
+ * Returns undefined when no `<kind>ToolCall` key is present (a malformed or
509
+ * forward-incompatible step), so the caller skips it rather than emitting a
510
+ * blank, nameless tool call.
511
+ *
512
+ * History: an earlier revision parsed a `{ type: "toolCall", message: { type,
513
+ * args, result: { status, value } } }` envelope. That shape never appears in the
514
+ * real task-result blob (confirmed against production sub-agent outputs and the
515
+ * WA03 capture), so every sub-agent tool call was silently discarded and the UI
516
+ * showed a sub-agent that "did nothing".
517
+ */
518
+ function buildSubAgentToolCall(toolCall, seq) {
519
+ if (toolCall == null || typeof toolCall !== "object")
520
+ return undefined;
521
+ const obj = toolCall;
522
+ const kindKey = Object.keys(obj).find((k) => k !== "toolCallId" && k.endsWith("ToolCall"));
523
+ if (!kindKey)
524
+ return undefined;
525
+ const name = kindKey.slice(0, -"ToolCall".length);
526
+ const inner = obj[kindKey] != null && typeof obj[kindKey] === "object"
527
+ ? obj[kindKey]
528
+ : {};
529
+ // Prefer the SDK's real call id so the row is stable across resumes and never
530
+ // collides with a sibling; fall back to a per-step synthetic id only when the
531
+ // SDK omits one.
532
+ const id = typeof obj.toolCallId === "string" && obj.toolCallId
533
+ ? obj.toolCallId
534
+ : `sub-${name}-${seq}`;
535
+ const { status, result, error } = interpretSubAgentToolResult(inner.result);
536
+ const tc = create(ToolCallSchema, {
537
+ id,
538
+ name,
539
+ status,
540
+ result,
541
+ error,
542
+ startedAt: utcTimestamp(),
543
+ completedAt: utcTimestamp(),
544
+ toolKind: classifyTool(name),
545
+ });
546
+ if (inner.args != null && typeof inner.args === "object") {
547
+ tc.args = inner.args;
548
+ tc.argsPreview = JSON.stringify(inner.args);
549
+ }
550
+ return tc;
551
+ }
552
+ /**
553
+ * Map a sub-agent tool call's `result` oneof to a (status, result, error)
554
+ * triple. `success` → COMPLETED with the serialized payload (a screenshot is
555
+ * canonicalized the same way as a top-level result); any failure branch (see
556
+ * {@link SUBAGENT_TOOL_RESULT_FAILURE_KEYS}) → FAILED with the serialized
557
+ * detail. An absent result is a COMPLETED call with no output — the SDK omits
558
+ * `result` for a call that reports nothing.
559
+ */
560
+ function interpretSubAgentToolResult(result) {
561
+ if (result == null || typeof result !== "object") {
562
+ return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "", error: "" };
563
+ }
564
+ const r = result;
565
+ if ("success" in r) {
566
+ const val = r.success;
567
+ const str = canonicalizeImageResult(val) ??
568
+ (typeof val === "string" ? val : JSON.stringify(val));
569
+ return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: str, error: "" };
570
+ }
571
+ const failKey = Object.keys(r).find((k) => SUBAGENT_TOOL_RESULT_FAILURE_KEYS.has(k));
572
+ if (failKey) {
573
+ const val = r[failKey];
574
+ const detail = typeof val === "string" ? val : JSON.stringify(val);
575
+ return { status: ToolCallStatus.TOOL_CALL_FAILED, result: "", error: detail };
576
+ }
577
+ // Unknown oneof branch — surface it as a completed result rather than drop it.
578
+ return {
579
+ status: ToolCallStatus.TOOL_CALL_COMPLETED,
580
+ result: JSON.stringify(r),
581
+ error: "",
582
+ };
583
+ }
479
584
  /**
480
585
  * Stateful accumulator that merges per-token SDK events into coherent
481
586
  * AgentMessages.
@@ -531,11 +636,43 @@ export class MessageAccumulator {
531
636
  _subAgentExecutions = [];
532
637
  subAgentMap = new Map();
533
638
  mergedPolicies;
639
+ provenance;
640
+ workspaceRoot;
534
641
  toolCallIndex = new Map();
535
642
  _dirty = false;
536
643
  constructor(messages, options) {
537
644
  this.messages = messages;
538
645
  this.mergedPolicies = options?.mergedPolicies;
646
+ this.provenance = options?.provenance;
647
+ this.workspaceRoot = options?.workspaceRoot;
648
+ // Resume seeding. When constructed over a pre-seeded transcript (a durable
649
+ // resume — see seedCursorTranscriptFromExecution in index.ts), rebuild the
650
+ // by-id tool-call index so a cross-message completion for a seeded call
651
+ // resolves onto the existing proto, and re-register seeded sub-agents so
652
+ // their resumed lifecycle updates merge in place. A first run carries an
653
+ // empty transcript and no seed, so both are no-ops. Mirrors the deep-agent
654
+ // ExecutionState.rebuildToolCallIndex + sub-agent re-registration on resume.
655
+ this.rebuildToolCallIndex();
656
+ for (const sub of options?.seededSubAgents ?? []) {
657
+ this._subAgentExecutions.push(sub);
658
+ if (sub.id)
659
+ this.subAgentMap.set(sub.id, sub);
660
+ }
661
+ }
662
+ /**
663
+ * Index every tool call already present in the (seeded) transcript by its
664
+ * call_id. Called once at construction: on a first run the transcript is empty
665
+ * (no-op); on a resume it lets re-emitted lifecycle events for a previously
666
+ * committed call_id reconcile onto the existing proto instead of duplicating.
667
+ */
668
+ rebuildToolCallIndex() {
669
+ this.toolCallIndex.clear();
670
+ for (const message of this.messages) {
671
+ for (const tc of message.toolCalls) {
672
+ if (tc.id)
673
+ this.toolCallIndex.set(tc.id, tc);
674
+ }
675
+ }
539
676
  }
540
677
  get subAgentExecutions() {
541
678
  return this._subAgentExecutions;
@@ -621,16 +758,56 @@ export class MessageAccumulator {
621
758
  if (SUPPRESSED_TOOL_NAMES.has(event.name))
622
759
  return;
623
760
  const existing = this.toolCallIndex.get(event.call_id);
624
- if (!existing) {
625
- const tc = buildToolCallProto(event, this.mergedPolicies);
626
- this.findOrCreateLastAiMessage().toolCalls.push(tc);
627
- this.toolCallIndex.set(event.call_id, tc);
628
- // A new tool call is a discrete, user-visible event — force a prompt
629
- // flush so the live UI surfaces it the instant it starts.
630
- this._dirty = true;
761
+ if (existing) {
762
+ this.mergeToolCallEvent(existing, event);
631
763
  return;
632
764
  }
633
- this.mergeToolCallEvent(existing, event);
765
+ const tc = buildToolCallProto(event, this.mergedPolicies, this.provenance);
766
+ // Resume reconciliation. A resumed Cursor agent re-runs a previously
767
+ // approved tool with a BRAND-NEW call_id, so it misses the by-id index
768
+ // above. Reconcile it onto the seeded WAITING_APPROVAL call with the same
769
+ // canonical identity (the (category, salient)/MCP-name space the hook and
770
+ // grants already use — see toolCallIdentityToken) and keep the original id.
771
+ // Without this the seeded approved call and the re-run would both appear (a
772
+ // duplicate row) and dropping the seeded id would trip the backend's
773
+ // append-only-at-identity guard, stalling the run. This generalizes the v2
774
+ // deep-agent StatusBuilder.findResumableSeededToolCall (a tool-name match)
775
+ // to the full Cursor identity, reusing the single existing identity
776
+ // definition rather than introducing a parallel one.
777
+ const seeded = this.findResumableSeededToolCall(tc);
778
+ if (seeded) {
779
+ // Re-key the fresh call_id onto the seeded proto so this call_id's later
780
+ // lifecycle events resolve here, then merge in place. mergeToolCallEvent
781
+ // advances WAITING_APPROVAL (non-terminal) toward the event's status.
782
+ this.toolCallIndex.set(event.call_id, seeded);
783
+ this.mergeToolCallEvent(seeded, event);
784
+ return;
785
+ }
786
+ this.findOrCreateLastAiMessage().toolCalls.push(tc);
787
+ this.toolCallIndex.set(event.call_id, tc);
788
+ // A new tool call is a discrete, user-visible event — force a prompt
789
+ // flush so the live UI surfaces it the instant it starts.
790
+ this._dirty = true;
791
+ }
792
+ /**
793
+ * Find a seeded, still-gated tool call this resumed event should reconcile
794
+ * onto: the first tool call in the index that is still WAITING_APPROVAL and
795
+ * shares the candidate's canonical identity token. "First" (Map iteration =
796
+ * transcript order) mirrors the v2 deep-agent's ordered first-unreconciled
797
+ * match — once reconciled a call leaves WAITING_APPROVAL, so a second co-
798
+ * pending call with the same identity naturally reconciles onto the next one.
799
+ * Tool calls created during this turn are not WAITING_APPROVAL until the
800
+ * post-stream denial reconciliation runs, so they can never be matched here.
801
+ */
802
+ findResumableSeededToolCall(candidate) {
803
+ const wanted = toolCallIdentityToken(candidate);
804
+ for (const tc of this.toolCallIndex.values()) {
805
+ if (tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL &&
806
+ toolCallIdentityToken(tc) === wanted) {
807
+ return tc;
808
+ }
809
+ }
810
+ return undefined;
634
811
  }
635
812
  /**
636
813
  * Merge a repeated tool_call event into the ToolCall already tracked for this
@@ -800,70 +977,624 @@ export class MessageAccumulator {
800
977
  * approved once should produce one approval regardless of how many times the
801
978
  * agent re-attempted it within the turn.
802
979
  *
803
- * If a ledger denial has no matching streamed tool call (rare Cursor normally
804
- * emits a tool_call event for every attempt), a placeholder WAITING_APPROVAL
805
- * tool call is synthesized so the gate still surfaces and never renders as a
806
- * silent success.
980
+ * ONE GATE PER TURN (deny-only clean pause). The Cursor harness can only gate by
981
+ * the hook returning `deny`, which Cursor surfaces to the model as a tool
982
+ * *failure* so a blocked model frequently improvises a workaround (the
983
+ * canonical case: a denied `edit notes.md` followed ~2.5s later by a
984
+ * `shell: cat > notes.md`, in the SAME assistant message with no narration
985
+ * between them — observed in production, exec aex_01kw4p0cqgk0j8vvxbs5t8gv59).
986
+ * The first-denial stop (index.ts) tries to cancel the turn at that first
987
+ * denial, but `run.cancel()` is async and races the SDK's auto-execution, so the
988
+ * workaround can still stream and land a SECOND denial in the ledger. Two
989
+ * denials of distinct identity would otherwise surface two approval cards for
990
+ * one logical intent. Their identities differ (`write\nnotes.md` vs
991
+ * `shell\ncat > notes.md`), so no same-identity twin collapse can join them, and
992
+ * they share one message, so no positional rule can separate them; the only
993
+ * honest signal that the second is a reaction is CAUSALITY — it was emitted
994
+ * after the model saw the first denial. We therefore ANCHOR on the FIRST ledger
995
+ * denial of the turn (the ledger is reset per turn and appended in denial order,
996
+ * so ledger[0] is the original intent) and surface ONLY that identity. Every
997
+ * other denied identity in the turn — a post-denial workaround, or a genuine
998
+ * co-pending sibling the deny-only harness defers — is blanked in place to a
999
+ * hidden SKIPPED row ({@link collapseNonAnchorDenials}). A deferred sibling is
1000
+ * not lost: on resume it re-attempts and gates again next turn (sequential
1001
+ * gating). The native (LangGraph) harness pauses BEFORE the model can react, so
1002
+ * it keeps full in-turn co-pending and is untouched by this rule. This is the
1003
+ * near-term, invariant-preserving stepping stone to the Tool Execution Gateway,
1004
+ * where an un-leased workaround is refused by construction.
1005
+ *
1006
+ * Correlation runs in two passes. The first matches the streamed token to a
1007
+ * ledger token byte-for-byte (the common case). The hook, however, records its
1008
+ * token from the RAW path Cursor hands it — a bash script cannot normalize a
1009
+ * path against the workspace root — so an ABSOLUTE hook `file_path` against a
1010
+ * RELATIVE stream `path` (or vice versa) yields two different raw tokens for one
1011
+ * edit and the exact pass misses. The runner CAN normalize, so a second pass
1012
+ * matches any still-unmatched FILE denial to a streamed call by (category,
1013
+ * workspace-normalized path) and overlays the REAL streamed call, never appending
1014
+ * a content-less placeholder beside it. This is the difference between one honest
1015
+ * gate and two cards, one of which reads "No preview available". It reuses the
1016
+ * single tool-identity definition + `resolveWorkspacePath`; it introduces no
1017
+ * parallel identity.
807
1018
  *
808
- * Returns the tool calls now marked WAITING_APPROVAL (overlaid + synthesized).
1019
+ * Only after BOTH passes miss is a placeholder WAITING_APPROVAL tool call
1020
+ * synthesized (rare — Cursor normally emits a tool_call event for every
1021
+ * attempt), so the gate still surfaces and never renders as a silent success.
1022
+ * Critically, every match overlays a call IN PLACE (the committed id is
1023
+ * preserved): the backend's append-only-at-identity transcript guard rejects a
1024
+ * finalize that drops a previously-committed tool-call id, so reconciliation may
1025
+ * only reconcile entries in place, never remove them.
1026
+ *
1027
+ * Every matched/synthesized call is enriched with the hook-captured authoritative
1028
+ * input (`ledger.input`) via {@link applyGateInput}: the full proposed args, a
1029
+ * compact `args_preview`, and the content digest — so the approval card renders
1030
+ * the proposed write/edit content from `args` and a resume re-gates a diverging
1031
+ * sibling edit. A missing capture (the hook's grep fallback) degrades to the
1032
+ * prior behavior.
1033
+ *
1034
+ * Returns the tool calls now marked WAITING_APPROVAL — the single anchor gate
1035
+ * for the turn (overlaid or, rarely, synthesized).
809
1036
  */
810
- export function reconcileDeniedToolCalls(messages, ledger, mergedPolicies) {
1037
+ export async function reconcileDeniedToolCalls(messages, ledger, mergedPolicies, workspaceBackend) {
811
1038
  if (ledger.length === 0)
812
1039
  return [];
813
- // One approval per denied identity; a resource re-attempted within the turn
814
- // is gated under the same token and collapses to a single approval.
1040
+ // The workspace the gated files live in; its rootDir normalizes paths for the
1041
+ // abs-vs-rel correlation fallback (normalizedFileSalient).
1042
+ const workspaceRoot = workspaceBackend?.rootDir;
1043
+ // One gate per turn: anchor on the FIRST ledger denial. The ledger is reset
1044
+ // per turn and appended in denial order, so ledger[0] is the model's original
1045
+ // intent; any later denial of a DIFFERENT identity is a post-denial workaround
1046
+ // or a deferred co-pending sibling (see the doc comment). We surface ONLY the
1047
+ // anchor identity below and blank every other denied identity to a hidden
1048
+ // SKIPPED row. `deniedTokens` still carries every denied identity — it is the
1049
+ // scope for that collapse, never an additional gate.
1050
+ const anchorToken = ledger[0].token;
815
1051
  const deniedTokens = new Set(ledger.map((e) => e.token));
816
- const matched = new Set();
1052
+ // The authoritative pre-execution args the hook captured for the anchor. A
1053
+ // resource re-attempted within the turn shares a token; last write wins (the
1054
+ // attempts carry the same proposed change).
1055
+ let anchorInput;
1056
+ for (const e of ledger) {
1057
+ if (e.token === anchorToken && e.input)
1058
+ anchorInput = e.input;
1059
+ }
1060
+ const matchedCalls = new Set();
817
1061
  const result = [];
818
- // 1. Overlay WAITING_APPROVAL onto the streamed tool calls that were denied.
1062
+ let anchorMatched = false;
1063
+ // 1. Exact overlay: a streamed call whose token equals the anchor denial token
1064
+ // byte-for-byte (the path form agreed on both sides). The anchor resource
1065
+ // re-attempted within the turn shares one token and collapses to a single
1066
+ // approval (the first match is the keeper; same-identity twins are blanked
1067
+ // by collapseRedundantToolCallTwins below).
819
1068
  for (const msg of messages) {
1069
+ if (anchorMatched)
1070
+ break;
820
1071
  for (const tc of msg.toolCalls) {
821
- const token = toolCallIdentityToken(tc);
822
- if (!deniedTokens.has(token) || matched.has(token))
1072
+ if (toolCallIdentityToken(tc) !== anchorToken)
823
1073
  continue;
824
- markWaitingApproval(tc, mergedPolicies);
825
- matched.add(token);
1074
+ overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
1075
+ matchedCalls.add(tc);
826
1076
  result.push(tc);
1077
+ anchorMatched = true;
1078
+ break;
827
1079
  }
828
1080
  }
829
- // 2. Synthesize a tool call for any denial that never produced a stream event.
830
- // Rare with correct correlation (Cursor emits a tool_call for every attempt),
831
- // so this is a defensive net that still surfaces the gate rather than letting
832
- // a denied tool render as a silent success.
833
- for (const entry of ledger) {
834
- if (matched.has(entry.token))
835
- continue;
836
- const decoded = decodeIdentityToken(entry.token);
1081
+ // 2. Normalized-path fallback (the abs-vs-rel drift fix): if the anchor is a
1082
+ // FILE denial the exact pass missed, match a streamed call by (category,
1083
+ // workspace-normalized path) and overlay the REAL call never a content-
1084
+ // less placeholder beside it. Requires the workspace root to normalize;
1085
+ // shell/MCP denials (no path) and resumes without a root fall through to
1086
+ // synthesis.
1087
+ if (!anchorMatched && workspaceRoot) {
1088
+ const decoded = decodeIdentityToken(anchorToken);
1089
+ const wanted = decoded
1090
+ ? normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot)
1091
+ : undefined;
1092
+ if (wanted) {
1093
+ const tc = findUnmatchedStreamCallByNormalizedSalient(messages, matchedCalls, wanted, workspaceRoot);
1094
+ if (tc) {
1095
+ overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
1096
+ matchedCalls.add(tc);
1097
+ result.push(tc);
1098
+ anchorMatched = true;
1099
+ }
1100
+ }
1101
+ }
1102
+ // 2a. One gate per turn: blank every denied identity OTHER than the anchor to a
1103
+ // hidden SKIPPED row (the workaround shell, or a deferred co-pending
1104
+ // sibling). Runs BEFORE the WAITING_FOR_APPROVAL persist so a reaction is
1105
+ // never persisted as WAITING_APPROVAL — the backend authors an approval
1106
+ // REQUESTED event only from a WAITING_APPROVAL tool call, so collapsing
1107
+ // here keeps the append-only approval-event stream free of an orphan
1108
+ // REQUESTED that would need retraction.
1109
+ const nonAnchorCollapsed = collapseNonAnchorDenials(messages, deniedTokens, anchorToken);
1110
+ if (nonAnchorCollapsed > 0) {
1111
+ console.log(`ExecuteCursor reconcile collapsed ${nonAnchorCollapsed} non-anchor denied ` +
1112
+ `tool call(s) to hidden SKIPPED (one gate per turn; anchor is the first ` +
1113
+ `denial of the turn)`);
1114
+ }
1115
+ // 2b. Collapse same-turn duplicate edits. When the model emitted the SAME
1116
+ // resource twice in one turn (two call ids, one identity token), only the
1117
+ // FIRST same-token stream call was overlaid into the gate above; any OTHER
1118
+ // same-token call stays a committed row (RUNNING zombie, or a
1119
+ // denied-reported-as-success COMPLETED) that would render as a second,
1120
+ // content-less card beside the gate (the reported "No preview available"
1121
+ // duplicate). The overlaid gate is now WAITING_APPROVAL, so the shared
1122
+ // routine recognizes it as the keeper and blanks the twins IN PLACE to
1123
+ // hidden SKIPPED rows — we cannot drop them, since the backend's
1124
+ // append-only-at-identity guard rejects removing a previously-committed
1125
+ // tool-call id, but the id is preserved so the finalize stays append-only.
1126
+ const collapsed = collapseRedundantToolCallTwins(messages);
1127
+ if (collapsed > 0) {
1128
+ console.log(`ExecuteCursor reconcile collapsed ${collapsed} redundant tool-call twin(s) ` +
1129
+ `superseded by the approval gate (kept in place as hidden SKIPPED rows)`);
1130
+ }
1131
+ // 2c. Finalize interrupted rows. The first-denial stop cancelled the run, so
1132
+ // a tool call still PENDING/RUNNING here can never complete — no event
1133
+ // will ever deliver its result, and left alone it persists as a spinner
1134
+ // forever. The canonical victim is a post-denial workaround whose own
1135
+ // hook denial raced (or never reached) the final ledger read, so the
1136
+ // token-scoped collapse in 2a could not see it (production case
1137
+ // aex_01kwj07f7g23c3wp9sn8496z5g: a python-write shell reaction persisted
1138
+ // as RUNNING with requiresApproval=true). Whatever the cause, a
1139
+ // non-terminal row on a turn that is pausing is an interrupted attempt
1140
+ // with no output: collapse it to the same hidden SKIPPED shape as every
1141
+ // other superseded row (in place — the append-only-at-identity guard
1142
+ // forbids dropping a committed id). Runs AFTER the anchor overlay, so the
1143
+ // gate itself (now WAITING_APPROVAL) is never touched.
1144
+ const interrupted = finalizeInterruptedToolCalls(messages);
1145
+ if (interrupted > 0) {
1146
+ console.log(`ExecuteCursor reconcile collapsed ${interrupted} interrupted non-terminal ` +
1147
+ `tool call(s) that can never complete (run cancelled at first denial)`);
1148
+ }
1149
+ // 3. Synthesize the anchor gate if it matched NO streamed call in either pass
1150
+ // (rare — Cursor emits a tool_call event for every attempt), so the gate
1151
+ // still surfaces rather than rendering as a silent success. After the
1152
+ // normalized fallback this should be ~0; the caller logs a divergence when
1153
+ // it is not (a synthesized id is prefixed `approval:`). Only the anchor is
1154
+ // ever synthesized: non-anchor denials are deliberately collapsed, never
1155
+ // surfaced (one gate per turn).
1156
+ if (!anchorMatched) {
1157
+ const anchorEntry = ledger.find((e) => e.token === anchorToken) ?? ledger[0];
1158
+ const decoded = decodeIdentityToken(anchorToken);
837
1159
  // Display the hook's raw tool name; carry the decoded salient so the grant
838
1160
  // rebuilt from this tool call on reinvocation keys on the same resource.
839
- const displayName = entry.toolName || decoded?.key || "tool";
1161
+ const displayName = anchorEntry.toolName || decoded?.key || "tool";
840
1162
  const salient = decoded?.salient ?? "";
841
- const tc = synthesizeWaitingApprovalToolCall(displayName, salient, entry.token, mergedPolicies);
1163
+ const tc = synthesizeWaitingApprovalToolCall(displayName, salient, decoded?.digest ?? "", anchorToken, mergedPolicies);
1164
+ // The hook-captured input upgrades the placeholder from a bare {path} to the
1165
+ // full proposed args, so even a synthesized gate shows the proposed change.
1166
+ applyGateInput(tc, anchorInput ?? anchorEntry.input);
842
1167
  appendToolCallToLastAiMessage(messages, tc);
843
- matched.add(entry.token);
844
1168
  result.push(tc);
845
1169
  }
846
1170
  return result;
847
1171
  }
1172
+ /**
1173
+ * Overlay WAITING_APPROVAL onto a streamed tool call the hook denied. Mutates
1174
+ * `tc` in place — the call keeps its committed id, so the backend's
1175
+ * append-only-at-identity transcript guard accepts the finalize (an in-place
1176
+ * status change is a reconcile, not a drop). The single overlay routine for both
1177
+ * the exact and the normalized correlation passes, so the gate diff can never
1178
+ * diverge between them. The hook-captured `input` (when present) is the
1179
+ * authoritative, complete proposed args — the stream may have carried only
1180
+ * partial args before the first-denial cancel — so it supplies the args preview
1181
+ * and the content digest (see {@link applyGateInput}).
1182
+ */
1183
+ function overlayDeniedStreamCall(tc, input, mergedPolicies) {
1184
+ markWaitingApproval(tc, mergedPolicies);
1185
+ applyGateInput(tc, input);
1186
+ }
1187
+ /**
1188
+ * Collapse every tool call still in a non-terminal state (PENDING / RUNNING)
1189
+ * to the hidden SKIPPED row shape, returning how many were collapsed.
1190
+ *
1191
+ * Called only on the pause-for-approval path, after the anchor gate has been
1192
+ * overlaid to WAITING_APPROVAL: the run was cancelled, so nothing will ever
1193
+ * complete these calls, and a permanently-RUNNING row would render as an
1194
+ * eternal spinner beside the approval card. This is the causality sibling of
1195
+ * {@link collapseNonAnchorDenials}: that collapse is token-scoped (it needs the
1196
+ * denial in the ledger), while this one catches the attempt whose hook denial
1197
+ * raced the final ledger read or whose execution the cancel interrupted
1198
+ * outright — either way an attempt with no output that the turn's end orphaned.
1199
+ */
1200
+ function finalizeInterruptedToolCalls(messages) {
1201
+ let finalized = 0;
1202
+ for (const msg of messages) {
1203
+ for (const tc of msg.toolCalls) {
1204
+ if (tc.status !== ToolCallStatus.TOOL_CALL_PENDING &&
1205
+ tc.status !== ToolCallStatus.TOOL_CALL_RUNNING) {
1206
+ continue;
1207
+ }
1208
+ hideToolCallRow(tc);
1209
+ finalized++;
1210
+ }
1211
+ }
1212
+ return finalized;
1213
+ }
1214
+ /**
1215
+ * Recognizes a tool call already blanked to a hidden collapsed row, so a second
1216
+ * pass never re-collapses it (and never miscounts). Mirrors the SDK's
1217
+ * `isCollapsedToolCall` shape without importing across the runner/SDK seam.
1218
+ */
1219
+ function isAlreadyCollapsed(tc) {
1220
+ return (tc.status === ToolCallStatus.TOOL_CALL_SKIPPED &&
1221
+ !tc.requiresApproval &&
1222
+ !tc.result &&
1223
+ !tc.error &&
1224
+ !tc.argsPreview);
1225
+ }
1226
+ /**
1227
+ * Whether a tool call carries a change/output of its own — the signal that it is
1228
+ * authoritative for its resource rather than a redundant denial/cancel twin.
1229
+ *
1230
+ * The notion of "change" is category-aware on purpose:
1231
+ * - A file mutation (`write`/`delete`) never carries an authoritative change on
1232
+ * the tool-call ROW: under apply-then-review its review lives in the
1233
+ * `FileChangeSet` ledger (capture mode), and under the no-storage deny-gate it
1234
+ * is the WAITING_APPROVAL gate itself (kept explicitly by the caller). So a
1235
+ * file row is authoritative only as that gate, never on its own — hence
1236
+ * `false` here. (Before Phase 5 Slice 4 this read `file_changes.length > 0`;
1237
+ * that field is gone, and the row was never the review surface.)
1238
+ * - Every other gated tool (shell, MCP) has no ledger; its "change" is its
1239
+ * execution output, so a genuine run carries a non-empty `result` while a
1240
+ * denied/cancelled attempt that never executed does not. This keeps two
1241
+ * distinct shell runs (each with output) both visible while still collapsing a
1242
+ * same-command denial twin.
1243
+ */
1244
+ function carriesOwnChange(tc) {
1245
+ const category = toolApprovalCategory(tc.name);
1246
+ if (category === "write" || category === "delete") {
1247
+ return false;
1248
+ }
1249
+ return !!tc.result;
1250
+ }
1251
+ /**
1252
+ * Collapse redundant same-identity tool-call twins to a single visible row.
1253
+ *
1254
+ * The model frequently emits the SAME gated action twice in one turn (two
1255
+ * tool-call ids, one identity). When the first attempt is gated and the run is
1256
+ * cancelled mid-flight, the extra attempt never receives a terminal event and
1257
+ * persists as a stuck `RUNNING` row ("No preview available"); other variants are
1258
+ * a denied-reported-as-success `COMPLETED` with an empty result, two no-change
1259
+ * `COMPLETED` attempts where neither carries a change, or — on the denial path —
1260
+ * a `FAILED` twin beside the overlaid gate. All render as a duplicate card beside
1261
+ * the real action (or the approval gate). This is the recurring duplicate-card
1262
+ * defect, most visible for file edits but shared by every gated tool family.
1263
+ *
1264
+ * The routine is harness-agnostic and a pure function of `messages`:
1265
+ *
1266
+ * 1. Scope to GATED identities — file mutations (`write`/`delete`) and shell key
1267
+ * on their cross-taxonomy category; MCP tools are recognized by their server
1268
+ * slug. A same-turn duplicate of a gated tool is a denial/cancel artifact, not
1269
+ * meaningful repetition. The category is name-derived (via {@link toolIdentity}
1270
+ * -> approvalCategory), so a twin cancelled before classification (empty
1271
+ * `toolKind`) is still scoped via its name (`edit` -> `write`). Ungated
1272
+ * read-only tools are left untouched.
1273
+ * 2. Group those calls by `toolCallIdentityToken` — the SAME `toolIdentity` used
1274
+ * for denial correlation and resume grants, so scope and grouping cannot drift.
1275
+ * 3. In each group the keepers carry authoritative state — a change/output of
1276
+ * their own (see {@link carriesOwnChange}) or the approval gate itself
1277
+ * (`WAITING_APPROVAL`). For a file mutation the gate is the sole authoritative
1278
+ * row: the row carries no diff (review lives in the `FileChangeSet` ledger, or
1279
+ * is the no-storage deny-gate itself), so a denied write's same-identity
1280
+ * siblings — a denied/zombie row or a stale snapshot from a second attempt —
1281
+ * collapse onto the gate. A shell/MCP twin keeps every distinct run with
1282
+ * output. If NO member qualifies (every attempt produced no change), keep
1283
+ * exactly ONE representative — preferring a terminal attempt over a stuck
1284
+ * `RUNNING` zombie — so the resource still shows a single card. Every
1285
+ * non-keeper is blanked in place to a hidden `SKIPPED` row (see
1286
+ * {@link collapseDenialTwin}).
1287
+ *
1288
+ * It is deliberately subtractive — it only ever HIDES a row, never invents a
1289
+ * terminal state. The committed `id` is preserved on every collapse, so the
1290
+ * finalize stays append-only by construction and the backend's
1291
+ * append-only-at-identity guard accepts it. Returns the number collapsed, for
1292
+ * observability.
1293
+ */
1294
+ export function collapseRedundantToolCallTwins(messages) {
1295
+ const groups = new Map();
1296
+ for (const msg of messages) {
1297
+ for (const tc of msg.toolCalls) {
1298
+ const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1299
+ const gated = tc.mcpServerSlug
1300
+ ? true
1301
+ : id.key === "write" || id.key === "delete" || id.key === "shell";
1302
+ if (!gated)
1303
+ continue;
1304
+ const token = grantToken(id.key, id.salient);
1305
+ const bucket = groups.get(token);
1306
+ if (bucket)
1307
+ bucket.push(tc);
1308
+ else
1309
+ groups.set(token, [tc]);
1310
+ }
1311
+ }
1312
+ let collapsed = 0;
1313
+ for (const group of groups.values()) {
1314
+ if (group.length < 2)
1315
+ continue; // a lone call is never a twin
1316
+ // Keepers carry authoritative state: a change/output of their own
1317
+ // (carriesOwnChange) or the approval gate itself. For a file mutation the gate
1318
+ // is the sole authoritative row (the row carries no diff — review lives in the
1319
+ // ledger, or the row IS the no-storage deny-gate), so a denied write's
1320
+ // same-identity siblings collapse onto it; a shell/MCP twin keeps every
1321
+ // distinct run with output.
1322
+ const keepers = new Set(group.filter((tc) => carriesOwnChange(tc) ||
1323
+ tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL));
1324
+ // All attempts produced no change (e.g. denied-reported-as-success): keep one
1325
+ // representative, preferring a settled outcome over a stuck RUNNING zombie.
1326
+ if (keepers.size === 0) {
1327
+ const terminal = [...group].reverse().find((tc) => isTerminalToolStatus(tc.status));
1328
+ keepers.add(terminal ?? group[0]);
1329
+ }
1330
+ for (const tc of group) {
1331
+ if (keepers.has(tc))
1332
+ continue;
1333
+ if (isAlreadyCollapsed(tc))
1334
+ continue;
1335
+ collapseDenialTwin(tc);
1336
+ collapsed++;
1337
+ }
1338
+ }
1339
+ return collapsed;
1340
+ }
1341
+ /**
1342
+ * One gate per turn: blank every DENIED tool call whose identity differs from
1343
+ * the anchor (the first denial of the turn) to a hidden SKIPPED row.
1344
+ *
1345
+ * This is the cross-identity complement of {@link collapseRedundantToolCallTwins}
1346
+ * (which only joins SAME-identity duplicates). The canonical target is the
1347
+ * deny-only workaround — a denied `edit notes.md` followed by a `shell:
1348
+ * cat > notes.md` whose identity (`shell\n…`) differs from the edit's
1349
+ * (`write\nnotes.md`), so no twin collapse can join them and (since they share
1350
+ * one assistant message) no positional rule can separate them. The honest signal
1351
+ * that the shell is redundant is that it is a DIFFERENT denied identity in the
1352
+ * same turn as the anchor; under the one-gate-per-turn contract every such
1353
+ * identity is either a post-denial reaction or a co-pending sibling the harness
1354
+ * defers to the next turn, so it is hidden, not surfaced.
1355
+ *
1356
+ * Scoped strictly to identities present in `deniedTokens`: a non-denied tool
1357
+ * (an earlier read/glob, or an already-granted call that ran) is never touched.
1358
+ * Subtractive and id-preserving (via {@link collapseDenialTwin}), so the finalize
1359
+ * stays append-only. Returns the number collapsed, for observability.
1360
+ */
1361
+ function collapseNonAnchorDenials(messages, deniedTokens, anchorToken) {
1362
+ let collapsed = 0;
1363
+ for (const msg of messages) {
1364
+ for (const tc of msg.toolCalls) {
1365
+ const token = toolCallIdentityToken(tc);
1366
+ if (token === anchorToken)
1367
+ continue;
1368
+ if (!deniedTokens.has(token))
1369
+ continue;
1370
+ if (isAlreadyCollapsed(tc))
1371
+ continue;
1372
+ collapseDenialTwin(tc);
1373
+ collapsed++;
1374
+ }
1375
+ }
1376
+ return collapsed;
1377
+ }
1378
+ /**
1379
+ * Blank a superseded denial twin in place to a hidden SKIPPED row. Keeps the
1380
+ * committed `id` (append-only), `name`, and `toolKind`; clears every renderable
1381
+ * surface and the approval flags so the SDK's `isCollapsedToolCall` predicate
1382
+ * recognizes it and renders nothing. The structured `args` are left as the honest
1383
+ * stored record of the redundant attempt (never rendered, since the row is
1384
+ * hidden; the gate carries the authoritative proposed change).
1385
+ */
1386
+ function collapseDenialTwin(tc) {
1387
+ hideToolCallRow(tc);
1388
+ }
1389
+ // `hideToolCallRow` (the "hidden row" shape, shared by the denial-twin collapse
1390
+ // and the capture flow) lives in shared/tool-row.ts so both harnesses collapse
1391
+ // rows identically; imported at the top of this module.
1392
+ /**
1393
+ * Overlay the hook-captured authoritative tool input onto a gated tool call so
1394
+ * the approval card can show the proposed change before the user approves.
1395
+ *
1396
+ * When `input` is present it becomes the single source for the preview: the full
1397
+ * structured `args` (the approval card renders the proposed write/edit content
1398
+ * from these), a compact-but-always-valid `args_preview` (the field a resumed
1399
+ * turn parses to rebuild the grant salient — so salient fields are never elided),
1400
+ * and the content digest.
1401
+ *
1402
+ * The digest is the resume identity: it binds the grant to (category, path,
1403
+ * content) so a sibling edit to the same file re-gates rather than riding an
1404
+ * earlier approval through. It is also the identity the Cursor deny-gate's
1405
+ * exact-apply reads on resume — together with the whole-file bytes in `args` — to
1406
+ * write exactly what was approved (see exact-apply.ts). There is no separate
1407
+ * captured `file_changes` mirror; `args` is the single source for both the
1408
+ * preview and the applied bytes.
1409
+ *
1410
+ * With no `input` (the hook's grep fallback) there is nothing authoritative to
1411
+ * stamp and the call keeps its existing args.
1412
+ */
1413
+ function applyGateInput(tc, input) {
1414
+ if (!input)
1415
+ return;
1416
+ tc.args = input;
1417
+ tc.argsPreview = buildElidedArgsPreview(input, SALIENT_ARG_FIELDS);
1418
+ // Stamp the content digest from the AUTHORITATIVE captured input, so the
1419
+ // approved edit's exact content survives to resume on a small, never-elided
1420
+ // field — the grant then binds to (category, path, content) and a sibling
1421
+ // edit to the same file re-gates. Empty for a non-content tool. This is the
1422
+ // one place the digest is authored; everything downstream reads the field.
1423
+ tc.approvalContentDigest = contentDigest(input);
1424
+ }
1425
+ /**
1426
+ * The workspace-normalized identity of a FILE approval category's salient, or
1427
+ * undefined for a non-file category (shell, whose salient is a command, not a
1428
+ * path) or an empty salient. Both the hook-decoded denial salient and a streamed
1429
+ * call's salient pass through this, so an absolute-vs-relative path difference
1430
+ * collapses to one comparable key (`category + "\n" + relPath`). Restricting to
1431
+ * write/delete keeps a shell command from being mangled by path normalization.
1432
+ */
1433
+ function normalizedFileSalient(category, salient, workspaceRoot) {
1434
+ if ((category !== "write" && category !== "delete") || !salient)
1435
+ return undefined;
1436
+ const { path } = resolveWorkspacePath(salient, workspaceRoot, /* virtualRoot */ false);
1437
+ return `${category}\n${path}`;
1438
+ }
1439
+ /**
1440
+ * Find the first not-yet-overlaid streamed tool call whose workspace-normalized
1441
+ * (category, path) equals `wanted`. Skips calls already claimed by an earlier
1442
+ * denial so several concurrent file denials each overlay a distinct stream call.
1443
+ */
1444
+ function findUnmatchedStreamCallByNormalizedSalient(messages, matchedCalls, wanted, workspaceRoot) {
1445
+ for (const msg of messages) {
1446
+ for (const tc of msg.toolCalls) {
1447
+ if (matchedCalls.has(tc))
1448
+ continue;
1449
+ const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1450
+ if (normalizedFileSalient(id.key, id.salient, workspaceRoot) === wanted) {
1451
+ return tc;
1452
+ }
1453
+ }
1454
+ }
1455
+ return undefined;
1456
+ }
1457
+ /**
1458
+ * Redact provisional post-denial narration when a Cursor turn pauses for approval.
1459
+ *
1460
+ * THE PROBLEM. Unlike the native harness — which gates with a LangGraph
1461
+ * `interrupt()` *before* the tool runs, so the model never sees a denial — the
1462
+ * Cursor harness can only gate via the file-based `beforeMCPExecution`/
1463
+ * `preToolUse` hook returning `deny`. Cursor surfaces that deny to the model as
1464
+ * a tool *failure* (often its own generic "blocked by a hook" text; see the
1465
+ * Phase 0 ground-truth capture in cursor_hitl_test.go), and there is no
1466
+ * non-leaky SDK approval primitive to use instead (the `request` event is
1467
+ * opaque and carries no responder). So a well-behaved model frequently reacts by
1468
+ * narrating defeat — "I couldn't do this; enable the hook in your Cursor
1469
+ * settings" — which would otherwise be persisted as the assistant's verdict and
1470
+ * rendered right next to the approval card that is, in fact, asking the user to
1471
+ * approve. Contradictory and alarming.
1472
+ *
1473
+ * THE GUARANTEE. The runner's job is to simplify this data, not mirror its
1474
+ * complexity: a turn that pauses for approval must read the SAME shape the
1475
+ * native harness produces — `[pre-tool text][tool calls WAITING_APPROVAL]`, with
1476
+ * no post-denial verdict. We therefore BLANK (clear the `content` of, and mark
1477
+ * non-streaming) the trailing assistant/thinking messages that (a) appear
1478
+ * positionally AFTER the last message bearing a WAITING_APPROVAL tool call and
1479
+ * (b) carry no tool calls of their own. The approval card (projected from the
1480
+ * WAITING_APPROVAL tool-call status) becomes the single, unambiguous source of
1481
+ * truth. The blanked messages are already invisible on every surface via the
1482
+ * existing empty-message handling (`buildThreadItems` skips empty `MESSAGE_AI`;
1483
+ * `MessageEntry` renders nothing for empty `MESSAGE_THINKING`), so the shared
1484
+ * `@stigmer/react`/Ink components stay harness-agnostic with zero per-harness UI
1485
+ * special-casing — the cleanliness lives in the data, not in each consumer.
1486
+ *
1487
+ * WHY BLANK INSTEAD OF REMOVE. Removing the messages would make the persisted
1488
+ * WAITING_FOR_APPROVAL transcript SHORTER than the in-progress transcript the
1489
+ * runner already streamed. The backend's append-only message guard rejects a
1490
+ * shrink for a non-terminal execution (it protects against regressed/partial
1491
+ * writes). Blanking keeps the message COUNT identical, so the finalize is
1492
+ * append-only BY CONSTRUCTION and the guard accepts it with no special case —
1493
+ * which is why this phase deletes the backend's former `isApprovalFinalize`
1494
+ * shrink exception in both editions. The transcript is the authoritative *raw*
1495
+ * record; the verbatim narration text remains recoverable from the runner logs
1496
+ * and the recorded cursor-event stream.
1497
+ *
1498
+ * WHY THIS IS DETERMINISTIC. `attachToolCallToLastAi` calls
1499
+ * `finalizeStreaming(run_id)` before attaching a tool call, so any assistant
1500
+ * text the model emits *after* the denied tool call always starts a NEW message
1501
+ * — post-denial narration is never merged into the message that holds the gated
1502
+ * call. We stop at the first non-narration message — one bearing a VISIBLE tool
1503
+ * call — so legitimately-executed tools after the gate and any text around them
1504
+ * are never touched; only the contiguous trailing reaction block is blanked. A
1505
+ * message whose every tool call was collapsed to the hidden SKIPPED row (a
1506
+ * post-denial workaround or an interrupted attempt — see
1507
+ * collapseNonAnchorDenials / finalizeInterruptedToolCalls, which run first) IS
1508
+ * trailing narration: its rows render as absent, so only its text remains, and
1509
+ * that text is precisely the reaction this redaction exists to blank. Treating
1510
+ * it as a stop would strand every reaction message behind it (the production
1511
+ * shape in aex_01kwj07f7g23c3wp9sn8496z5g: [gate][thinking][narration+workaround
1512
+ * row] — the old walk stopped at the workaround message and redacted nothing).
1513
+ * The first-denial stop in index.ts is the primary mechanism that keeps this
1514
+ * block small (it ends the turn before the model produces inter-tool
1515
+ * narration); this redaction is the backstop for any token that streamed before
1516
+ * the cancel landed.
1517
+ *
1518
+ * Returns the blanked messages (for diagnostics); mutates `messages` in place.
1519
+ */
1520
+ export function clearProvisionalPostDenialNarration(messages, deniedToolCalls) {
1521
+ if (deniedToolCalls.length === 0)
1522
+ return [];
1523
+ // reconcileDeniedToolCalls returns the very ToolCall protos held inside
1524
+ // messages[].toolCalls (overlaid) or appended to the last AI message
1525
+ // (synthesized), so object identity is a stable, exact match.
1526
+ const denied = new Set(deniedToolCalls);
1527
+ let lastGatedIdx = -1;
1528
+ for (let i = 0; i < messages.length; i++) {
1529
+ if (messages[i].toolCalls.some((tc) => denied.has(tc))) {
1530
+ lastGatedIdx = i;
1531
+ }
1532
+ }
1533
+ if (lastGatedIdx < 0)
1534
+ return [];
1535
+ const redacted = [];
1536
+ for (let i = messages.length - 1; i > lastGatedIdx; i--) {
1537
+ const msg = messages[i];
1538
+ const isProvisionalNarration = (msg.type === MessageType.MESSAGE_AI || msg.type === MessageType.MESSAGE_THINKING) &&
1539
+ msg.toolCalls.every((tc) => isToolCallRowHidden(tc));
1540
+ // Stop at the first message that is NOT trailing narration: a message
1541
+ // bearing a visible (non-collapsed) tool call marks real activity we must
1542
+ // preserve, and anything before it is no longer "trailing".
1543
+ if (!isProvisionalNarration)
1544
+ break;
1545
+ // Blank in place — keep the message so the transcript count never shrinks,
1546
+ // but drop its provisional content so no consumer renders the defeatist
1547
+ // verdict. Empty AI/THINKING messages are hidden by the SDK already; hidden
1548
+ // SKIPPED rows already render as absent.
1549
+ msg.content = "";
1550
+ msg.isStreaming = false;
1551
+ redacted.unshift(msg);
1552
+ }
1553
+ return redacted;
1554
+ }
848
1555
  /**
849
1556
  * Compute a streamed tool call's identity token in the same canonical space the
850
- * preToolUse hook records denials in (see {@link toolIdentity} and grantToken).
851
- * The token keys on the cross-taxonomy category + salient resource, so a stream
852
- * `edit` (token `base64("write\n/path")`) correlates to the hook's `Write` deny
853
- * for the same path, even though the two layers name the tool differently.
1557
+ * preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
1558
+ * The token keys on the cross-taxonomy category + salient resource PLUS, for a
1559
+ * file edit/write, the {@link contentDigest} of the edit content — so a stream
1560
+ * `edit` correlates to the hook's `Write` deny for the same path AND content,
1561
+ * and an approval of one edit does not match a DIFFERENT edit to the same file.
1562
+ *
1563
+ * The digest is read from the persisted `approval_content_digest` field when
1564
+ * present (a seeded gate carries it, stable even if `args` was elided), and is
1565
+ * recomputed from the call's args otherwise (a freshly-streamed call). For a
1566
+ * shell/delete/MCP call (no content) it falls back to the coarse token, exactly
1567
+ * as before — so those identities are unchanged.
1568
+ *
1569
+ * Exported so the resume-grant round-trip can be locked against it: the grant a
1570
+ * resume mints for an approved tool (buildApprovalGrants -> primaryToken) must
1571
+ * equal THIS denial/overlay identity, or the re-issued call is re-gated forever
1572
+ * (the dual-path drift the approval-state round-trip suite guards against).
854
1573
  */
855
- function toolCallIdentityToken(tc) {
1574
+ export function toolCallIdentityToken(tc) {
856
1575
  const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
857
- return grantToken(id.key, id.salient);
1576
+ const digest = tc.approvalContentDigest || contentDigest(toolCallArgs(tc));
1577
+ return primaryToken(id.key, id.salient, digest);
858
1578
  }
859
- /** Decode a grantToken back into its (key, salient) for the synthesis fallback. */
1579
+ /**
1580
+ * Decode a primary token back into its (key, salient, digest) for the synthesis
1581
+ * fallback. The token is `base64(key \n salient)` (coarse) or
1582
+ * `base64(key \n salient \n digest)` (content-exact); the digest is the optional
1583
+ * third segment. salient never contains a newline (a path or shell command), so
1584
+ * splitting on the first two newlines is unambiguous.
1585
+ */
860
1586
  function decodeIdentityToken(token) {
861
1587
  try {
862
1588
  const decoded = Buffer.from(token, "base64").toString("utf-8");
863
- const nl = decoded.indexOf("\n");
864
- if (nl < 0)
1589
+ const first = decoded.indexOf("\n");
1590
+ if (first < 0)
865
1591
  return undefined;
866
- return { key: decoded.slice(0, nl), salient: decoded.slice(nl + 1) };
1592
+ const key = decoded.slice(0, first);
1593
+ const rest = decoded.slice(first + 1);
1594
+ const second = rest.indexOf("\n");
1595
+ if (second < 0)
1596
+ return { key, salient: rest, digest: "" };
1597
+ return { key, salient: rest.slice(0, second), digest: rest.slice(second + 1) };
867
1598
  }
868
1599
  catch {
869
1600
  return undefined;
@@ -902,7 +1633,7 @@ function markWaitingApproval(tc, mergedPolicies) {
902
1633
  tc.error = "";
903
1634
  tc.result = "";
904
1635
  }
905
- function synthesizeWaitingApprovalToolCall(displayName, salient, token, mergedPolicies) {
1636
+ function synthesizeWaitingApprovalToolCall(displayName, salient, digest, token, mergedPolicies) {
906
1637
  const tc = create(ToolCallSchema, {
907
1638
  id: `approval:${token}`,
908
1639
  name: displayName,
@@ -911,6 +1642,10 @@ function synthesizeWaitingApprovalToolCall(displayName, salient, token, mergedPo
911
1642
  startedAt: utcTimestamp(),
912
1643
  approvalRequestedAt: utcTimestamp(),
913
1644
  toolKind: classifyTool(displayName),
1645
+ // Carry the decoded digest so this placeholder's identity (and the grant
1646
+ // rebuilt from it on resume) equals the anchor's content token. applyGateInput
1647
+ // overwrites it from the authoritative input when one was captured.
1648
+ approvalContentDigest: digest,
914
1649
  });
915
1650
  // Carry the salient resource so reconstructAdjudicatedApprovals -> the grant
916
1651
  // builder keys on the same resource the hook will see on the re-attempt.
@@ -920,6 +1655,17 @@ function synthesizeWaitingApprovalToolCall(displayName, salient, token, mergedPo
920
1655
  tc.approvalMessage = salient
921
1656
  ? `Tool requires approval: ${displayName} (${salient})`
922
1657
  : resolveDeniedApprovalMessage(displayName, "", {}, mergedPolicies);
1658
+ // A synthesized call is a ledger denial — it was gated, so it has a governing
1659
+ // layer. A denied call never occurs under a global bypass or a matching lease,
1660
+ // so empty leases + no bypass faithfully attribute it (a built-in resolves to
1661
+ // builtin_category; an MCP placeholder lacks a reconstructed slug and stays
1662
+ // UNSPECIFIED rather than be mislabeled).
1663
+ if (mergedPolicies) {
1664
+ const source = resolveApprovalProvenance(displayName, "", mergedPolicies, NO_LEASED_CATEGORIES, false);
1665
+ tc.approvalPolicySource = toProtoPolicySource(source);
1666
+ if (source)
1667
+ tc.policyEngineVersion = POLICY_ENGINE_VERSION;
1668
+ }
923
1669
  return tc;
924
1670
  }
925
1671
  /**