@stigmer/runner 3.15.3-dev.20260914121148 → 3.16.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 (592) hide show
  1. package/README.md +2 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/__test-utils__/approval-contract/types.d.ts +1 -1
  4. package/dist/__test-utils__/hermetic-activity.d.ts +61 -7
  5. package/dist/__test-utils__/hermetic-activity.js +100 -9
  6. package/dist/__test-utils__/hermetic-activity.js.map +1 -1
  7. package/dist/__test-utils__/model-registry-fixture.d.ts +22 -3
  8. package/dist/__test-utils__/model-registry-fixture.js +22 -7
  9. package/dist/__test-utils__/model-registry-fixture.js.map +1 -1
  10. package/dist/activities/execute-cursor/__test-utils__/contract-subject.d.ts +2 -2
  11. package/dist/activities/execute-cursor/__test-utils__/contract-subject.js +2 -2
  12. package/dist/activities/execute-cursor/__test-utils__/fold.d.ts +61 -0
  13. package/dist/activities/execute-cursor/__test-utils__/fold.js +94 -0
  14. package/dist/activities/execute-cursor/__test-utils__/fold.js.map +1 -0
  15. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js +1 -1
  16. package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js.map +1 -1
  17. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.d.ts +47 -12
  18. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js +41 -10
  19. package/dist/activities/execute-cursor/__test-utils__/hermetic-cursor.js.map +1 -1
  20. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.d.ts +15 -6
  21. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js +17 -4
  22. package/dist/activities/execute-cursor/__test-utils__/scripted-agent.js.map +1 -1
  23. package/dist/activities/execute-cursor/adapter.d.ts +4 -4
  24. package/dist/activities/execute-cursor/adapter.js +4 -4
  25. package/dist/activities/execute-cursor/approval-policy.d.ts +10 -10
  26. package/dist/activities/execute-cursor/approval-policy.js +12 -23
  27. package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
  28. package/dist/activities/execute-cursor/approval-state.d.ts +35 -1
  29. package/dist/activities/execute-cursor/approval-state.js +65 -0
  30. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  31. package/dist/activities/execute-cursor/{message-translator.d.ts → boundary-rows.d.ts} +45 -341
  32. package/dist/activities/execute-cursor/boundary-rows.js +1018 -0
  33. package/dist/activities/execute-cursor/boundary-rows.js.map +1 -0
  34. package/dist/activities/execute-cursor/cursor-capabilities.d.ts +1 -1
  35. package/dist/activities/execute-cursor/cursor-capabilities.js +1 -1
  36. package/dist/activities/execute-cursor/cursor-event-recorder.d.ts +33 -14
  37. package/dist/activities/execute-cursor/cursor-event-recorder.js +43 -21
  38. package/dist/activities/execute-cursor/cursor-event-recorder.js.map +1 -1
  39. package/dist/activities/execute-cursor/prompt-builder.d.ts +3 -3
  40. package/dist/activities/execute-cursor/prompt-builder.js +4 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  42. package/dist/activities/execute-cursor/service-tier.js +1 -2
  43. package/dist/activities/execute-cursor/service-tier.js.map +1 -1
  44. package/dist/activities/execute-cursor/sub-agent-steps.d.ts +79 -0
  45. package/dist/activities/execute-cursor/sub-agent-steps.js +180 -0
  46. package/dist/activities/execute-cursor/sub-agent-steps.js.map +1 -0
  47. package/dist/activities/execute-cursor/tool-result.d.ts +49 -0
  48. package/dist/activities/execute-cursor/tool-result.js +130 -0
  49. package/dist/activities/execute-cursor/tool-result.js.map +1 -0
  50. package/dist/activities/execute-cursor/translator.d.ts +178 -0
  51. package/dist/activities/execute-cursor/translator.js +485 -0
  52. package/dist/activities/execute-cursor/translator.js.map +1 -0
  53. package/dist/activities/execute-cursor/turn-boundary.d.ts +15 -5
  54. package/dist/activities/execute-cursor/turn-boundary.js +10 -14
  55. package/dist/activities/execute-cursor/turn-boundary.js.map +1 -1
  56. package/dist/activities/execute-cursor/turn-settle.d.ts +11 -7
  57. package/dist/activities/execute-cursor/turn-settle.js +47 -34
  58. package/dist/activities/execute-cursor/turn-settle.js.map +1 -1
  59. package/dist/activities/execute-cursor/turn-setup.d.ts +2 -2
  60. package/dist/activities/execute-cursor/turn-setup.js +5 -5
  61. package/dist/activities/execute-cursor/turn-setup.js.map +1 -1
  62. package/dist/activities/execute-cursor/turn-stream.d.ts +23 -14
  63. package/dist/activities/execute-cursor/turn-stream.js +41 -28
  64. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  65. package/dist/activities/execute-cursor/turn.d.ts +4 -4
  66. package/dist/activities/execute-cursor/turn.js +5 -5
  67. package/dist/activities/execute-cursor/usage-pricing.d.ts +2 -2
  68. package/dist/activities/execute-cursor/usage-pricing.js +2 -2
  69. package/dist/activities/execute-deep-agent/__test-utils__/contract-subject.d.ts +2 -2
  70. package/dist/activities/execute-deep-agent/__test-utils__/contract-subject.js +2 -2
  71. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.d.ts +16 -11
  72. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js +22 -10
  73. package/dist/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.js.map +1 -1
  74. package/dist/activities/execute-deep-agent/__test-utils__/hitl-script.js +1 -1
  75. package/dist/activities/execute-deep-agent/__test-utils__/hitl-script.js.map +1 -1
  76. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model-module.d.ts +2 -3
  77. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model-module.js +2 -3
  78. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model-module.js.map +1 -1
  79. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +12 -3
  80. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +18 -4
  81. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
  82. package/dist/activities/execute-deep-agent/__test-utils__/v3-event-fixtures.d.ts +10 -1
  83. package/dist/activities/execute-deep-agent/__test-utils__/v3-event-fixtures.js +24 -11
  84. package/dist/activities/execute-deep-agent/__test-utils__/v3-event-fixtures.js.map +1 -1
  85. package/dist/activities/execute-deep-agent/adapter.d.ts +1 -1
  86. package/dist/activities/execute-deep-agent/adapter.js +1 -1
  87. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +19 -19
  88. package/dist/activities/execute-deep-agent/approval-file-change.js +16 -15
  89. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -1
  90. package/dist/activities/execute-deep-agent/deep-agent-capabilities.d.ts +2 -2
  91. package/dist/activities/execute-deep-agent/deep-agent-capabilities.js +2 -2
  92. package/dist/activities/execute-deep-agent/hitl.d.ts +2 -2
  93. package/dist/activities/execute-deep-agent/hitl.js +2 -2
  94. package/dist/activities/execute-deep-agent/inline-publisher.d.ts +13 -3
  95. package/dist/activities/execute-deep-agent/inline-publisher.js +3 -3
  96. package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
  97. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +3 -3
  98. package/dist/activities/execute-deep-agent/prompt-builder.js +6 -7
  99. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  100. package/dist/activities/execute-deep-agent/streaming-side-effects.d.ts +16 -8
  101. package/dist/activities/execute-deep-agent/streaming-side-effects.js +20 -33
  102. package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
  103. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +3 -3
  104. package/dist/activities/execute-deep-agent/subagent-transformer.js +1 -1
  105. package/dist/activities/execute-deep-agent/translator.d.ts +134 -0
  106. package/dist/activities/execute-deep-agent/translator.js +418 -0
  107. package/dist/activities/execute-deep-agent/translator.js.map +1 -0
  108. package/dist/activities/execute-deep-agent/turn-settle.d.ts +13 -7
  109. package/dist/activities/execute-deep-agent/turn-settle.js +34 -45
  110. package/dist/activities/execute-deep-agent/turn-settle.js.map +1 -1
  111. package/dist/activities/execute-deep-agent/turn-setup.d.ts +8 -8
  112. package/dist/activities/execute-deep-agent/turn-setup.js +7 -7
  113. package/dist/activities/execute-deep-agent/turn-stream.d.ts +13 -10
  114. package/dist/activities/execute-deep-agent/turn-stream.js +44 -41
  115. package/dist/activities/execute-deep-agent/turn-stream.js.map +1 -1
  116. package/dist/activities/execute-deep-agent/turn.d.ts +3 -3
  117. package/dist/activities/execute-deep-agent/turn.js +3 -3
  118. package/dist/activities/generate-session-subject.d.ts +25 -4
  119. package/dist/activities/generate-session-subject.js +17 -7
  120. package/dist/activities/generate-session-subject.js.map +1 -1
  121. package/dist/client/stigmer-client.d.ts +17 -6
  122. package/dist/client/stigmer-client.js +47 -14
  123. package/dist/client/stigmer-client.js.map +1 -1
  124. package/dist/config.d.ts +6 -3
  125. package/dist/config.js +4 -1
  126. package/dist/config.js.map +1 -1
  127. package/dist/harness/approval-decisions.d.ts +1 -1
  128. package/dist/harness/approval-decisions.js +1 -1
  129. package/dist/harness/capabilities.d.ts +21 -11
  130. package/dist/harness/capabilities.js +2 -2
  131. package/dist/harness/capture.d.ts +4 -4
  132. package/dist/harness/capture.js +4 -4
  133. package/dist/harness/persist-chokepoint.d.ts +3 -3
  134. package/dist/harness/persist-chokepoint.js +3 -3
  135. package/dist/harness/registry.d.ts +2 -2
  136. package/dist/harness/registry.js +2 -2
  137. package/dist/harness/run-turn.d.ts +6 -4
  138. package/dist/harness/run-turn.js +31 -9
  139. package/dist/harness/run-turn.js.map +1 -1
  140. package/dist/harness/stop-controller.d.ts +1 -1
  141. package/dist/harness/stop-controller.js +1 -1
  142. package/dist/harness/terminal-table.d.ts +7 -7
  143. package/dist/harness/terminal-table.js +7 -7
  144. package/dist/harness/transcript/builder.d.ts +182 -0
  145. package/dist/harness/transcript/builder.js +693 -0
  146. package/dist/harness/transcript/builder.js.map +1 -0
  147. package/dist/harness/transcript/events.d.ts +218 -0
  148. package/dist/harness/transcript/events.js +50 -0
  149. package/dist/harness/transcript/events.js.map +1 -0
  150. package/dist/harness/transcript/state.d.ts +110 -0
  151. package/dist/harness/transcript/state.js +148 -0
  152. package/dist/harness/transcript/state.js.map +1 -0
  153. package/dist/harness/turn-context.d.ts +54 -38
  154. package/dist/harness/turn-context.js +42 -43
  155. package/dist/harness/turn-context.js.map +1 -1
  156. package/dist/harness/types.d.ts +63 -22
  157. package/dist/harness/types.js +12 -11
  158. package/dist/harness/types.js.map +1 -1
  159. package/dist/harness/usage-accumulator.d.ts +2 -2
  160. package/dist/harness/usage-accumulator.js +2 -2
  161. package/dist/harness-adapters.d.ts +5 -4
  162. package/dist/harness-adapters.js +5 -4
  163. package/dist/harness-adapters.js.map +1 -1
  164. package/dist/interceptors/run-credential-activity.d.ts +28 -0
  165. package/dist/interceptors/run-credential-activity.js +56 -0
  166. package/dist/interceptors/run-credential-activity.js.map +1 -0
  167. package/dist/middleware/approval-gate.d.ts +6 -0
  168. package/dist/middleware/approval-gate.js +7 -57
  169. package/dist/middleware/approval-gate.js.map +1 -1
  170. package/dist/middleware/cost-advisory.d.ts +3 -3
  171. package/dist/middleware/cost-advisory.js +3 -3
  172. package/dist/middleware/index.d.ts +1 -1
  173. package/dist/middleware/index.js +1 -1
  174. package/dist/runner-manager.js +10 -4
  175. package/dist/runner-manager.js.map +1 -1
  176. package/dist/shared/activity-input.d.ts +9 -0
  177. package/dist/shared/activity-input.js +17 -0
  178. package/dist/shared/activity-input.js.map +1 -1
  179. package/dist/shared/approval-policy.d.ts +53 -0
  180. package/dist/shared/approval-policy.js +76 -0
  181. package/dist/shared/approval-policy.js.map +1 -1
  182. package/dist/shared/args-preview.d.ts +45 -22
  183. package/dist/shared/args-preview.js +51 -41
  184. package/dist/shared/args-preview.js.map +1 -1
  185. package/dist/shared/attachment-resolver.d.ts +4 -4
  186. package/dist/shared/attachment-resolver.js +4 -4
  187. package/dist/shared/attachment-zip.d.ts +2 -2
  188. package/dist/shared/attachment-zip.js +2 -2
  189. package/dist/shared/channel-attachment.d.ts +8 -5
  190. package/dist/shared/channel-attachment.js +10 -6
  191. package/dist/shared/channel-attachment.js.map +1 -1
  192. package/dist/shared/cost-guard.d.ts +2 -2
  193. package/dist/shared/cost-guard.js +2 -2
  194. package/dist/shared/exact-apply.d.ts +1 -1
  195. package/dist/shared/exact-apply.js +1 -1
  196. package/dist/shared/execution-context.d.ts +2 -2
  197. package/dist/shared/execution-context.js +2 -2
  198. package/dist/shared/extract-structured-output.d.ts +2 -2
  199. package/dist/shared/extract-structured-output.js +2 -2
  200. package/dist/shared/filereview/cas-touched.d.ts +2 -2
  201. package/dist/shared/filereview/cas-touched.js +2 -2
  202. package/dist/shared/filereview/progress.d.ts +13 -0
  203. package/dist/shared/filereview/progress.js +13 -0
  204. package/dist/shared/filereview/progress.js.map +1 -1
  205. package/dist/shared/heartbeat.d.ts +1 -1
  206. package/dist/shared/heartbeat.js +1 -1
  207. package/dist/shared/llm-backend.d.ts +0 -4
  208. package/dist/shared/llm-backend.js +0 -4
  209. package/dist/shared/llm-backend.js.map +1 -1
  210. package/dist/shared/memory-attachment.d.ts +16 -9
  211. package/dist/shared/memory-attachment.js +18 -10
  212. package/dist/shared/memory-attachment.js.map +1 -1
  213. package/dist/shared/model-error.d.ts +1 -2
  214. package/dist/shared/model-error.js +1 -2
  215. package/dist/shared/model-error.js.map +1 -1
  216. package/dist/shared/persist-decision.d.ts +18 -24
  217. package/dist/shared/persist-decision.js +19 -11
  218. package/dist/shared/persist-decision.js.map +1 -1
  219. package/dist/shared/prompt-sections.d.ts +4 -4
  220. package/dist/shared/prompt-sections.js +4 -4
  221. package/dist/shared/run-credential-store.d.ts +32 -0
  222. package/dist/shared/run-credential-store.js +39 -0
  223. package/dist/shared/run-credential-store.js.map +1 -0
  224. package/dist/shared/run-credential.d.ts +61 -0
  225. package/dist/shared/run-credential.js +68 -0
  226. package/dist/shared/run-credential.js.map +1 -0
  227. package/dist/shared/skill-resolver.d.ts +3 -3
  228. package/dist/shared/skill-resolver.js +3 -3
  229. package/dist/shared/status-offload.d.ts +1 -1
  230. package/dist/shared/status-offload.js +2 -2
  231. package/dist/shared/status-offload.js.map +1 -1
  232. package/dist/shared/streaming-scheduler.d.ts +2 -7
  233. package/dist/shared/streaming-scheduler.js +6 -14
  234. package/dist/shared/streaming-scheduler.js.map +1 -1
  235. package/dist/shared/subagent-rows.d.ts +2 -2
  236. package/dist/shared/subagent-rows.js +2 -2
  237. package/dist/shared/synthesized-attachment.d.ts +30 -3
  238. package/dist/shared/synthesized-attachment.js +26 -0
  239. package/dist/shared/synthesized-attachment.js.map +1 -1
  240. package/dist/shared/todos.d.ts +3 -3
  241. package/dist/shared/todos.js +3 -3
  242. package/dist/shared/tool-row.d.ts +19 -1
  243. package/dist/shared/tool-row.js +36 -2
  244. package/dist/shared/tool-row.js.map +1 -1
  245. package/dist/shared/worker-shutdown.d.ts +1 -1
  246. package/dist/shared/worker-shutdown.js +1 -1
  247. package/dist/shared/workspace/session-provision.d.ts +1 -1
  248. package/dist/shared/workspace/session-provision.js +1 -1
  249. package/dist/shared/workspace/sources/empty.d.ts +1 -1
  250. package/dist/shared/workspace/sources/empty.js +1 -2
  251. package/dist/shared/workspace/sources/empty.js.map +1 -1
  252. package/dist/shared/workspace/sources/git.js +4 -4
  253. package/dist/shared/workspace/sources/git.js.map +1 -1
  254. package/dist/shared/workspace/sources/local-path.js +6 -6
  255. package/dist/shared/workspace/sources/local-path.js.map +1 -1
  256. package/dist/shared/workspace/stigmer-link.d.ts +1 -1
  257. package/dist/shared/workspace/stigmer-link.js +1 -1
  258. package/dist/shared/workspace/types.d.ts +2 -5
  259. package/dist/shared/workspace/types.js +0 -6
  260. package/dist/shared/workspace/types.js.map +1 -1
  261. package/dist/shared/workspace/writeback-coordinator.d.ts +17 -4
  262. package/dist/shared/workspace/writeback-coordinator.js +7 -9
  263. package/dist/shared/workspace/writeback-coordinator.js.map +1 -1
  264. package/dist/worker.js +10 -3
  265. package/dist/worker.js.map +1 -1
  266. package/dist/workflow-source.d.ts +10 -0
  267. package/dist/workflow-source.js +12 -0
  268. package/dist/workflow-source.js.map +1 -1
  269. package/dist/workflows/execute-from-execution.d.ts +15 -0
  270. package/dist/workflows/execute-from-execution.js +11 -0
  271. package/dist/workflows/execute-from-execution.js.map +1 -1
  272. package/dist/workflows/index.d.ts +5 -3
  273. package/dist/workflows/index.js +5 -3
  274. package/dist/workflows/index.js.map +1 -1
  275. package/dist/workflows/interceptors/run-credential.d.ts +34 -0
  276. package/dist/workflows/interceptors/run-credential.js +71 -0
  277. package/dist/workflows/interceptors/run-credential.js.map +1 -0
  278. package/package.json +4 -4
  279. package/src/__test-utils__/__tests__/execution-record-barrier.test.ts +70 -0
  280. package/src/__test-utils__/__tests__/harness-contract-self-check.test.ts +35 -1
  281. package/src/__test-utils__/approval-contract/contract.ts +1 -1
  282. package/src/__test-utils__/approval-contract/types.ts +1 -1
  283. package/src/__test-utils__/harness-boot-order-child.ts +1 -1
  284. package/src/__test-utils__/harness-contract/contract.ts +8 -8
  285. package/src/__test-utils__/harness-contract/recording-sink.ts +11 -1
  286. package/src/__test-utils__/harness-contract/runtime-contract.ts +52 -16
  287. package/src/__test-utils__/harness-contract/scripted-adapter.ts +47 -43
  288. package/src/__test-utils__/hermetic-activity.ts +113 -9
  289. package/src/__test-utils__/model-registry-fixture.ts +31 -7
  290. package/src/__test-utils__/module-specifiers.ts +32 -3
  291. package/src/__tests__/harness-boot-order.test.ts +1 -1
  292. package/src/__tests__/harness-contract.test.ts +1 -1
  293. package/src/__tests__/run-credential-e2e.test.ts +233 -0
  294. package/src/__tests__/run-credential-registration.test.ts +80 -0
  295. package/src/__tests__/runner-manager.test.ts +1 -1
  296. package/src/activities/__tests__/generate-session-subject-wire.test.ts +119 -0
  297. package/src/activities/execute-cursor/__test-utils__/__tests__/scripted-double.test.ts +1 -1
  298. package/src/activities/execute-cursor/__test-utils__/contract-subject.ts +2 -2
  299. package/src/activities/execute-cursor/__test-utils__/fold.ts +116 -0
  300. package/src/activities/execute-cursor/__test-utils__/gateway-substrate.ts +1 -1
  301. package/src/activities/execute-cursor/__test-utils__/hermetic-cursor.ts +67 -17
  302. package/src/activities/execute-cursor/__test-utils__/scripted-agent.ts +19 -5
  303. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +2 -1
  304. package/src/activities/execute-cursor/__tests__/approval-state.test.ts +14 -21
  305. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +3 -3
  306. package/src/activities/execute-cursor/__tests__/coarse-fingerprint.test.ts +2 -1
  307. package/src/activities/execute-cursor/__tests__/cursor-event-recorder.test.ts +142 -0
  308. package/src/activities/execute-cursor/__tests__/cursor-fetch-interceptor-bypass.test.ts +19 -8
  309. package/src/activities/execute-cursor/__tests__/cursor-sdk-auth-smoke.test.ts +10 -8
  310. package/src/activities/execute-cursor/__tests__/cursor-transcript-live.test.ts +298 -0
  311. package/src/activities/execute-cursor/__tests__/deny-gate-exact-apply.test.ts +4 -2
  312. package/src/activities/execute-cursor/__tests__/fetch-interceptor.test.ts +1 -1
  313. package/src/activities/execute-cursor/__tests__/hermetic/deny-and-retry.test.ts +1 -1
  314. package/src/activities/execute-cursor/__tests__/hermetic/file-review-capture.test.ts +1 -1
  315. package/src/activities/execute-cursor/__tests__/hermetic/goldens/mcp-tool-call.status.json +71 -0
  316. package/src/activities/execute-cursor/__tests__/hermetic/goldens/sdk-task-note.status.json +73 -0
  317. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.error-delta.status.json +78 -0
  318. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.output-first.status.json +72 -0
  319. package/src/activities/execute-cursor/__tests__/hermetic/goldens/shell-output-delta.status.json +72 -0
  320. package/src/activities/execute-cursor/__tests__/hermetic/goldens/sub-agent-delegation.status.json +109 -0
  321. package/src/activities/execute-cursor/__tests__/hermetic/goldens/thinking-turn.status.json +78 -0
  322. package/src/activities/execute-cursor/__tests__/hermetic/goldens/todo-write.status.json +119 -0
  323. package/src/activities/execute-cursor/__tests__/hermetic/goldens/tool-error.status.json +68 -0
  324. package/src/activities/execute-cursor/__tests__/hermetic/goldens/tool-only-step.status.json +67 -0
  325. package/src/activities/execute-cursor/__tests__/hermetic/goldens/unattributed-hook-block.status.json +1 -1
  326. package/src/activities/execute-cursor/__tests__/hermetic/harness-contract.test.ts +7 -7
  327. package/src/activities/execute-cursor/__tests__/hermetic/mcp-tool-call.test.ts +146 -0
  328. package/src/activities/execute-cursor/__tests__/hermetic/pause-vs-shutdown.test.ts +5 -5
  329. package/src/activities/execute-cursor/__tests__/hermetic/plain-turn.test.ts +2 -1
  330. package/src/activities/execute-cursor/__tests__/hermetic/run-wait-arms.test.ts +2 -1
  331. package/src/activities/execute-cursor/__tests__/hermetic/sdk-task-note.test.ts +122 -0
  332. package/src/activities/execute-cursor/__tests__/hermetic/shell-output-delta.test.ts +276 -0
  333. package/src/activities/execute-cursor/__tests__/hermetic/stream-self-stop-arms.test.ts +8 -8
  334. package/src/activities/execute-cursor/__tests__/hermetic/sub-agent-delegation.test.ts +196 -0
  335. package/src/activities/execute-cursor/__tests__/hermetic/thinking-turn.test.ts +145 -0
  336. package/src/activities/execute-cursor/__tests__/hermetic/thrown-error-arms.test.ts +1 -1
  337. package/src/activities/execute-cursor/__tests__/hermetic/todo-write.test.ts +165 -0
  338. package/src/activities/execute-cursor/__tests__/hermetic/tool-call.test.ts +3 -3
  339. package/src/activities/execute-cursor/__tests__/hermetic/tool-error.test.ts +138 -0
  340. package/src/activities/execute-cursor/__tests__/hermetic/tool-only-step.test.ts +130 -0
  341. package/src/activities/execute-cursor/__tests__/hermetic/unattributed-hook-block.test.ts +13 -1
  342. package/src/activities/execute-cursor/__tests__/hermetic/workspace-lock-timeout.test.ts +2 -2
  343. package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +43 -60
  344. package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +23 -28
  345. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +2 -1
  346. package/src/activities/execute-cursor/__tests__/http2-interceptor.test.ts +1 -1
  347. package/src/activities/execute-cursor/__tests__/prompt-goldens.test.ts +5 -5
  348. package/src/activities/execute-cursor/__tests__/same-identity-reproposal.test.ts +11 -13
  349. package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +8 -11
  350. package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +8 -22
  351. package/src/activities/execute-cursor/__tests__/translator.test.ts +594 -0
  352. package/src/activities/execute-cursor/__tests__/turn-boundary.test.ts +8 -5
  353. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +87 -63
  354. package/src/activities/execute-cursor/adapter.ts +4 -4
  355. package/src/activities/execute-cursor/approval-policy.ts +12 -24
  356. package/src/activities/execute-cursor/approval-state.ts +66 -1
  357. package/src/activities/execute-cursor/boundary-rows.ts +1132 -0
  358. package/src/activities/execute-cursor/cursor-capabilities.ts +1 -1
  359. package/src/activities/execute-cursor/cursor-event-recorder.ts +57 -28
  360. package/src/activities/execute-cursor/fetch-interceptor.ts +1 -1
  361. package/src/activities/execute-cursor/http2-interceptor.ts +1 -1
  362. package/src/activities/execute-cursor/prompt-builder.ts +6 -6
  363. package/src/activities/execute-cursor/service-tier.ts +1 -2
  364. package/src/activities/execute-cursor/sub-agent-steps.ts +188 -0
  365. package/src/activities/execute-cursor/tool-result.ts +128 -0
  366. package/src/activities/execute-cursor/translator.ts +541 -0
  367. package/src/activities/execute-cursor/turn-boundary.ts +21 -14
  368. package/src/activities/execute-cursor/turn-settle.ts +46 -34
  369. package/src/activities/execute-cursor/turn-setup.ts +5 -5
  370. package/src/activities/execute-cursor/turn-stream.ts +49 -46
  371. package/src/activities/execute-cursor/turn.ts +5 -5
  372. package/src/activities/execute-cursor/usage-pricing.ts +2 -2
  373. package/src/activities/execute-deep-agent/__test-utils__/contract-subject.ts +2 -2
  374. package/src/activities/execute-deep-agent/__test-utils__/hermetic-deep-agent.ts +23 -11
  375. package/src/activities/execute-deep-agent/__test-utils__/hitl-script.ts +1 -1
  376. package/src/activities/execute-deep-agent/__test-utils__/scripted-model-module.ts +2 -3
  377. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +25 -4
  378. package/src/activities/execute-deep-agent/__test-utils__/v3-event-fixtures.ts +25 -11
  379. package/src/activities/execute-deep-agent/__tests__/adapter-graph-is-sdk-free.test.ts +1 -1
  380. package/src/activities/execute-deep-agent/__tests__/adapter-is-temporal-free.test.ts +1 -1
  381. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +6 -17
  382. package/src/activities/execute-deep-agent/__tests__/hermetic/file-review-capture.test.ts +25 -8
  383. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/approve-all-lease.turn1.status.json +2 -7
  384. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/approve-all-lease.turn2.status.json +4 -14
  385. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/approve.turn2.status.json +1 -5
  386. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/file-review-capture.status.json +4 -7
  387. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/gate.turn1.status.json +1 -5
  388. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn1.status.json +2 -9
  389. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/inline-artifact.turn2.status.json +2 -9
  390. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/pause.loop.after-tool.status.json +1 -4
  391. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/pause.loop.mid-tool.status.json +1 -4
  392. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/platform-stop.status.json +1 -4
  393. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/recursion-limit.persisted.status.json +109 -76
  394. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/reject.turn2.status.json +1 -5
  395. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/sequential-gates.turn2.status.json +2 -10
  396. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/sequential-gates.turn3.status.json +2 -10
  397. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/skip.turn2.status.json +1 -5
  398. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/sub-agent-delegation.status.json +3 -6
  399. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/tool-call.status.json +1 -4
  400. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/worker-shutdown.status.json +1 -4
  401. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/write-todos.status.json +92 -0
  402. package/src/activities/execute-deep-agent/__tests__/hermetic/harness-contract.test.ts +3 -3
  403. package/src/activities/execute-deep-agent/__tests__/hermetic/hitl-approve-all-lease.test.ts +9 -5
  404. package/src/activities/execute-deep-agent/__tests__/hermetic/hitl-round-trips.test.ts +3 -3
  405. package/src/activities/execute-deep-agent/__tests__/hermetic/hitl-sequential-gates.test.ts +3 -3
  406. package/src/activities/execute-deep-agent/__tests__/hermetic/inline-artifact-across-gate.test.ts +7 -6
  407. package/src/activities/execute-deep-agent/__tests__/hermetic/pause-vs-shutdown.test.ts +10 -11
  408. package/src/activities/execute-deep-agent/__tests__/hermetic/plain-turn.test.ts +4 -4
  409. package/src/activities/execute-deep-agent/__tests__/hermetic/platform-stop.test.ts +9 -8
  410. package/src/activities/execute-deep-agent/__tests__/hermetic/recursion-limit.test.ts +21 -13
  411. package/src/activities/execute-deep-agent/__tests__/hermetic/resolution-error.test.ts +2 -2
  412. package/src/activities/execute-deep-agent/__tests__/hermetic/structured-output.test.ts +7 -7
  413. package/src/activities/execute-deep-agent/__tests__/hermetic/sub-agent-delegation.test.ts +28 -12
  414. package/src/activities/execute-deep-agent/__tests__/hermetic/tool-call.test.ts +21 -21
  415. package/src/activities/execute-deep-agent/__tests__/hermetic/workspace-lock-timeout.test.ts +5 -5
  416. package/src/activities/execute-deep-agent/__tests__/hermetic/write-todos.test.ts +160 -0
  417. package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +35 -30
  418. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +3 -4
  419. package/src/activities/execute-deep-agent/__tests__/prompt-goldens.test.ts +12 -10
  420. package/src/activities/execute-deep-agent/__tests__/scripted-model.test.ts +13 -3
  421. package/src/activities/execute-deep-agent/__tests__/streaming-side-effects.test.ts +26 -18
  422. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +1 -1
  423. package/src/activities/execute-deep-agent/__tests__/{subagent-tracker.test.ts → translator-subagents.test.ts} +76 -57
  424. package/src/activities/execute-deep-agent/__tests__/{v3-status-builder.test.ts → translator-through-builder.test.ts} +213 -299
  425. package/src/activities/execute-deep-agent/__tests__/translator.test.ts +505 -0
  426. package/src/activities/execute-deep-agent/__tests__/turn-stream.test.ts +11 -29
  427. package/src/activities/execute-deep-agent/adapter.ts +1 -1
  428. package/src/activities/execute-deep-agent/approval-file-change.ts +20 -22
  429. package/src/activities/execute-deep-agent/deep-agent-capabilities.ts +2 -2
  430. package/src/activities/execute-deep-agent/hitl.ts +2 -2
  431. package/src/activities/execute-deep-agent/inline-publisher.ts +16 -5
  432. package/src/activities/execute-deep-agent/prompt-builder.ts +6 -7
  433. package/src/activities/execute-deep-agent/streaming-side-effects.ts +22 -31
  434. package/src/activities/execute-deep-agent/subagent-transformer.ts +3 -3
  435. package/src/activities/execute-deep-agent/translator.ts +463 -0
  436. package/src/activities/execute-deep-agent/turn-settle.ts +35 -43
  437. package/src/activities/execute-deep-agent/turn-setup.ts +8 -8
  438. package/src/activities/execute-deep-agent/turn-stream.ts +53 -51
  439. package/src/activities/execute-deep-agent/turn.ts +3 -3
  440. package/src/activities/generate-session-subject.ts +29 -4
  441. package/src/client/__tests__/stigmer-client.test.ts +92 -0
  442. package/src/client/stigmer-client.ts +48 -14
  443. package/src/config.ts +6 -3
  444. package/src/harness/README.md +335 -0
  445. package/src/harness/__tests__/approval-decisions.test.ts +2 -2
  446. package/src/harness/__tests__/capture.test.ts +3 -3
  447. package/src/harness/__tests__/import-direction.test.ts +3 -2
  448. package/src/harness/__tests__/mount-skills.test.ts +4 -1
  449. package/src/harness/__tests__/readme-index.test.ts +339 -0
  450. package/src/harness/__tests__/run-turn.test.ts +131 -6
  451. package/src/harness/__tests__/transcript-is-engine-free.test.ts +83 -0
  452. package/src/harness/__tests__/transcript-writers.test.ts +191 -0
  453. package/src/harness/__tests__/turn-attachments-vision.test.ts +5 -2
  454. package/src/harness/__tests__/turn-context.test.ts +5 -5
  455. package/src/harness/approval-decisions.ts +1 -1
  456. package/src/harness/capabilities.ts +21 -11
  457. package/src/harness/capture.ts +4 -4
  458. package/src/harness/persist-chokepoint.ts +3 -3
  459. package/src/harness/registry.ts +2 -2
  460. package/src/harness/run-turn.ts +33 -9
  461. package/src/harness/stop-controller.ts +1 -1
  462. package/src/harness/terminal-table.ts +7 -7
  463. package/src/harness/transcript/__tests__/builder.test.ts +975 -0
  464. package/src/harness/transcript/__tests__/state.test.ts +143 -0
  465. package/src/harness/transcript/builder.ts +755 -0
  466. package/src/harness/transcript/events.ts +262 -0
  467. package/src/harness/transcript/state.ts +167 -0
  468. package/src/harness/turn-context.ts +61 -46
  469. package/src/harness/types.ts +64 -22
  470. package/src/harness/usage-accumulator.ts +2 -2
  471. package/src/harness-adapters.ts +5 -4
  472. package/src/interceptors/__tests__/run-credential-activity.test.ts +133 -0
  473. package/src/interceptors/run-credential-activity.ts +67 -0
  474. package/src/middleware/__tests__/cost-advisory.test.ts +2 -2
  475. package/src/middleware/approval-gate.ts +7 -74
  476. package/src/middleware/cost-advisory.ts +3 -3
  477. package/src/middleware/index.ts +1 -1
  478. package/src/runner-manager.ts +14 -4
  479. package/src/shared/__tests__/approval-policy.test.ts +45 -0
  480. package/src/shared/__tests__/args-preview.test.ts +40 -20
  481. package/src/shared/__tests__/attachment-naming.test.ts +1 -1
  482. package/src/shared/__tests__/attachment-resolver.test.ts +5 -5
  483. package/src/shared/__tests__/attachment-zip.test.ts +1 -1
  484. package/src/shared/__tests__/blueprint-resolver.test.ts +1 -1
  485. package/src/shared/__tests__/channel-attachment.test.ts +12 -0
  486. package/src/shared/__tests__/cost-guard.test.ts +1 -1
  487. package/src/shared/__tests__/exact-apply.test.ts +2 -1
  488. package/src/shared/__tests__/memory-attachment.test.ts +17 -0
  489. package/src/shared/__tests__/persist-decision.test.ts +19 -33
  490. package/src/shared/__tests__/prompt-sections.test.ts +1 -1
  491. package/src/shared/__tests__/run-credential.test.ts +111 -0
  492. package/src/shared/__tests__/stamp-flowed-rows.test.ts +1 -1
  493. package/src/shared/__tests__/status-offload.test.ts +1 -1
  494. package/src/shared/__tests__/streaming-scheduler.test.ts +8 -9
  495. package/src/shared/__tests__/todos.test.ts +1 -1
  496. package/src/shared/__tests__/tool-row.test.ts +1 -1
  497. package/src/shared/activity-input.ts +21 -0
  498. package/src/shared/approval-policy.ts +95 -0
  499. package/src/shared/args-preview.ts +51 -42
  500. package/src/shared/attachment-resolver.ts +4 -4
  501. package/src/shared/attachment-zip.ts +2 -2
  502. package/src/shared/channel-attachment.ts +14 -6
  503. package/src/shared/cost-guard.ts +2 -2
  504. package/src/shared/exact-apply.ts +1 -1
  505. package/src/shared/execution-context.ts +2 -2
  506. package/src/shared/extract-structured-output.ts +2 -2
  507. package/src/shared/filereview/__tests__/capture.test.ts +9 -5
  508. package/src/shared/filereview/__tests__/git-substrate.test.ts +4 -1
  509. package/src/shared/filereview/__tests__/proxy-reconcile.test.ts +3 -3
  510. package/src/shared/filereview/cas-touched.ts +2 -2
  511. package/src/shared/filereview/progress.ts +13 -0
  512. package/src/shared/heartbeat.ts +1 -1
  513. package/src/shared/llm-backend.ts +0 -4
  514. package/src/shared/memory-attachment.ts +22 -10
  515. package/src/shared/model-error.ts +1 -2
  516. package/src/shared/persist-decision.ts +19 -28
  517. package/src/shared/prompt-sections.ts +4 -4
  518. package/src/shared/run-credential-store.ts +45 -0
  519. package/src/shared/run-credential.ts +70 -0
  520. package/src/shared/skill-resolver.ts +3 -3
  521. package/src/shared/status-offload.ts +2 -2
  522. package/src/shared/streaming-scheduler.ts +8 -13
  523. package/src/shared/subagent-rows.ts +2 -2
  524. package/src/shared/synthesized-attachment.ts +38 -3
  525. package/src/shared/todos.ts +3 -3
  526. package/src/shared/tool-row.ts +38 -2
  527. package/src/shared/worker-shutdown.ts +1 -1
  528. package/src/shared/workspace/__tests__/git-source.test.ts +3 -3
  529. package/src/shared/workspace/__tests__/provisioner.test.ts +5 -5
  530. package/src/shared/workspace/__tests__/session-provision.test.ts +2 -2
  531. package/src/shared/workspace/__tests__/writeback-coordinator.integration.test.ts +8 -9
  532. package/src/shared/workspace/__tests__/writeback-coordinator.test.ts +25 -25
  533. package/src/shared/workspace/provisioner.ts +1 -1
  534. package/src/shared/workspace/session-provision.ts +1 -1
  535. package/src/shared/workspace/sources/empty.ts +2 -2
  536. package/src/shared/workspace/sources/git.ts +3 -4
  537. package/src/shared/workspace/sources/local-path.ts +5 -6
  538. package/src/shared/workspace/stigmer-link.ts +1 -1
  539. package/src/shared/workspace/types.ts +2 -5
  540. package/src/shared/workspace/writeback-coordinator.ts +23 -12
  541. package/src/worker.ts +15 -3
  542. package/src/workflow-source.ts +15 -0
  543. package/src/workflows/__tests__/run-credential-interceptor.test.ts +178 -0
  544. package/src/workflows/execute-from-execution.ts +17 -0
  545. package/src/workflows/index.ts +5 -3
  546. package/src/workflows/interceptors/run-credential.ts +101 -0
  547. package/dist/activities/execute-cursor/delta-enricher.d.ts +0 -87
  548. package/dist/activities/execute-cursor/delta-enricher.js +0 -265
  549. package/dist/activities/execute-cursor/delta-enricher.js.map +0 -1
  550. package/dist/activities/execute-cursor/message-translator.js +0 -2040
  551. package/dist/activities/execute-cursor/message-translator.js.map +0 -1
  552. package/dist/activities/execute-cursor/todo-tracker.d.ts +0 -40
  553. package/dist/activities/execute-cursor/todo-tracker.js +0 -80
  554. package/dist/activities/execute-cursor/todo-tracker.js.map +0 -1
  555. package/dist/activities/execute-deep-agent/execution-state.d.ts +0 -54
  556. package/dist/activities/execute-deep-agent/execution-state.js +0 -68
  557. package/dist/activities/execute-deep-agent/execution-state.js.map +0 -1
  558. package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +0 -65
  559. package/dist/activities/execute-deep-agent/status-builder-shared.js +0 -90
  560. package/dist/activities/execute-deep-agent/status-builder-shared.js.map +0 -1
  561. package/dist/activities/execute-deep-agent/subagent-tracker.d.ts +0 -113
  562. package/dist/activities/execute-deep-agent/subagent-tracker.js +0 -400
  563. package/dist/activities/execute-deep-agent/subagent-tracker.js.map +0 -1
  564. package/dist/activities/execute-deep-agent/v3-events.d.ts +0 -102
  565. package/dist/activities/execute-deep-agent/v3-events.js +0 -35
  566. package/dist/activities/execute-deep-agent/v3-events.js.map +0 -1
  567. package/dist/activities/execute-deep-agent/v3-protocol-normalizer.d.ts +0 -15
  568. package/dist/activities/execute-deep-agent/v3-protocol-normalizer.js +0 -235
  569. package/dist/activities/execute-deep-agent/v3-protocol-normalizer.js.map +0 -1
  570. package/dist/activities/execute-deep-agent/v3-status-builder.d.ts +0 -111
  571. package/dist/activities/execute-deep-agent/v3-status-builder.js +0 -457
  572. package/dist/activities/execute-deep-agent/v3-status-builder.js.map +0 -1
  573. package/dist/shared/execution-status-writer.d.ts +0 -31
  574. package/dist/shared/execution-status-writer.js +0 -43
  575. package/dist/shared/execution-status-writer.js.map +0 -1
  576. package/src/activities/execute-cursor/__tests__/delta-enricher.test.ts +0 -310
  577. package/src/activities/execute-cursor/__tests__/message-translator.test.ts +0 -1033
  578. package/src/activities/execute-cursor/__tests__/todo-tracker.test.ts +0 -498
  579. package/src/activities/execute-cursor/delta-enricher.ts +0 -307
  580. package/src/activities/execute-cursor/message-translator.ts +0 -2319
  581. package/src/activities/execute-cursor/todo-tracker.ts +0 -91
  582. package/src/activities/execute-deep-agent/__tests__/execution-state-extended.test.ts +0 -148
  583. package/src/activities/execute-deep-agent/__tests__/execution-state.test.ts +0 -154
  584. package/src/activities/execute-deep-agent/__tests__/status-builder-shared.test.ts +0 -47
  585. package/src/activities/execute-deep-agent/__tests__/v3-protocol-normalizer.test.ts +0 -324
  586. package/src/activities/execute-deep-agent/execution-state.ts +0 -78
  587. package/src/activities/execute-deep-agent/status-builder-shared.ts +0 -142
  588. package/src/activities/execute-deep-agent/subagent-tracker.ts +0 -479
  589. package/src/activities/execute-deep-agent/v3-events.ts +0 -153
  590. package/src/activities/execute-deep-agent/v3-protocol-normalizer.ts +0 -264
  591. package/src/activities/execute-deep-agent/v3-status-builder.ts +0 -565
  592. package/src/shared/execution-status-writer.ts +0 -56
@@ -1,2319 +0,0 @@
1
- /**
2
- * Translates Cursor SDK streaming events into Stigmer AgentMessage protos.
3
- *
4
- * The Cursor SDK emits SDKMessage events during a Run. This module provides
5
- * both stateless translation (translateEvent) and stateful accumulation
6
- * (MessageAccumulator) for building coherent messages from token-level
7
- * streaming events.
8
- *
9
- * The Cursor SDK emits one SDKAssistantMessage per token chunk — a single
10
- * LLM turn produces dozens of events. MessageAccumulator merges them into
11
- * a single AgentMessage per turn, matching the Python agent-runner's
12
- * proven pattern (see chat_model.py handle_chat_model_stream).
13
- *
14
- * Tool calls are attached to the most recent MESSAGE_AI message rather
15
- * than emitted as standalone MESSAGE_TOOL messages. This matches:
16
- * - The proto model (AgentMessage.tool_calls repeated field)
17
- * - The Python agent-runner's StatusBuilder pattern
18
- * - The UI's MessageThread expectation (tool calls on AI messages)
19
- *
20
- * Task (sub-agent) tool calls additionally produce SubAgentExecution
21
- * protos accessible via MessageAccumulator.subAgentExecutions.
22
- *
23
- * MCP tool enrichment:
24
- * Cursor reports MCP tool calls with name="mcp" and the actual details
25
- * (providerIdentifier, toolName, args) inside event.args. This module
26
- * extracts those details to populate the ToolCall proto with:
27
- * - name: the actual MCP tool name (e.g., "search_services")
28
- * - mcpServerSlug: the MCP server identifier (e.g., "planton")
29
- * - requiresApproval: from the merged policy chain
30
- * - approvalMessage: from the policy, with placeholder resolution
31
- */
32
-
33
- import { create } from "@bufbuild/protobuf";
34
- import type { JsonObject } from "@bufbuild/protobuf";
35
- import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
36
- import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
37
- import { SubAgentExecutionSchema, type SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
38
- import { ApprovalAction, ApprovalPolicySource, MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
39
- import type { SDKMessage } from "@cursor/sdk";
40
- import type { MergedToolPolicy } from "./approval-policy.js";
41
- import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage, SALIENT_ARG_FIELDS } from "./approval-policy.js";
42
- import {
43
- POLICY_ENGINE_VERSION,
44
- resolveApprovalProvenance,
45
- toProtoPolicySource,
46
- unattendedSkipMessage,
47
- } from "../../shared/approval-policy.js";
48
- import {
49
- approvalDenials,
50
- denialKindOf,
51
- grantToken,
52
- primaryToken,
53
- toolIdentity,
54
- type DeniedLedgerEntry,
55
- } from "./approval-state.js";
56
- import { utcTimestamp } from "../../shared/status.js";
57
- import { cancelInProgressSubAgentProtos } from "../../shared/subagent-rows.js";
58
- import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
59
- import { classifyTool, toolApprovalCategory, type ToolApprovalCategory } from "../../shared/tool-kind.js";
60
- import { resolveWorkspacePath } from "../../shared/file-change.js";
61
- import { contentDigest, extractFilePath } from "../../shared/file-tools.js";
62
- import { isSecretLikePath } from "../../shared/filereview/secret-paths.js";
63
- import { buildElidedArgsPreview } from "../../shared/args-preview.js";
64
- import type { WorkspaceBackend } from "../../shared/workspace/types.js";
65
-
66
- export { utcTimestamp };
67
-
68
- const SUPPRESSED_TOOL_NAMES = new Set(["TodoWrite", "updateTodos"]);
69
-
70
- /**
71
- * Details extracted from an MCP tool call event's args.
72
- *
73
- * When Cursor invokes an MCP tool, the SDK stream reports event.name as
74
- * "mcp" and packs the real tool identity into event.args:
75
- * { providerIdentifier: "planton", toolName: "search_services", args: {...} }
76
- */
77
- export interface McpToolDetails {
78
- providerIdentifier: string;
79
- toolName: string;
80
- innerArgs: Record<string, unknown>;
81
- }
82
-
83
- /**
84
- * Try to extract MCP tool details from a tool_call event.
85
- * Returns undefined if the event is not an MCP tool call.
86
- */
87
- export function extractMcpToolDetails(
88
- event: Extract<SDKMessage, { type: "tool_call" }>,
89
- ): McpToolDetails | undefined {
90
- if (event.name !== "mcp") return undefined;
91
-
92
- const args = event.args;
93
- if (args == null || typeof args !== "object") return undefined;
94
-
95
- const obj = args as Record<string, unknown>;
96
- const providerIdentifier = typeof obj.providerIdentifier === "string" ? obj.providerIdentifier : "";
97
- const toolName = typeof obj.toolName === "string" ? obj.toolName : "";
98
-
99
- if (!toolName) return undefined;
100
-
101
- const innerArgs = (typeof obj.args === "object" && obj.args !== null)
102
- ? obj.args as Record<string, unknown>
103
- : {};
104
-
105
- return { providerIdentifier, toolName, innerArgs };
106
- }
107
-
108
- /**
109
- * Translate a single Cursor SDKMessage into zero or more Stigmer AgentMessages.
110
- *
111
- * Most events produce exactly one message. Some (like system init) are
112
- * informational and produce none.
113
- *
114
- * Note: for production streaming, use MessageAccumulator instead — it
115
- * merges token-level events and attaches tool calls to their parent AI
116
- * messages. This stateless function is retained for unit testing and
117
- * simple single-event translation.
118
- */
119
- export function translateEvent(event: SDKMessage): AgentMessage[] {
120
- switch (event.type) {
121
- case "assistant":
122
- return [translateAssistant(event)];
123
- case "thinking":
124
- return [translateThinking(event)];
125
- case "tool_call":
126
- return [translateToolCall(event)];
127
- case "task":
128
- return event.text ? [translateTask(event)] : [];
129
- case "system":
130
- case "status":
131
- case "user":
132
- case "request":
133
- return [];
134
- default:
135
- return [];
136
- }
137
- }
138
-
139
- function translateAssistant(event: Extract<SDKMessage, { type: "assistant" }>): AgentMessage {
140
- const textBlocks = event.message.content
141
- .filter((b): b is { type: "text"; text: string } => b.type === "text")
142
- .map((b) => b.text);
143
-
144
- return create(AgentMessageSchema, {
145
- type: MessageType.MESSAGE_AI,
146
- content: textBlocks.join(""),
147
- timestamp: utcTimestamp(),
148
- });
149
- }
150
-
151
- function translateThinking(event: Extract<SDKMessage, { type: "thinking" }>): AgentMessage {
152
- return create(AgentMessageSchema, {
153
- type: MessageType.MESSAGE_THINKING,
154
- content: event.text,
155
- timestamp: utcTimestamp(),
156
- });
157
- }
158
-
159
- /**
160
- * Stateless translation of a tool_call event into a standalone MESSAGE_TOOL
161
- * message. Retained for backward compatibility with translateEvent() and
162
- * tests that use the stateless API.
163
- */
164
- function translateToolCall(event: Extract<SDKMessage, { type: "tool_call" }>): AgentMessage {
165
- const toolCall = buildToolCallProto(event);
166
- const displayName = toolCall.mcpServerSlug
167
- ? `${toolCall.mcpServerSlug}/${toolCall.name}`
168
- : toolCall.name;
169
- return create(AgentMessageSchema, {
170
- type: MessageType.MESSAGE_TOOL,
171
- content: `Tool: ${displayName} [${event.status}]`,
172
- timestamp: utcTimestamp(),
173
- toolCalls: [toolCall],
174
- });
175
- }
176
-
177
- /**
178
- * Build a ToolCall proto from a Cursor SDK tool_call event.
179
- *
180
- * For MCP tools (event.name === "mcp"), extracts the actual tool name
181
- * and server slug from event.args. For built-in tools, uses the event
182
- * name directly.
183
- *
184
- * Approval fields are populated when mergedPolicies are provided.
185
- * Without policies, only basic fields are set (backward compatible).
186
- *
187
- * When mergedPolicies are provided, the tool call also carries its authorization
188
- * provenance (approval_policy_source) — which policy layer gated or cleared it —
189
- * derived from the same merged policy chain the gate uses, so the Cursor
190
- * reconstruction is as auditable as the native harness. `provenance` supplies the
191
- * run-scoped context (global bypass, active leases) the per-tool map cannot.
192
- */
193
- export function buildToolCallProto(
194
- event: Extract<SDKMessage, { type: "tool_call" }>,
195
- mergedPolicies?: ReadonlyMap<string, MergedToolPolicy>,
196
- provenance?: ApprovalProvenanceContext,
197
- ): ToolCall {
198
- const status = mapToolCallStatus(event.status);
199
- const mcpDetails = extractMcpToolDetails(event);
200
-
201
- const actualName = mcpDetails?.toolName ?? event.name;
202
- const mcpServerSlug = mcpDetails?.providerIdentifier ?? "";
203
-
204
- const toolCall = create(ToolCallSchema, {
205
- id: event.call_id,
206
- name: actualName,
207
- status,
208
- startedAt: status === ToolCallStatus.TOOL_CALL_RUNNING ? utcTimestamp() : "",
209
- completedAt: isTerminalToolStatus(status) ? utcTimestamp() : "",
210
- result: toResultString(event.result),
211
- error: status === ToolCallStatus.TOOL_CALL_FAILED
212
- ? (typeof event.result === "string" ? event.result : "Tool call failed")
213
- : "",
214
- mcpServerSlug,
215
- toolKind: classifyTool(actualName, mcpServerSlug),
216
- });
217
-
218
- if (event.args != null) {
219
- toolCall.argsPreview = typeof event.args === "string"
220
- ? event.args
221
- : JSON.stringify(event.args);
222
- }
223
-
224
- const argsObj = mcpDetails?.innerArgs ?? (
225
- typeof event.args === "object" && event.args !== null
226
- ? event.args as Record<string, unknown>
227
- : undefined
228
- );
229
- if (argsObj && typeof argsObj === "object") {
230
- toolCall.args = argsObj as import("@bufbuild/protobuf").JsonObject;
231
- }
232
-
233
- // Populate approval fields from the merged policy chain
234
- if (mergedPolicies && mcpDetails) {
235
- const policy = lookupMcpToolPolicy(actualName, mcpServerSlug, mergedPolicies);
236
- if (policy) {
237
- toolCall.requiresApproval = true;
238
- toolCall.approvalMessage = resolveApprovalMessage(
239
- policy.approvalMessage,
240
- actualName,
241
- mcpDetails.innerArgs,
242
- );
243
- if (status === ToolCallStatus.TOOL_CALL_FAILED) {
244
- toolCall.approvalRequestedAt = utcTimestamp();
245
- }
246
- }
247
- } else if (mergedPolicies && !mcpDetails) {
248
- const requires = builtInRequiresApproval(actualName);
249
- toolCall.requiresApproval = requires;
250
- if (requires) {
251
- const template = getBuiltInApprovalMessage(actualName);
252
- if (template) {
253
- toolCall.approvalMessage = resolveApprovalMessage(template, actualName, argsObj ?? {});
254
- if (status === ToolCallStatus.TOOL_CALL_FAILED) {
255
- toolCall.approvalRequestedAt = utcTimestamp();
256
- }
257
- }
258
- }
259
- }
260
-
261
- // Stamp authorization provenance from the same merged policy chain the gate
262
- // (the deny-oracle hook + this map) uses, so the persisted record explains WHY
263
- // each tool was gated or cleared. Only when policies are present — the stateless
264
- // path leaves it UNSPECIFIED, like an unclassified tool_kind.
265
- if (mergedPolicies) {
266
- const source = resolveApprovalProvenance(
267
- actualName,
268
- mcpServerSlug,
269
- mergedPolicies,
270
- provenance?.leasedCategories ?? NO_LEASED_CATEGORIES,
271
- provenance?.globalBypass ?? false,
272
- );
273
- toolCall.approvalPolicySource = toProtoPolicySource(source);
274
- if (source) toolCall.policyEngineVersion = POLICY_ENGINE_VERSION;
275
- }
276
-
277
- return toolCall;
278
- }
279
-
280
- /**
281
- * Run-scoped approval context the Cursor reconstruction needs to attribute a tool
282
- * call's provenance beyond the per-tool merged policy map: the pre-armed global
283
- * bypass and the built-in categories holding a run-lifetime lease.
284
- */
285
- export interface ApprovalProvenanceContext {
286
- readonly globalBypass: boolean;
287
- readonly leasedCategories: ReadonlySet<ToolApprovalCategory>;
288
- }
289
-
290
- /** Shared empty set so a reconstruction without leases allocates nothing. */
291
- const NO_LEASED_CATEGORIES: ReadonlySet<ToolApprovalCategory> = new Set();
292
-
293
- function translateTask(event: Extract<SDKMessage, { type: "task" }>): AgentMessage {
294
- return create(AgentMessageSchema, {
295
- type: MessageType.MESSAGE_SYSTEM,
296
- content: event.text ?? "",
297
- timestamp: utcTimestamp(),
298
- });
299
- }
300
-
301
- function mapToolCallStatus(cursorStatus: string): ToolCallStatus {
302
- switch (cursorStatus) {
303
- case "running":
304
- return ToolCallStatus.TOOL_CALL_RUNNING;
305
- case "completed":
306
- return ToolCallStatus.TOOL_CALL_COMPLETED;
307
- case "error":
308
- return ToolCallStatus.TOOL_CALL_FAILED;
309
- default:
310
- return ToolCallStatus.TOOL_CALL_STATUS_UNSPECIFIED;
311
- }
312
- }
313
-
314
- function mapSubAgentStatus(cursorStatus: string): SubAgentStatus {
315
- switch (cursorStatus) {
316
- case "running":
317
- return SubAgentStatus.SUB_AGENT_IN_PROGRESS;
318
- case "completed":
319
- return SubAgentStatus.SUB_AGENT_COMPLETED;
320
- case "error":
321
- return SubAgentStatus.SUB_AGENT_FAILED;
322
- default:
323
- return SubAgentStatus.SUB_AGENT_PENDING;
324
- }
325
- }
326
-
327
- /**
328
- * Terminal for the MONOTONIC status guard in {@link mergeToolCallEvent}: once a
329
- * row reaches one of these, a later event re-emit must not regress it.
330
- *
331
- * TOOL_CALL_INTERRUPTED is deliberately NOT here, even though it is terminal
332
- * everywhere else (server, clients, provenance scoping — see the shared
333
- * TERMINAL_TOOL_CALL_STATUSES in tool-row.ts). INTERRUPTED is server-authored
334
- * when an execution terminalizes with the call in flight; if that FAILED
335
- * execution is later RECOVERED, the harness checkpoint can re-execute the call
336
- * under its original call id — and live execution evidence outranks the
337
- * interruption marker, so the replayed event must advance the row to its true
338
- * outcome (the enum's documented recovery supersede rule).
339
- */
340
- function isTerminalToolStatus(status: ToolCallStatus): boolean {
341
- return (
342
- status === ToolCallStatus.TOOL_CALL_COMPLETED ||
343
- status === ToolCallStatus.TOOL_CALL_FAILED ||
344
- status === ToolCallStatus.TOOL_CALL_SKIPPED
345
- );
346
- }
347
-
348
- /**
349
- * Extract sub-agent name from task tool args, handling both the
350
- * legacy string format (`"generalPurpose"`) and the current SDK
351
- * object format (`{ kind: "generalPurpose", name?: "..." }`).
352
- *
353
- * Falls back to `description` (always populated by the SDK) before
354
- * returning the generic `"task"`. The `kind` value `"unspecified"`
355
- * is treated as absent since the Cursor SDK uses it as a default
356
- * when the sub-agent type is not specified in the blueprint.
357
- */
358
- function extractSubagentName(args: unknown): string {
359
- if (args == null || typeof args !== "object") return "task";
360
- const obj = args as Record<string, unknown>;
361
-
362
- const subagentType = obj.subagentType ?? obj.subagent_type;
363
- if (typeof subagentType === "string" && subagentType) return subagentType;
364
- if (subagentType != null && typeof subagentType === "object") {
365
- const typed = subagentType as Record<string, unknown>;
366
- if (typeof typed.name === "string" && typed.name) return typed.name;
367
- if (typeof typed.kind === "string" && typed.kind && typed.kind !== "unspecified") {
368
- return typed.kind;
369
- }
370
- }
371
-
372
- if (typeof obj.description === "string" && obj.description) return obj.description;
373
-
374
- return "task";
375
- }
376
-
377
- function safeString(obj: unknown, key: string): string {
378
- if (obj != null && typeof obj === "object" && key in obj) {
379
- const val = (obj as Record<string, unknown>)[key];
380
- return typeof val === "string" ? val : "";
381
- }
382
- return "";
383
- }
384
-
385
- /**
386
- * Normalize a tool_call event result into a string for the ToolCall proto.
387
- * Returns "" for an absent result so callers can treat "no result yet" and
388
- * "empty result" uniformly (e.g. to avoid clobbering a captured result).
389
- *
390
- * The one non-passthrough case is a multimodal MCP result (e.g. a computer-use
391
- * screenshot): see {@link canonicalizeImageResult}. Everything else is the
392
- * string as-is, or a whole-value JSON.stringify — byte-identical to before.
393
- */
394
- export function toResultString(result: unknown): string {
395
- if (result == null) return "";
396
- if (typeof result === "string") return result;
397
- const canonical = canonicalizeImageResult(result);
398
- if (canonical !== undefined) return canonical;
399
- return JSON.stringify(result);
400
- }
401
-
402
- /**
403
- * Re-emit a Cursor MCP result that carries an image block as the canonical
404
- * top-level content-block array the persist-time offload understands.
405
- *
406
- * The Cursor SDK wraps an MCP tool result as `{ status, value: { content: [...] } }`
407
- * (or a bare `{ content: [...] }`), where an image block is
408
- * `{ image: { data, mimeType } }` and `data` is a Node Buffer-JSON
409
- * (`{ type:"Buffer", data:number[] }`). Persisting that envelope verbatim buries
410
- * the image where `detectImagePayload`/`contentBlocks` (shared/status-offload.ts)
411
- * cannot see it, so the screenshot lands as `text/plain` instead of a renderable
412
- * `ToolCallOutputRef`.
413
- *
414
- * This mirrors `serializeToolContent` in the deep-agent path
415
- * (execute-deep-agent/status-builder-shared.ts): the harness adapter normalizes
416
- * its own wire shape into the canonical array
417
- * `[{ type:"text", text }, { type:"image", data:<base64>, mimeType }]`
418
- * so the shared offload stays harness-agnostic (its envelope handling is
419
- * documented there as insurance, not the primary path). Buffer-JSON is decoded
420
- * to base64 here so the bloated byte-array never propagates into the status.
421
- *
422
- * Returns undefined when there is no content array or no image block, so the
423
- * caller falls back to its existing serialization (no change for text/error
424
- * results).
425
- */
426
- export function canonicalizeImageResult(result: unknown): string | undefined {
427
- if (result == null || typeof result !== "object") return undefined;
428
- const blocks = resultContentBlocks(result as Record<string, unknown>);
429
- if (!blocks) return undefined;
430
-
431
- const canonical: Array<Record<string, unknown>> = [];
432
- let sawImage = false;
433
- for (const block of blocks) {
434
- if (!block || typeof block !== "object") continue;
435
- const b = block as Record<string, unknown>;
436
-
437
- if (b.image && typeof b.image === "object") {
438
- const img = b.image as Record<string, unknown>;
439
- const base64 = imageDataToBase64(img.data);
440
- if (base64) {
441
- const mimeType = typeof img.mimeType === "string" ? img.mimeType : "image/png";
442
- canonical.push({ type: "image", data: base64, mimeType });
443
- sawImage = true;
444
- continue;
445
- }
446
- }
447
-
448
- const text = blockText(b);
449
- if (text !== undefined) canonical.push({ type: "text", text });
450
- }
451
-
452
- return sawImage ? JSON.stringify(canonical) : undefined;
453
- }
454
-
455
- /** Extract the content-block array from a Cursor result envelope or a bare one. */
456
- function resultContentBlocks(obj: Record<string, unknown>): unknown[] | undefined {
457
- const value = obj.value;
458
- if (value && typeof value === "object" && Array.isArray((value as Record<string, unknown>).content)) {
459
- return (value as Record<string, unknown>).content as unknown[];
460
- }
461
- if (Array.isArray(obj.content)) return obj.content;
462
- return undefined;
463
- }
464
-
465
- /**
466
- * Decode an image block's `data` to plain base64. Accepts a Node Buffer-JSON
467
- * (`{ type:"Buffer", data:number[] }`, how the Cursor SDK serializes bytes), a
468
- * `data:` URL, or an already-base64 string. Returns undefined for anything else.
469
- */
470
- function imageDataToBase64(data: unknown): string | undefined {
471
- if (data && typeof data === "object") {
472
- const d = data as Record<string, unknown>;
473
- if (d.type === "Buffer" && Array.isArray(d.data)) {
474
- try {
475
- return Buffer.from(d.data as number[]).toString("base64");
476
- } catch {
477
- return undefined;
478
- }
479
- }
480
- return undefined;
481
- }
482
- if (typeof data === "string" && data) {
483
- const dataUrl = data.match(/^data:image\/[a-zA-Z0-9.+-]+;base64,([\s\S]+)$/);
484
- return (dataUrl ? dataUrl[1] : data).replace(/\s+/g, "");
485
- }
486
- return undefined;
487
- }
488
-
489
- /** Extract the text from a Cursor content block: `{ text:{text} }` or `{ text }`. */
490
- function blockText(b: Record<string, unknown>): string | undefined {
491
- const t = b.text;
492
- if (typeof t === "string") return t;
493
- if (t && typeof t === "object" && typeof (t as Record<string, unknown>).text === "string") {
494
- return (t as Record<string, unknown>).text as string;
495
- }
496
- return undefined;
497
- }
498
-
499
- /**
500
- * Parse the task tool's completed result into AgentMessages.
501
- *
502
- * The Cursor SDK returns sub-agent work as a blob in the task tool's
503
- * completed event (not as streaming events with a distinct agent_id).
504
- * Re-verified 2026-08-23 on the pinned SDK (1.0.13) AND the latest (1.0.28),
505
- * this time including the write side (stigmer/stigmer#839): an injected
506
- * platform.eventStore/eventNotifier receives parent events only on 1.0.13 and
507
- * is bypassed entirely on 1.0.28 (persistence moved into the executor daemon's
508
- * own state dir); zero events reach the parent's run.stream() mid-task; the
509
- * agentId in the task args is not a platform-store agent id at all, so it can
510
- * never be addressable via Agent.listRuns / Agent.getRun (this is WHY the
511
- * 2026-07-02 read-side polls all returned not-found). On 1.0.28 the child
512
- * transcript lands at a deterministic path knowable at spawn
513
- * (~/.cursor/projects/<slug>/agent-transcripts/<parentId>/subagents/<argsAgentId>.jsonl)
514
- * but is flushed only at completion — verified with 200ms sampling across a
515
- * full sub-agent run. Live nested visibility is therefore still an upstream
516
- * SDK limitation — do not try to fake it here; the UI shows an elapsed-time
517
- * affordance instead (SubAgentSection). Probe scripts and recordings:
518
- * stigmer-cloud _projects/2026-08/20260823.03.cursor-subagent-live-progress.
519
- * The result shape is:
520
- *
521
- * { status: "success", value: { conversationSteps: ConversationStep[] } }
522
- *
523
- * where each ConversationStep is a protobuf-oneof object keyed DIRECTLY by its
524
- * kind (there is NO `{ type, message }` envelope — verified against production
525
- * sub-agent blobs; see the `buildSubAgentToolCall` note):
526
- * - { thinkingMessage: { text, thinkingDurationMs? } }
527
- * - { assistantMessage: { text } }
528
- * - { toolCall: { toolCallId, <kind>ToolCall: { args, result } } }
529
- *
530
- * This function defensively parses whatever steps are present and
531
- * appends corresponding AgentMessage protos to the output array.
532
- * Unknown step types are silently skipped for forward compatibility.
533
- */
534
- export function extractConversationSteps(
535
- result: unknown,
536
- out: AgentMessage[],
537
- ): void {
538
- if (result == null || typeof result !== "object") return;
539
- const r = result as Record<string, unknown>;
540
-
541
- const value = r.value ?? r;
542
- if (value == null || typeof value !== "object") return;
543
- const v = value as Record<string, unknown>;
544
-
545
- const steps = v.conversationSteps;
546
- if (!Array.isArray(steps)) return;
547
-
548
- for (const step of steps) {
549
- if (step == null || typeof step !== "object") continue;
550
- const s = step as Record<string, unknown>;
551
- const type = s.type as string | undefined;
552
-
553
- if (type === "thinkingMessage" || s.thinkingMessage != null) {
554
- const msg = (type === "thinkingMessage" ? s.message : s.thinkingMessage) as Record<string, unknown> | undefined;
555
- const text = typeof msg?.text === "string" ? msg.text : "";
556
- if (text) {
557
- out.push(create(AgentMessageSchema, {
558
- type: MessageType.MESSAGE_THINKING,
559
- content: text,
560
- timestamp: utcTimestamp(),
561
- }));
562
- }
563
- } else if (type === "assistantMessage" || s.assistantMessage != null) {
564
- const msg = (type === "assistantMessage" ? s.message : s.assistantMessage) as Record<string, unknown> | undefined;
565
- const text = typeof msg?.text === "string" ? msg.text : "";
566
- if (text) {
567
- out.push(create(AgentMessageSchema, {
568
- type: MessageType.MESSAGE_AI,
569
- content: text,
570
- timestamp: utcTimestamp(),
571
- }));
572
- }
573
- } else if (s.toolCall != null) {
574
- const tc = buildSubAgentToolCall(s.toolCall, out.length);
575
- if (tc) {
576
- out.push(create(AgentMessageSchema, {
577
- type: MessageType.MESSAGE_AI,
578
- content: "",
579
- timestamp: utcTimestamp(),
580
- toolCalls: [tc],
581
- }));
582
- }
583
- }
584
- }
585
- }
586
-
587
- /**
588
- * The failure branches of a sub-agent tool call's `result` oneof. A completion
589
- * is `{ success: ... }`; every other branch is a non-completion the UI must show
590
- * as failed — an errored read/glob/grep (`error`), or a shell the approval gate
591
- * stopped (`permissionDenied` / `rejected`).
592
- */
593
- const SUBAGENT_TOOL_RESULT_FAILURE_KEYS = new Set([
594
- "error",
595
- "permissionDenied",
596
- "rejected",
597
- ]);
598
-
599
- /**
600
- * Build a ToolCall proto from one sub-agent `toolCall` conversation step.
601
- *
602
- * The Cursor SDK serializes a sub-agent's tool call as protobuf-oneof JSON:
603
- *
604
- * { toolCallId, <kind>ToolCall: { args, result } }
605
- *
606
- * The tool family is the lone `<kind>ToolCall` sibling of `toolCallId` (e.g.
607
- * `readToolCall`, `globToolCall`, `grepToolCall`, `shellToolCall`); the bare
608
- * tool name (`read`) is the suffix-stripped key, which feeds the shared
609
- * {@link classifyTool} exactly like a top-level call. `result` is itself a oneof
610
- * `{ success | error | permissionDenied | rejected }` (see
611
- * {@link interpretSubAgentToolResult}).
612
- *
613
- * This is deliberately key-driven rather than an enumerated switch, so a new
614
- * tool family the SDK adds surfaces automatically instead of being dropped.
615
- * Returns undefined when no `<kind>ToolCall` key is present (a malformed or
616
- * forward-incompatible step), so the caller skips it rather than emitting a
617
- * blank, nameless tool call.
618
- *
619
- * History: an earlier revision parsed a `{ type: "toolCall", message: { type,
620
- * args, result: { status, value } } }` envelope. That shape never appears in the
621
- * real task-result blob (confirmed against production sub-agent outputs and the
622
- * WA03 capture), so every sub-agent tool call was silently discarded and the UI
623
- * showed a sub-agent that "did nothing".
624
- */
625
- function buildSubAgentToolCall(
626
- toolCall: unknown,
627
- seq: number,
628
- ): ToolCall | undefined {
629
- if (toolCall == null || typeof toolCall !== "object") return undefined;
630
- const obj = toolCall as Record<string, unknown>;
631
-
632
- const kindKey = Object.keys(obj).find(
633
- (k) => k !== "toolCallId" && k.endsWith("ToolCall"),
634
- );
635
- if (!kindKey) return undefined;
636
-
637
- const name = kindKey.slice(0, -"ToolCall".length);
638
- const inner =
639
- obj[kindKey] != null && typeof obj[kindKey] === "object"
640
- ? (obj[kindKey] as Record<string, unknown>)
641
- : {};
642
- // Prefer the SDK's real call id so the row is stable across resumes and never
643
- // collides with a sibling; fall back to a per-step synthetic id only when the
644
- // SDK omits one.
645
- const id =
646
- typeof obj.toolCallId === "string" && obj.toolCallId
647
- ? obj.toolCallId
648
- : `sub-${name}-${seq}`;
649
-
650
- const { status, result, error } = interpretSubAgentToolResult(inner.result);
651
-
652
- const tc = create(ToolCallSchema, {
653
- id,
654
- name,
655
- status,
656
- result,
657
- error,
658
- startedAt: utcTimestamp(),
659
- completedAt: utcTimestamp(),
660
- toolKind: classifyTool(name),
661
- });
662
-
663
- if (inner.args != null && typeof inner.args === "object") {
664
- tc.args = inner.args as JsonObject;
665
- tc.argsPreview = JSON.stringify(inner.args);
666
- }
667
- return tc;
668
- }
669
-
670
- /**
671
- * Map a sub-agent tool call's `result` oneof to a (status, result, error)
672
- * triple. `success` → COMPLETED with the serialized payload (a screenshot is
673
- * canonicalized the same way as a top-level result); any failure branch (see
674
- * {@link SUBAGENT_TOOL_RESULT_FAILURE_KEYS}) → FAILED with the serialized
675
- * detail. An absent result is a COMPLETED call with no output — the SDK omits
676
- * `result` for a call that reports nothing.
677
- */
678
- function interpretSubAgentToolResult(result: unknown): {
679
- status: ToolCallStatus;
680
- result: string;
681
- error: string;
682
- } {
683
- if (result == null || typeof result !== "object") {
684
- return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "", error: "" };
685
- }
686
- const r = result as Record<string, unknown>;
687
-
688
- if ("success" in r) {
689
- const val = r.success;
690
- const str =
691
- canonicalizeImageResult(val) ??
692
- (typeof val === "string" ? val : JSON.stringify(val));
693
- return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: str, error: "" };
694
- }
695
-
696
- const failKey = Object.keys(r).find((k) =>
697
- SUBAGENT_TOOL_RESULT_FAILURE_KEYS.has(k),
698
- );
699
- if (failKey) {
700
- const val = r[failKey];
701
- const detail = typeof val === "string" ? val : JSON.stringify(val);
702
- return { status: ToolCallStatus.TOOL_CALL_FAILED, result: "", error: detail };
703
- }
704
-
705
- // Unknown oneof branch — surface it as a completed result rather than drop it.
706
- return {
707
- status: ToolCallStatus.TOOL_CALL_COMPLETED,
708
- result: JSON.stringify(r),
709
- error: "",
710
- };
711
- }
712
-
713
- /**
714
- * Options for creating a MessageAccumulator with policy awareness.
715
- */
716
- export interface MessageAccumulatorOptions {
717
- mergedPolicies?: ReadonlyMap<string, MergedToolPolicy>;
718
- /**
719
- * Run-scoped approval context (global bypass + active leases) so reconstructed
720
- * tool calls carry their authorization provenance. Omitted in unit tests that
721
- * only assert basic translation; provenance then stays UNSPECIFIED.
722
- */
723
- provenance?: ApprovalProvenanceContext;
724
- /**
725
- * Absolute workspace root, used to render file-change paths relative to the
726
- * workspace (with the absolute path retained). Omitted in unit tests, in
727
- * which case raw tool-arg paths are used verbatim.
728
- */
729
- workspaceRoot?: string;
730
- /**
731
- * The status's own `subAgentExecutions` array, wrapped BY REFERENCE exactly
732
- * as `messages` is: the accumulator upserts into it in place, so the rows
733
- * the runtime seeded on a resume (`harness/turn-context.ts`
734
- * `seedFromPersistedStatus`) are indexed by id at construction and a
735
- * resumed sub-agent's lifecycle updates merge onto its seeded row instead
736
- * of producing a duplicate. Omitted in unit tests that only assert basic
737
- * translation; the accumulator then owns a private array.
738
- */
739
- subAgentExecutions?: SubAgentExecution[];
740
- }
741
-
742
- /**
743
- * Stateful accumulator that merges per-token SDK events into coherent
744
- * AgentMessages.
745
- *
746
- * The Cursor SDK emits one `assistant` event per token chunk (validated:
747
- * a 2-sentence response produces ~41 events). Without accumulation each
748
- * chunk becomes a separate AgentMessage, causing the UI to render each
749
- * word on its own line.
750
- *
751
- * MessageAccumulator tracks the active AI and thinking messages per
752
- * run_id. Consecutive assistant events for the same run_id append to
753
- * the existing message's content instead of creating new ones.
754
- *
755
- * Tool calls are attached to the most recent MESSAGE_AI message's
756
- * toolCalls array — matching the Python agent-runner's StatusBuilder
757
- * pattern and the UI's MessageThread expectations.
758
- *
759
- * Tool call lifecycle is tracked via a `toolCallIndex` map (keyed by
760
- * call_id), mirroring the native harness's `ExecutionState.toolCalls`.
761
- * This ensures completion events always find the correct ToolCall proto
762
- * regardless of which AI message it was originally attached to — the
763
- * index stores the same object reference that lives in the message's
764
- * `toolCalls[]` array, so mutations propagate directly to the proto.
765
- *
766
- * Task (sub-agent) tool calls additionally produce SubAgentExecution
767
- * protos, accessible via the subAgentExecutions getter.
768
- */
769
-
770
- export class MessageAccumulator {
771
- private readonly messages: AgentMessage[];
772
- private activeAiByRunId = new Map<string, AgentMessage>();
773
- private activeThinkingByRunId = new Map<string, AgentMessage>();
774
- private readonly _subAgentExecutions: SubAgentExecution[];
775
- private readonly subAgentMap = new Map<string, SubAgentExecution>();
776
- private readonly mergedPolicies?: ReadonlyMap<string, MergedToolPolicy>;
777
- private readonly provenance?: ApprovalProvenanceContext;
778
- private readonly workspaceRoot?: string;
779
- private readonly toolCallIndex = new Map<string, ToolCall>();
780
- private _dirty = false;
781
-
782
- constructor(messages: AgentMessage[], options?: MessageAccumulatorOptions) {
783
- this.messages = messages;
784
- this._subAgentExecutions = options?.subAgentExecutions ?? [];
785
- this.mergedPolicies = options?.mergedPolicies;
786
- this.provenance = options?.provenance;
787
- this.workspaceRoot = options?.workspaceRoot;
788
-
789
- // Resume seeding. When constructed over a pre-seeded status (the runtime's
790
- // `seedFromPersistedStatus` on a resume), rebuild the by-id indexes so a
791
- // cross-message completion for a seeded call resolves onto the existing
792
- // proto and a resumed sub-agent's lifecycle updates merge onto its seeded
793
- // row. A first run carries an empty status, so both are no-ops.
794
- this.rebuildToolCallIndex();
795
- this.rebuildSubAgentIndex();
796
- }
797
-
798
- /** Index every sub-agent row already present in the (seeded) array by its id, so upserts land on it. */
799
- private rebuildSubAgentIndex(): void {
800
- for (const sub of this._subAgentExecutions) {
801
- if (sub.id) this.subAgentMap.set(sub.id, sub);
802
- }
803
- }
804
-
805
- /**
806
- * Index every tool call already present in the (seeded) transcript by its
807
- * call_id. Called once at construction: on a first run the transcript is empty
808
- * (no-op); on a resume it lets re-emitted lifecycle events for a previously
809
- * committed call_id reconcile onto the existing proto instead of duplicating.
810
- */
811
- private rebuildToolCallIndex(): void {
812
- this.toolCallIndex.clear();
813
- for (const message of this.messages) {
814
- for (const tc of message.toolCalls) {
815
- if (tc.id) this.toolCallIndex.set(tc.id, tc);
816
- }
817
- }
818
- }
819
-
820
- get subAgentExecutions(): SubAgentExecution[] {
821
- return this._subAgentExecutions;
822
- }
823
-
824
- /**
825
- * True when a discrete, user-visible state change has accumulated since the
826
- * last markPersisted(): a tool call created or transitioned to a terminal
827
- * status, or a sub-agent execution created or updated. The streaming loop
828
- * treats this as a force-flush signal so the live UI surfaces a tool call the
829
- * instant it starts and completes, and a sub-agent's IN_PROGRESS state the
830
- * instant delegation begins — instead of waiting for the scheduler's time
831
- * cadence or the parent finalizing.
832
- *
833
- * High-frequency token deltas (assistant text, model thinking) deliberately do
834
- * NOT set this flag; they ride the StreamingUpdateScheduler's time cadence so
835
- * we avoid a per-token persist storm — matching the native harness, which only
836
- * force-flushes on discrete tool start/end events.
837
- */
838
- get isDirty(): boolean {
839
- return this._dirty;
840
- }
841
-
842
- /** Clears the dirty flag after the latest status has been persisted. */
843
- markPersisted(): void {
844
- this._dirty = false;
845
- }
846
-
847
- /**
848
- * Transition any non-terminal sub-agent (IN_PROGRESS or PENDING) to CANCELLED.
849
- *
850
- * Called when the parent run is aborted (pause / cancel / worker shutdown):
851
- * the Cursor SDK run stops, so a delegated sub-agent is no longer executing.
852
- * Without this, the final snapshot would show a permanent "Running" zombie
853
- * sub-agent. Mirrors the native harness's cancelSubAgents().
854
- */
855
- cancelInProgressSubAgents(): void {
856
- if (cancelInProgressSubAgentProtos(this._subAgentExecutions)) {
857
- this._dirty = true;
858
- }
859
- }
860
-
861
- processEvent(event: SDKMessage): void {
862
- switch (event.type) {
863
- case "assistant":
864
- this.accumulateAssistant(event);
865
- break;
866
- case "thinking":
867
- this.accumulateThinking(event);
868
- break;
869
- case "tool_call":
870
- this.finalizeStreaming(event.run_id);
871
- this.attachToolCallToLastAi(event);
872
- break;
873
- case "task":
874
- if (event.text) {
875
- this.messages.push(translateTask(event));
876
- }
877
- break;
878
- }
879
- }
880
-
881
- finalize(): void {
882
- for (const msg of this.activeAiByRunId.values()) {
883
- msg.isStreaming = false;
884
- }
885
- for (const msg of this.activeThinkingByRunId.values()) {
886
- msg.isStreaming = false;
887
- }
888
- this.activeAiByRunId.clear();
889
- this.activeThinkingByRunId.clear();
890
- }
891
-
892
- /**
893
- * Attach a tool call to the current AI message, upserting by `call_id` so a
894
- * single call maps to at most ONE ToolCall across all messages.
895
- *
896
- * The Cursor SDK can emit the lifecycle for one `call_id` more than once —
897
- * observed in production as two "running" events ~0.5s apart for task/edit
898
- * tools, which previously appended a duplicate ToolCall (the same call
899
- * rendered two or three times in the UI). We therefore index by `call_id`
900
- * and merge subsequent events into the existing proto, mirroring how
901
- * trackSubAgentExecution() upserts via subAgentMap. The first event for a
902
- * `call_id` (running or terminal) creates the proto on the last AI message;
903
- * the index keeps pointing at it even after later assistant text starts a
904
- * new AI message, so cross-message completions still land on the original.
905
- */
906
- private attachToolCallToLastAi(
907
- event: Extract<SDKMessage, { type: "tool_call" }>,
908
- ): void {
909
- if (SUPPRESSED_TOOL_NAMES.has(event.name)) return;
910
-
911
- const existing = this.toolCallIndex.get(event.call_id);
912
- if (existing) {
913
- this.mergeToolCallEvent(existing, event);
914
- return;
915
- }
916
-
917
- const tc = buildToolCallProto(event, this.mergedPolicies, this.provenance);
918
-
919
- // Resume reconciliation. A resumed Cursor agent re-runs a previously
920
- // approved tool with a BRAND-NEW call_id, so it misses the by-id index
921
- // above. Reconcile it onto the seeded WAITING_APPROVAL call with the same
922
- // canonical identity (the (category, salient)/MCP-name space the hook and
923
- // grants already use — see toolCallIdentityToken) and keep the original id.
924
- // Without this the seeded approved call and the re-run would both appear (a
925
- // duplicate row) and dropping the seeded id would trip the backend's
926
- // append-only-at-identity guard, stalling the run. This generalizes what
927
- // the native builder does by exact tool_call_id (`V3StatusBuilder`'s resume
928
- // reconciliation; its v2 predecessor matched by tool name) to the full
929
- // Cursor identity, reusing the single existing identity definition rather
930
- // than introducing a parallel one.
931
- const seeded = this.findResumableSeededToolCall(tc);
932
- if (seeded) {
933
- // Re-key the fresh call_id onto the seeded proto so this call_id's later
934
- // lifecycle events resolve here, then merge in place. mergeToolCallEvent
935
- // advances WAITING_APPROVAL (non-terminal) toward the event's status.
936
- this.toolCallIndex.set(event.call_id, seeded);
937
- this.mergeToolCallEvent(seeded, event);
938
- return;
939
- }
940
-
941
- this.findOrCreateLastAiMessage().toolCalls.push(tc);
942
- this.toolCallIndex.set(event.call_id, tc);
943
- // A new tool call is a discrete, user-visible event — force a prompt
944
- // flush so the live UI surfaces it the instant it starts.
945
- this._dirty = true;
946
- }
947
-
948
- /**
949
- * Find a seeded, still-gated tool call this resumed event should reconcile
950
- * onto: the first tool call in the index that is still WAITING_APPROVAL and
951
- * shares the candidate's canonical identity token. "First" (Map iteration =
952
- * transcript order) mirrors the v2 deep-agent's ordered first-unreconciled
953
- * match — once reconciled a call leaves WAITING_APPROVAL, so a second co-
954
- * pending call with the same identity naturally reconciles onto the next one.
955
- * Tool calls created during this turn are not WAITING_APPROVAL until the
956
- * post-stream denial reconciliation runs, so they can never be matched here.
957
- *
958
- * Only a row whose decision EXECUTES is resumable: the re-issue after an
959
- * APPROVE is the approved act itself, landing on its row. A row the user
960
- * declined (SKIP / REJECT) is a closed gate — a later same-identity call is a
961
- * NEW act the model was told not to perform, and it gets its own row so the
962
- * hook's denial gates it afresh instead of landing on the declined row and
963
- * inheriting its decision (S2 M4 finding F9, the accumulator's half; the
964
- * denial overlay's half is {@link isAdjudicatedRow}).
965
- */
966
- private findResumableSeededToolCall(candidate: ToolCall): ToolCall | undefined {
967
- const wanted = toolCallIdentityToken(candidate);
968
- for (const tc of this.toolCallIndex.values()) {
969
- if (
970
- tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL &&
971
- !isDeclinedRow(tc) &&
972
- toolCallIdentityToken(tc) === wanted
973
- ) {
974
- return tc;
975
- }
976
- }
977
- return undefined;
978
- }
979
-
980
- /**
981
- * Merge a repeated tool_call event into the ToolCall already tracked for this
982
- * `call_id`. The merge is defensive because a re-emitted event may carry less
983
- * information than an earlier one (a late "running" after "completed", or a
984
- * completion with an empty result): status only advances toward terminal,
985
- * timestamps are stamped once, and a populated result/args is never clobbered
986
- * by an empty one.
987
- */
988
- private mergeToolCallEvent(
989
- existing: ToolCall,
990
- event: Extract<SDKMessage, { type: "tool_call" }>,
991
- ): void {
992
- const status = mapToolCallStatus(event.status);
993
- const wasTerminal = isTerminalToolStatus(existing.status);
994
-
995
- // Status advances monotonically: once terminal (completed/failed/skipped)
996
- // a later "running" re-emit must not regress it back to RUNNING.
997
- if (!isTerminalToolStatus(existing.status)) {
998
- existing.status = status;
999
- }
1000
- if (isTerminalToolStatus(status) && !existing.completedAt) {
1001
- existing.completedAt = utcTimestamp();
1002
- }
1003
-
1004
- // The running -> terminal transition is the user-visible "tool finished"
1005
- // moment — force a prompt flush so the result appears live rather than at
1006
- // the next scheduler tick. Repeated terminal re-emits (already terminal) do
1007
- // not re-flag: that would be noise, not a state change.
1008
- if (!wasTerminal && isTerminalToolStatus(status)) {
1009
- this._dirty = true;
1010
- }
1011
- if (!existing.startedAt && status === ToolCallStatus.TOOL_CALL_RUNNING) {
1012
- existing.startedAt = utcTimestamp();
1013
- }
1014
-
1015
- // Only a non-empty incoming result overwrites; a result-less "running"
1016
- // re-emit must not wipe a result captured on completion (or vice versa).
1017
- const incomingResult = toResultString(event.result);
1018
- if (incomingResult) {
1019
- existing.result = incomingResult;
1020
- }
1021
-
1022
- if (status === ToolCallStatus.TOOL_CALL_FAILED) {
1023
- if (!existing.error) {
1024
- existing.error = typeof event.result === "string"
1025
- ? event.result
1026
- : "Tool call failed";
1027
- }
1028
- if (existing.requiresApproval && !existing.approvalRequestedAt) {
1029
- existing.approvalRequestedAt = utcTimestamp();
1030
- }
1031
- }
1032
-
1033
- if (event.args != null && !existing.argsPreview) {
1034
- existing.argsPreview = typeof event.args === "string"
1035
- ? event.args
1036
- : JSON.stringify(event.args);
1037
- }
1038
- }
1039
-
1040
- private findOrCreateLastAiMessage(): AgentMessage {
1041
- for (let i = this.messages.length - 1; i >= 0; i--) {
1042
- if (this.messages[i].type === MessageType.MESSAGE_AI) {
1043
- return this.messages[i];
1044
- }
1045
- }
1046
- const msg = create(AgentMessageSchema, {
1047
- type: MessageType.MESSAGE_AI,
1048
- content: "",
1049
- timestamp: utcTimestamp(),
1050
- });
1051
- this.messages.push(msg);
1052
- return msg;
1053
- }
1054
-
1055
- trackSubAgentExecution(
1056
- event: Extract<SDKMessage, { type: "tool_call" }>,
1057
- ): SubAgentExecution | undefined {
1058
- const existing = this.subAgentMap.get(event.call_id);
1059
-
1060
- if (existing) {
1061
- existing.status = mapSubAgentStatus(event.status);
1062
- if (event.status === "completed" || event.status === "error") {
1063
- existing.completedAt = utcTimestamp();
1064
- }
1065
- if (event.status === "completed" && event.result != null) {
1066
- existing.output = typeof event.result === "string"
1067
- ? event.result
1068
- : JSON.stringify(event.result);
1069
- extractConversationSteps(event.result, existing.messages);
1070
- }
1071
- if (event.status === "error") {
1072
- existing.error = typeof event.result === "string"
1073
- ? event.result
1074
- : "Sub-agent failed";
1075
- }
1076
- this._dirty = true;
1077
- return existing;
1078
- }
1079
-
1080
- const sub = create(SubAgentExecutionSchema, {
1081
- id: event.call_id,
1082
- name: extractSubagentName(event.args),
1083
- subject: safeString(event.args, "description"),
1084
- input: safeString(event.args, "prompt"),
1085
- status: mapSubAgentStatus(event.status),
1086
- startedAt: utcTimestamp(),
1087
- });
1088
- this._subAgentExecutions.push(sub);
1089
- this.subAgentMap.set(event.call_id, sub);
1090
- this._dirty = true;
1091
- return sub;
1092
- }
1093
-
1094
- private accumulateAssistant(
1095
- event: Extract<SDKMessage, { type: "assistant" }>,
1096
- ): void {
1097
- const text = event.message.content
1098
- .filter((b): b is { type: "text"; text: string } => b.type === "text")
1099
- .map((b) => b.text)
1100
- .join("");
1101
-
1102
- if (!text) return;
1103
-
1104
- const existing = this.activeAiByRunId.get(event.run_id);
1105
- if (existing) {
1106
- existing.content += text;
1107
- return;
1108
- }
1109
-
1110
- const msg = create(AgentMessageSchema, {
1111
- type: MessageType.MESSAGE_AI,
1112
- content: text,
1113
- timestamp: utcTimestamp(),
1114
- isStreaming: true,
1115
- });
1116
- this.messages.push(msg);
1117
- this.activeAiByRunId.set(event.run_id, msg);
1118
- }
1119
-
1120
- private accumulateThinking(
1121
- event: Extract<SDKMessage, { type: "thinking" }>,
1122
- ): void {
1123
- if (!event.text) return;
1124
-
1125
- const existing = this.activeThinkingByRunId.get(event.run_id);
1126
- if (existing) {
1127
- existing.content += event.text;
1128
- return;
1129
- }
1130
-
1131
- const msg = create(AgentMessageSchema, {
1132
- type: MessageType.MESSAGE_THINKING,
1133
- content: event.text,
1134
- timestamp: utcTimestamp(),
1135
- isStreaming: true,
1136
- });
1137
- this.messages.push(msg);
1138
- this.activeThinkingByRunId.set(event.run_id, msg);
1139
- }
1140
-
1141
- private finalizeStreaming(runId: string): void {
1142
- const ai = this.activeAiByRunId.get(runId);
1143
- if (ai) {
1144
- ai.isStreaming = false;
1145
- this.activeAiByRunId.delete(runId);
1146
- }
1147
- const thinking = this.activeThinkingByRunId.get(runId);
1148
- if (thinking) {
1149
- thinking.isStreaming = false;
1150
- this.activeThinkingByRunId.delete(runId);
1151
- }
1152
- }
1153
- }
1154
-
1155
- /**
1156
- * Reconcile the denial ledger written by the preToolUse hook against the tool
1157
- * calls accumulated from the stream, marking each denied call as
1158
- * WAITING_APPROVAL.
1159
- *
1160
- * This is the cursor analog of the native harness synthesizing WAITING_APPROVAL
1161
- * tool calls from LangGraph interrupts (execute-deep-agent/turn-settle.ts). The hook
1162
- * ledger — not the SDK-reported tool status — is the authoritative record of
1163
- * what was gated, because the hook is the only component that makes the per-call
1164
- * allow/deny decision. The backend then projects pending_approvals from these
1165
- * WAITING_APPROVAL tool calls (PendingApprovalComputer), so the approval surface
1166
- * is driven entirely by tool-call status, exactly like the native harness.
1167
- *
1168
- * Correlation is by tool identity token (the same space as approvedGrantTokens),
1169
- * not by call id: a denied tool's identity is stable, and a single resource
1170
- * approved once should produce one approval regardless of how many times the
1171
- * agent re-attempted it within the turn.
1172
- *
1173
- * ONE GATE PER TURN (deny-only clean pause). The Cursor harness can only gate by
1174
- * the hook returning `deny`, which Cursor surfaces to the model as a tool
1175
- * *failure* — so a blocked model frequently improvises a workaround (the
1176
- * canonical case: a denied `edit notes.md` followed ~2.5s later by a
1177
- * `shell: cat > notes.md`, in the SAME assistant message with no narration
1178
- * between them — observed in production, exec aex_01kw4p0cqgk0j8vvxbs5t8gv59).
1179
- * The first-denial stop (index.ts) tries to cancel the turn at that first
1180
- * denial, but `run.cancel()` is async and races the SDK's auto-execution, so the
1181
- * workaround can still stream and land a SECOND denial in the ledger. Two
1182
- * denials of distinct identity would otherwise surface two approval cards for
1183
- * one logical intent. Their identities differ (`write\nnotes.md` vs
1184
- * `shell\ncat > notes.md`), so no same-identity twin collapse can join them, and
1185
- * they share one message, so no positional rule can separate them; the only
1186
- * honest signal that the second is a reaction is CAUSALITY — it was emitted
1187
- * after the model saw the first denial. We therefore ANCHOR on the FIRST ledger
1188
- * denial of the turn (the ledger is reset per turn and appended in denial order,
1189
- * so ledger[0] is the original intent) and surface ONLY that identity. Every
1190
- * other denied identity in the turn — a post-denial workaround, or a genuine
1191
- * co-pending sibling the deny-only harness defers — is blanked in place to a
1192
- * hidden SKIPPED row ({@link collapseNonAnchorDenials}). A deferred sibling is
1193
- * not lost: on resume it re-attempts and gates again next turn (sequential
1194
- * gating). The native (LangGraph) harness pauses BEFORE the model can react, so
1195
- * it keeps full in-turn co-pending and is untouched by this rule. This is the
1196
- * near-term, invariant-preserving stepping stone to the Tool Execution Gateway,
1197
- * where an un-leased workaround is refused by construction.
1198
- *
1199
- * Correlation runs in two passes. The first matches the streamed token to a
1200
- * ledger token byte-for-byte (the common case). The hook, however, records its
1201
- * token from the RAW path Cursor hands it — a bash script cannot normalize a
1202
- * path against the workspace root — so an ABSOLUTE hook `file_path` against a
1203
- * RELATIVE stream `path` (or vice versa) yields two different raw tokens for one
1204
- * edit and the exact pass misses. The runner CAN normalize, so a second pass
1205
- * matches any still-unmatched FILE denial to a streamed call by (category,
1206
- * workspace-normalized path) and overlays the REAL streamed call, never appending
1207
- * a content-less placeholder beside it. This is the difference between one honest
1208
- * gate and two cards, one of which reads "No preview available". It reuses the
1209
- * single tool-identity definition + `resolveWorkspacePath`; it introduces no
1210
- * parallel identity.
1211
- *
1212
- * Only after BOTH passes miss is a placeholder WAITING_APPROVAL tool call
1213
- * synthesized (rare — Cursor normally emits a tool_call event for every
1214
- * attempt), so the gate still surfaces and never renders as a silent success.
1215
- * Critically, every match overlays a call IN PLACE (the committed id is
1216
- * preserved): the backend's append-only-at-identity transcript guard rejects a
1217
- * finalize that drops a previously-committed tool-call id, so reconciliation may
1218
- * only reconcile entries in place, never remove them.
1219
- *
1220
- * Every matched/synthesized call is enriched with the hook-captured authoritative
1221
- * input (`ledger.input`) via {@link applyGateInput}: the full proposed args, a
1222
- * compact `args_preview`, and the content digest — so the approval card renders
1223
- * the proposed write/edit content from `args` and a resume re-gates a diverging
1224
- * sibling edit. A missing capture (the hook's grep fallback) degrades to the
1225
- * prior behavior.
1226
- *
1227
- * Returns the tool calls now marked WAITING_APPROVAL — the single anchor gate
1228
- * for the turn (overlaid or, rarely, synthesized).
1229
- */
1230
- export async function reconcileDeniedToolCalls(
1231
- messages: AgentMessage[],
1232
- ledger: DeniedLedgerEntry[],
1233
- mergedPolicies?: ReadonlyMap<string, MergedToolPolicy>,
1234
- workspaceBackend?: WorkspaceBackend,
1235
- ): Promise<ToolCall[]> {
1236
- // Defense-in-depth: only APPROVAL-kind denials may become approval gates.
1237
- // The turn boundary already passes the filtered subset; re-filtering here
1238
- // makes it structurally impossible for a secret/capture-error/fail-closed
1239
- // entry to manufacture a pause the user cannot meaningfully grant, no matter
1240
- // what a future caller passes (an absent kind is the pre-kind format and
1241
- // counts as approval).
1242
- ledger = approvalDenials(ledger);
1243
- if (ledger.length === 0) return [];
1244
-
1245
- // The workspace the gated files live in; its rootDir normalizes paths for the
1246
- // abs-vs-rel correlation fallback (normalizedFileSalient).
1247
- const workspaceRoot = workspaceBackend?.rootDir;
1248
-
1249
- // One gate per turn: anchor on the FIRST ledger denial. The ledger is reset
1250
- // per turn and appended in denial order, so ledger[0] is the model's original
1251
- // intent; any later denial of a DIFFERENT identity is a post-denial workaround
1252
- // or a deferred co-pending sibling (see the doc comment). We surface ONLY the
1253
- // anchor identity below and blank every other denied identity to a hidden
1254
- // SKIPPED row. `deniedTokens` still carries every denied identity — it is the
1255
- // scope for that collapse, never an additional gate.
1256
- const anchorToken = ledger[0].token;
1257
- const deniedTokens = new Set(ledger.map((e) => e.token));
1258
- // The authoritative pre-execution args the hook captured for the anchor. A
1259
- // resource re-attempted within the turn shares a token; last write wins (the
1260
- // attempts carry the same proposed change).
1261
- let anchorInput: Record<string, unknown> | undefined;
1262
- for (const e of ledger) {
1263
- if (e.token === anchorToken && e.input) anchorInput = e.input;
1264
- }
1265
- const matchedCalls = new Set<ToolCall>();
1266
- const result: ToolCall[] = [];
1267
- let anchorMatched = false;
1268
-
1269
- // 1. Exact overlay: a streamed call whose token equals the anchor denial token
1270
- // byte-for-byte (the path form agreed on both sides). The anchor resource
1271
- // re-attempted within the turn shares one token and collapses to a single
1272
- // approval (the first match is the keeper; same-identity twins are blanked
1273
- // by collapseRedundantToolCallTwins below). A row an earlier invocation's
1274
- // gate already settled is never the home of THIS turn's denial
1275
- // (isAdjudicatedRow) — the search moves past it to this turn's attempt.
1276
- for (const msg of messages) {
1277
- if (anchorMatched) break;
1278
- for (const tc of msg.toolCalls) {
1279
- if (isAdjudicatedRow(tc)) continue;
1280
- if (toolCallIdentityToken(tc) !== anchorToken) continue;
1281
- overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
1282
- matchedCalls.add(tc);
1283
- result.push(tc);
1284
- anchorMatched = true;
1285
- break;
1286
- }
1287
- }
1288
-
1289
- // 2. Normalized-path fallback (the abs-vs-rel drift fix): if the anchor is a
1290
- // FILE denial the exact pass missed, match a streamed call by (category,
1291
- // workspace-normalized path) and overlay the REAL call — never a content-
1292
- // less placeholder beside it. Requires the workspace root to normalize;
1293
- // shell/MCP denials (no path) and resumes without a root fall through to
1294
- // synthesis.
1295
- if (!anchorMatched && workspaceRoot) {
1296
- const decoded = decodeIdentityToken(anchorToken);
1297
- const wanted = decoded
1298
- ? normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot)
1299
- : undefined;
1300
- if (wanted) {
1301
- const tc = findUnmatchedStreamCallByNormalizedSalient(
1302
- messages, matchedCalls, wanted, workspaceRoot,
1303
- );
1304
- if (tc) {
1305
- overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
1306
- matchedCalls.add(tc);
1307
- result.push(tc);
1308
- anchorMatched = true;
1309
- }
1310
- }
1311
- }
1312
-
1313
- // 2a. One gate per turn: blank every denied identity OTHER than the anchor to a
1314
- // hidden SKIPPED row (the workaround shell, or a deferred co-pending
1315
- // sibling). Runs BEFORE the WAITING_FOR_APPROVAL persist so a reaction is
1316
- // never persisted as WAITING_APPROVAL — the backend authors an approval
1317
- // REQUESTED event only from a WAITING_APPROVAL tool call, so collapsing
1318
- // here keeps the append-only approval-event stream free of an orphan
1319
- // REQUESTED that would need retraction.
1320
- const nonAnchorCollapsed = collapseNonAnchorDenials(messages, deniedTokens, anchorToken);
1321
- if (nonAnchorCollapsed > 0) {
1322
- console.log(
1323
- `ExecuteCursor reconcile collapsed ${nonAnchorCollapsed} non-anchor denied ` +
1324
- `tool call(s) to hidden SKIPPED (one gate per turn; anchor is the first ` +
1325
- `denial of the turn)`,
1326
- );
1327
- }
1328
-
1329
- // 2b. Collapse same-turn duplicate edits. When the model emitted the SAME
1330
- // resource twice in one turn (two call ids, one identity token), only the
1331
- // FIRST same-token stream call was overlaid into the gate above; any OTHER
1332
- // same-token call stays a committed row (RUNNING zombie, or a
1333
- // denied-reported-as-success COMPLETED) that would render as a second,
1334
- // content-less card beside the gate (the reported "No preview available"
1335
- // duplicate). The overlaid gate is now WAITING_APPROVAL, so the shared
1336
- // routine recognizes it as the keeper and blanks the twins IN PLACE to
1337
- // hidden SKIPPED rows — we cannot drop them, since the backend's
1338
- // append-only-at-identity guard rejects removing a previously-committed
1339
- // tool-call id, but the id is preserved so the finalize stays append-only.
1340
- const collapsed = collapseRedundantToolCallTwins(messages);
1341
- if (collapsed > 0) {
1342
- console.log(
1343
- `ExecuteCursor reconcile collapsed ${collapsed} redundant tool-call twin(s) ` +
1344
- `superseded by the approval gate (kept in place as hidden SKIPPED rows)`,
1345
- );
1346
- }
1347
-
1348
- // 2c. Finalize interrupted rows. The first-denial stop cancelled the run, so
1349
- // a tool call still PENDING/RUNNING here can never complete — no event
1350
- // will ever deliver its result, and left alone it persists as a spinner
1351
- // forever. The canonical victim is a post-denial workaround whose own
1352
- // hook denial raced (or never reached) the final ledger read, so the
1353
- // token-scoped collapse in 2a could not see it (production case
1354
- // aex_01kwj07f7g23c3wp9sn8496z5g: a python-write shell reaction persisted
1355
- // as RUNNING with requiresApproval=true). Whatever the cause, a
1356
- // non-terminal row on a turn that is pausing is an interrupted attempt
1357
- // with no output: collapse it to the same hidden SKIPPED shape as every
1358
- // other superseded row (in place — the append-only-at-identity guard
1359
- // forbids dropping a committed id). Runs AFTER the anchor overlay, so the
1360
- // gate itself (now WAITING_APPROVAL) is never touched.
1361
- const interrupted = finalizeInterruptedToolCalls(messages);
1362
- if (interrupted > 0) {
1363
- console.log(
1364
- `ExecuteCursor reconcile collapsed ${interrupted} interrupted non-terminal ` +
1365
- `tool call(s) that can never complete (run cancelled at first denial)`,
1366
- );
1367
- }
1368
-
1369
- // 3. Synthesize the anchor gate if it matched NO streamed call in either pass
1370
- // (rare — Cursor emits a tool_call event for every attempt), so the gate
1371
- // still surfaces rather than rendering as a silent success. After the
1372
- // normalized fallback this should be ~0; the caller logs a divergence when
1373
- // it is not (a synthesized id is prefixed `approval:`). Only the anchor is
1374
- // ever synthesized: non-anchor denials are deliberately collapsed, never
1375
- // surfaced (one gate per turn).
1376
- if (!anchorMatched) {
1377
- const anchorEntry = ledger.find((e) => e.token === anchorToken) ?? ledger[0];
1378
- const decoded = decodeIdentityToken(anchorToken);
1379
- // Display the hook's raw tool name; carry the decoded salient so the grant
1380
- // rebuilt from this tool call on reinvocation keys on the same resource.
1381
- const displayName = anchorEntry.toolName || decoded?.key || "tool";
1382
- const salient = decoded?.salient ?? "";
1383
- const tc = synthesizeWaitingApprovalToolCall(
1384
- displayName, salient, decoded?.digest ?? "", anchorToken, mergedPolicies,
1385
- );
1386
- // The hook-captured input upgrades the placeholder from a bare {path} to the
1387
- // full proposed args, so even a synthesized gate shows the proposed change.
1388
- applyGateInput(tc, anchorInput ?? anchorEntry.input);
1389
- appendToolCallToLastAiMessage(messages, tc);
1390
- result.push(tc);
1391
- }
1392
-
1393
- return result;
1394
- }
1395
-
1396
- /**
1397
- * Overlay WAITING_APPROVAL onto a streamed tool call the hook denied. Mutates
1398
- * `tc` in place — the call keeps its committed id, so the backend's
1399
- * append-only-at-identity transcript guard accepts the finalize (an in-place
1400
- * status change is a reconcile, not a drop). The single overlay routine for both
1401
- * the exact and the normalized correlation passes, so the gate diff can never
1402
- * diverge between them. The hook-captured `input` (when present) is the
1403
- * authoritative, complete proposed args — the stream may have carried only
1404
- * partial args before the first-denial cancel — so it supplies the args preview
1405
- * and the content digest (see {@link applyGateInput}).
1406
- */
1407
- function overlayDeniedStreamCall(
1408
- tc: ToolCall,
1409
- input: Record<string, unknown> | undefined,
1410
- mergedPolicies: ReadonlyMap<string, MergedToolPolicy> | undefined,
1411
- ): void {
1412
- markWaitingApproval(tc, mergedPolicies);
1413
- applyGateInput(tc, input);
1414
- }
1415
-
1416
- /**
1417
- * Collapse every tool call still in a non-terminal state (PENDING / RUNNING)
1418
- * to the hidden SKIPPED row shape, returning how many were collapsed.
1419
- *
1420
- * Called only on the pause-for-approval path, after the anchor gate has been
1421
- * overlaid to WAITING_APPROVAL: the run was cancelled, so nothing will ever
1422
- * complete these calls, and a permanently-RUNNING row would render as an
1423
- * eternal spinner beside the approval card. This is the causality sibling of
1424
- * {@link collapseNonAnchorDenials}: that collapse is token-scoped (it needs the
1425
- * denial in the ledger), while this one catches the attempt whose hook denial
1426
- * raced the final ledger read or whose execution the cancel interrupted
1427
- * outright — either way an attempt with no output that the turn's end orphaned.
1428
- */
1429
- function finalizeInterruptedToolCalls(messages: AgentMessage[]): number {
1430
- let finalized = 0;
1431
- for (const msg of messages) {
1432
- for (const tc of msg.toolCalls) {
1433
- if (
1434
- tc.status !== ToolCallStatus.TOOL_CALL_PENDING &&
1435
- tc.status !== ToolCallStatus.TOOL_CALL_RUNNING
1436
- ) {
1437
- continue;
1438
- }
1439
- hideToolCallRow(tc);
1440
- finalized++;
1441
- }
1442
- }
1443
- return finalized;
1444
- }
1445
-
1446
- /**
1447
- * Recognizes a tool call already blanked to a hidden collapsed row, so a second
1448
- * pass never re-collapses it (and never miscounts). Mirrors the SDK's
1449
- * `isCollapsedToolCall` shape without importing across the runner/SDK seam.
1450
- */
1451
- function isAlreadyCollapsed(tc: ToolCall): boolean {
1452
- return (
1453
- tc.status === ToolCallStatus.TOOL_CALL_SKIPPED &&
1454
- !tc.requiresApproval &&
1455
- !tc.result &&
1456
- !tc.error &&
1457
- !tc.argsPreview
1458
- );
1459
- }
1460
-
1461
- /**
1462
- * Whether the server has already adjudicated this row — the user decided its
1463
- * gate (`approval_action`, the server's field; the runner never writes it).
1464
- *
1465
- * Such a row belongs to a gate that is CLOSED: approved and executed, or
1466
- * declined, in an earlier invocation of this execution. No reconciliation of
1467
- * the CURRENT turn may rewrite it — not the denial overlay (a new denial of the
1468
- * same identity is a NEW act and gets its own row), not the twin collapse (the
1469
- * executed row is the transcript's record). Before this rule the overlay matched
1470
- * a denial to the FIRST same-identity row in the whole transcript, flipped a
1471
- * completed, approved row back to WAITING_APPROVAL with its APPROVE still on
1472
- * it, and the next invocation read that as the decision for the new, undecided
1473
- * proposal — an approval bleeding to a later identical act (S2 M4 finding F9,
1474
- * found by the harness contract kit's invariant 3 against this adapter).
1475
- */
1476
- function isAdjudicatedRow(tc: ToolCall): boolean {
1477
- return tc.approvalAction !== ApprovalAction.UNSPECIFIED;
1478
- }
1479
-
1480
- /** Adjudicated with a decision that does NOT execute: the user declined this gate. */
1481
- function isDeclinedRow(tc: ToolCall): boolean {
1482
- switch (tc.approvalAction) {
1483
- case ApprovalAction.SKIP:
1484
- case ApprovalAction.REJECT:
1485
- return true;
1486
- case ApprovalAction.UNSPECIFIED:
1487
- case ApprovalAction.APPROVE:
1488
- case ApprovalAction.APPROVE_ALL:
1489
- return false;
1490
- default: {
1491
- const exhaustive: never = tc.approvalAction;
1492
- throw new Error(`isDeclinedRow: unknown approval action ${String(exhaustive)}`);
1493
- }
1494
- }
1495
- }
1496
-
1497
- /**
1498
- * Whether a tool call carries a change/output of its own — the signal that it is
1499
- * authoritative for its resource rather than a redundant denial/cancel twin.
1500
- *
1501
- * The notion of "change" is category-aware on purpose:
1502
- * - A file mutation (`write`/`delete`) never carries an authoritative change on
1503
- * the tool-call ROW: under apply-then-review its review lives in the
1504
- * `FileChangeSet` ledger (capture mode), and under the no-storage deny-gate it
1505
- * is the WAITING_APPROVAL gate itself (kept explicitly by the caller). So a
1506
- * file row is authoritative only as that gate, never on its own — hence
1507
- * `false` here. (Before Phase 5 Slice 4 this read `file_changes.length > 0`;
1508
- * that field is gone, and the row was never the review surface.)
1509
- * - Every other gated tool (shell, MCP) has no ledger; its "change" is its
1510
- * execution output, so a genuine run carries a non-empty `result` while a
1511
- * denied/cancelled attempt that never executed does not. This keeps two
1512
- * distinct shell runs (each with output) both visible while still collapsing a
1513
- * same-command denial twin.
1514
- */
1515
- function carriesOwnChange(tc: ToolCall): boolean {
1516
- const category = toolApprovalCategory(tc.name);
1517
- if (category === "write" || category === "delete") {
1518
- return false;
1519
- }
1520
- return !!tc.result;
1521
- }
1522
-
1523
- /**
1524
- * Collapse redundant same-identity tool-call twins to a single visible row.
1525
- *
1526
- * The model frequently emits the SAME gated action twice in one turn (two
1527
- * tool-call ids, one identity). When the first attempt is gated and the run is
1528
- * cancelled mid-flight, the extra attempt never receives a terminal event and
1529
- * persists as a stuck `RUNNING` row ("No preview available"); other variants are
1530
- * a denied-reported-as-success `COMPLETED` with an empty result, two no-change
1531
- * `COMPLETED` attempts where neither carries a change, or — on the denial path —
1532
- * a `FAILED` twin beside the overlaid gate. All render as a duplicate card beside
1533
- * the real action (or the approval gate). This is the recurring duplicate-card
1534
- * defect, most visible for file edits but shared by every gated tool family.
1535
- *
1536
- * The routine is harness-agnostic and a pure function of `messages`:
1537
- *
1538
- * 1. Scope to GATED identities — file mutations (`write`/`delete`) and shell key
1539
- * on their cross-taxonomy category; MCP tools are recognized by their server
1540
- * slug. A same-turn duplicate of a gated tool is a denial/cancel artifact, not
1541
- * meaningful repetition. The category is name-derived (via {@link toolIdentity}
1542
- * -> approvalCategory), so a twin cancelled before classification (empty
1543
- * `toolKind`) is still scoped via its name (`edit` -> `write`). Ungated
1544
- * read-only tools are left untouched.
1545
- * 2. Group those calls by `toolCallIdentityToken` — the SAME `toolIdentity` used
1546
- * for denial correlation and resume grants, so scope and grouping cannot drift.
1547
- * 3. In each group the keepers carry authoritative state — a change/output of
1548
- * their own (see {@link carriesOwnChange}) or the approval gate itself
1549
- * (`WAITING_APPROVAL`). For a file mutation the gate is the sole authoritative
1550
- * row: the row carries no diff (review lives in the `FileChangeSet` ledger, or
1551
- * is the no-storage deny-gate itself), so a denied write's same-identity
1552
- * siblings — a denied/zombie row or a stale snapshot from a second attempt —
1553
- * collapse onto the gate. A shell/MCP twin keeps every distinct run with
1554
- * output. If NO member qualifies (every attempt produced no change), keep
1555
- * exactly ONE representative — preferring a terminal attempt over a stuck
1556
- * `RUNNING` zombie — so the resource still shows a single card. Every
1557
- * non-keeper is blanked in place to a hidden `SKIPPED` row (see
1558
- * {@link collapseDenialTwin}).
1559
- *
1560
- * It is deliberately subtractive — it only ever HIDES a row, never invents a
1561
- * terminal state. The committed `id` is preserved on every collapse, so the
1562
- * finalize stays append-only by construction and the backend's
1563
- * append-only-at-identity guard accepts it. Returns the number collapsed, for
1564
- * observability.
1565
- */
1566
- export function collapseRedundantToolCallTwins(messages: AgentMessage[]): number {
1567
- const groups = new Map<string, ToolCall[]>();
1568
- for (const msg of messages) {
1569
- for (const tc of msg.toolCalls) {
1570
- const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1571
- const gated = tc.mcpServerSlug
1572
- ? true
1573
- : id.key === "write" || id.key === "delete" || id.key === "shell";
1574
- if (!gated) continue;
1575
- const token = grantToken(id.key, id.salient);
1576
- const bucket = groups.get(token);
1577
- if (bucket) bucket.push(tc);
1578
- else groups.set(token, [tc]);
1579
- }
1580
- }
1581
-
1582
- let collapsed = 0;
1583
- for (const group of groups.values()) {
1584
- if (group.length < 2) continue; // a lone call is never a twin
1585
-
1586
- // Keepers carry authoritative state: a change/output of their own
1587
- // (carriesOwnChange), the approval gate itself, or a gate an earlier
1588
- // invocation already settled (isAdjudicatedRow — the transcript's record of
1589
- // what the user decided and what ran; a same-identity act in a later turn
1590
- // is a new act, never this row's twin). For a file mutation the gate is the
1591
- // sole authoritative row (the row carries no diff — review lives in the
1592
- // ledger, or the row IS the no-storage deny-gate), so a denied write's
1593
- // same-identity siblings collapse onto it; a shell/MCP twin keeps every
1594
- // distinct run with output.
1595
- const keepers = new Set<ToolCall>(
1596
- group.filter(
1597
- (tc) =>
1598
- carriesOwnChange(tc) ||
1599
- isAdjudicatedRow(tc) ||
1600
- tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
1601
- ),
1602
- );
1603
- // All attempts produced no change (e.g. denied-reported-as-success): keep one
1604
- // representative, preferring a settled outcome over a stuck RUNNING zombie.
1605
- if (keepers.size === 0) {
1606
- const terminal = [...group].reverse().find((tc) => isTerminalToolStatus(tc.status));
1607
- keepers.add(terminal ?? group[0]);
1608
- }
1609
-
1610
- for (const tc of group) {
1611
- if (keepers.has(tc)) continue;
1612
- if (isAlreadyCollapsed(tc)) continue;
1613
- collapseDenialTwin(tc);
1614
- collapsed++;
1615
- }
1616
- }
1617
- return collapsed;
1618
- }
1619
-
1620
- /**
1621
- * One gate per turn: blank every DENIED tool call whose identity differs from
1622
- * the anchor (the first denial of the turn) to a hidden SKIPPED row.
1623
- *
1624
- * This is the cross-identity complement of {@link collapseRedundantToolCallTwins}
1625
- * (which only joins SAME-identity duplicates). The canonical target is the
1626
- * deny-only workaround — a denied `edit notes.md` followed by a `shell:
1627
- * cat > notes.md` whose identity (`shell\n…`) differs from the edit's
1628
- * (`write\nnotes.md`), so no twin collapse can join them and (since they share
1629
- * one assistant message) no positional rule can separate them. The honest signal
1630
- * that the shell is redundant is that it is a DIFFERENT denied identity in the
1631
- * same turn as the anchor; under the one-gate-per-turn contract every such
1632
- * identity is either a post-denial reaction or a co-pending sibling the harness
1633
- * defers to the next turn, so it is hidden, not surfaced.
1634
- *
1635
- * Scoped strictly to identities present in `deniedTokens`: a non-denied tool
1636
- * (an earlier read/glob, or an already-granted call that ran) is never touched.
1637
- * Subtractive and id-preserving (via {@link collapseDenialTwin}), so the finalize
1638
- * stays append-only. Returns the number collapsed, for observability.
1639
- */
1640
- function collapseNonAnchorDenials(
1641
- messages: AgentMessage[],
1642
- deniedTokens: ReadonlySet<string>,
1643
- anchorToken: string,
1644
- ): number {
1645
- let collapsed = 0;
1646
- for (const msg of messages) {
1647
- for (const tc of msg.toolCalls) {
1648
- if (isAdjudicatedRow(tc)) continue;
1649
- const token = toolCallIdentityToken(tc);
1650
- if (token === anchorToken) continue;
1651
- if (!deniedTokens.has(token)) continue;
1652
- if (isAlreadyCollapsed(tc)) continue;
1653
- collapseDenialTwin(tc);
1654
- collapsed++;
1655
- }
1656
- }
1657
- return collapsed;
1658
- }
1659
-
1660
- /**
1661
- * Blank a superseded denial twin in place to a hidden SKIPPED row. Keeps the
1662
- * committed `id` (append-only), `name`, and `toolKind`; clears every renderable
1663
- * surface and the approval flags so the SDK's `isCollapsedToolCall` predicate
1664
- * recognizes it and renders nothing. The structured `args` are left as the honest
1665
- * stored record of the redundant attempt (never rendered, since the row is
1666
- * hidden; the gate carries the authoritative proposed change).
1667
- */
1668
- function collapseDenialTwin(tc: ToolCall): void {
1669
- hideToolCallRow(tc);
1670
- }
1671
-
1672
- // `hideToolCallRow` (the "hidden row" shape, shared by the denial-twin collapse
1673
- // and the capture flow) lives in shared/tool-row.ts so both harnesses collapse
1674
- // rows identically; imported at the top of this module.
1675
-
1676
- /**
1677
- * Overlay the hook-captured authoritative tool input onto a gated tool call so
1678
- * the approval card can show the proposed change before the user approves.
1679
- *
1680
- * When `input` is present it becomes the single source for the preview: the full
1681
- * structured `args` (the approval card renders the proposed write/edit content
1682
- * from these), a compact-but-always-valid `args_preview` (the field a resumed
1683
- * turn parses to rebuild the grant salient — so salient fields are never elided),
1684
- * and the content digest.
1685
- *
1686
- * The digest is the resume identity: it binds the grant to (category, path,
1687
- * content) so a sibling edit to the same file re-gates rather than riding an
1688
- * earlier approval through. It is also the identity the Cursor deny-gate's
1689
- * exact-apply reads on resume — together with the whole-file bytes in `args` — to
1690
- * write exactly what was approved (see exact-apply.ts). There is no separate
1691
- * captured `file_changes` mirror; `args` is the single source for both the
1692
- * preview and the applied bytes.
1693
- *
1694
- * With no `input` (the hook's grep fallback) there is nothing authoritative to
1695
- * stamp and the call keeps its existing args.
1696
- */
1697
- function applyGateInput(
1698
- tc: ToolCall,
1699
- input: Record<string, unknown> | undefined,
1700
- ): void {
1701
- if (!input) return;
1702
- // Defense-in-depth (DD-26 #2): never overlay a secret-like write's content into
1703
- // the persisted approval preview. Normally unreachable — the hook hard-blocks a
1704
- // secret write and records no ledger input — but if a hook classify failure fell
1705
- // one through, its content must still never reach args/args_preview. The
1706
- // Invariant-A backstop is the final net; this closes the path at the source.
1707
- const gatePath = extractFilePath(input);
1708
- if (gatePath !== null && isSecretLikePath(gatePath)) return;
1709
- tc.args = input as JsonObject;
1710
- tc.argsPreview = buildElidedArgsPreview(input, SALIENT_ARG_FIELDS);
1711
- // Stamp the content digest from the AUTHORITATIVE captured input, so the
1712
- // approved edit's exact content survives to resume on a small, never-elided
1713
- // field — the grant then binds to (category, path, content) and a sibling
1714
- // edit to the same file re-gates. Empty for a non-content tool. This is the
1715
- // one place the digest is authored; everything downstream reads the field.
1716
- tc.approvalContentDigest = contentDigest(input);
1717
- }
1718
-
1719
- /**
1720
- * The workspace-normalized identity of a FILE approval category's salient, or
1721
- * undefined for a non-file category (shell, whose salient is a command, not a
1722
- * path) or an empty salient. Both the hook-decoded denial salient and a streamed
1723
- * call's salient pass through this, so an absolute-vs-relative path difference
1724
- * collapses to one comparable key (`category + "\n" + relPath`). Restricting to
1725
- * write/delete keeps a shell command from being mangled by path normalization.
1726
- */
1727
- function normalizedFileSalient(
1728
- category: string,
1729
- salient: string,
1730
- workspaceRoot: string,
1731
- ): string | undefined {
1732
- if ((category !== "write" && category !== "delete") || !salient) return undefined;
1733
- const { path } = resolveWorkspacePath(salient, workspaceRoot, /* virtualRoot */ false);
1734
- return `${category}\n${path}`;
1735
- }
1736
-
1737
- /**
1738
- * Find the first not-yet-overlaid streamed tool call whose workspace-normalized
1739
- * (category, path) equals `wanted`. Skips calls already claimed by an earlier
1740
- * denial so several concurrent file denials each overlay a distinct stream call.
1741
- */
1742
- function findUnmatchedStreamCallByNormalizedSalient(
1743
- messages: AgentMessage[],
1744
- matchedCalls: ReadonlySet<ToolCall>,
1745
- wanted: string,
1746
- workspaceRoot: string,
1747
- ): ToolCall | undefined {
1748
- for (const msg of messages) {
1749
- for (const tc of msg.toolCalls) {
1750
- if (matchedCalls.has(tc) || isAdjudicatedRow(tc)) continue;
1751
- const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1752
- if (normalizedFileSalient(id.key, id.salient, workspaceRoot) === wanted) {
1753
- return tc;
1754
- }
1755
- }
1756
- }
1757
- return undefined;
1758
- }
1759
-
1760
- /**
1761
- * Redact provisional post-denial narration when a Cursor turn pauses for approval.
1762
- *
1763
- * THE PROBLEM. Unlike the native harness — which gates with a LangGraph
1764
- * `interrupt()` *before* the tool runs, so the model never sees a denial — the
1765
- * Cursor harness can only gate via the file-based `beforeMCPExecution`/
1766
- * `preToolUse` hook returning `deny`. Cursor surfaces that deny to the model as
1767
- * a tool *failure* (often its own generic "blocked by a hook" text; see the
1768
- * Phase 0 ground-truth capture in cursor_hitl_test.go), and there is no
1769
- * non-leaky SDK approval primitive to use instead (the `request` event is
1770
- * opaque and carries no responder). So a well-behaved model frequently reacts by
1771
- * narrating defeat — "I couldn't do this; enable the hook in your Cursor
1772
- * settings" — which would otherwise be persisted as the assistant's verdict and
1773
- * rendered right next to the approval card that is, in fact, asking the user to
1774
- * approve. Contradictory and alarming.
1775
- *
1776
- * THE GUARANTEE. The runner's job is to simplify this data, not mirror its
1777
- * complexity: a turn that pauses for approval must read the SAME shape the
1778
- * native harness produces — `[pre-tool text][tool calls WAITING_APPROVAL]`, with
1779
- * no post-denial verdict. We therefore BLANK (clear the `content` of, and mark
1780
- * non-streaming) the trailing assistant/thinking messages that (a) appear
1781
- * positionally AFTER the last message bearing a WAITING_APPROVAL tool call and
1782
- * (b) carry no tool calls of their own. The approval card (projected from the
1783
- * WAITING_APPROVAL tool-call status) becomes the single, unambiguous source of
1784
- * truth. The blanked messages are already invisible on every surface via the
1785
- * existing empty-message handling (`buildThreadItems` skips empty `MESSAGE_AI`;
1786
- * `MessageEntry` renders nothing for empty `MESSAGE_THINKING`), so the shared
1787
- * `@stigmer/react`/Ink components stay harness-agnostic with zero per-harness UI
1788
- * special-casing — the cleanliness lives in the data, not in each consumer.
1789
- *
1790
- * WHY BLANK INSTEAD OF REMOVE. Removing the messages would make the persisted
1791
- * WAITING_FOR_APPROVAL transcript SHORTER than the in-progress transcript the
1792
- * runner already streamed. The backend's append-only message guard rejects a
1793
- * shrink for a non-terminal execution (it protects against regressed/partial
1794
- * writes). Blanking keeps the message COUNT identical, so the finalize is
1795
- * append-only BY CONSTRUCTION and the guard accepts it with no special case —
1796
- * which is why this phase deletes the backend's former `isApprovalFinalize`
1797
- * shrink exception in both editions. The transcript is the authoritative *raw*
1798
- * record; the verbatim narration text remains recoverable from the runner logs
1799
- * and the recorded cursor-event stream.
1800
- *
1801
- * WHY THIS IS DETERMINISTIC. `attachToolCallToLastAi` calls
1802
- * `finalizeStreaming(run_id)` before attaching a tool call, so any assistant
1803
- * text the model emits *after* the denied tool call always starts a NEW message
1804
- * — post-denial narration is never merged into the message that holds the gated
1805
- * call. We stop at the first non-narration message — one bearing a VISIBLE tool
1806
- * call — so legitimately-executed tools after the gate and any text around them
1807
- * are never touched; only the contiguous trailing reaction block is blanked. A
1808
- * message whose every tool call was collapsed to the hidden SKIPPED row (a
1809
- * post-denial workaround or an interrupted attempt — see
1810
- * collapseNonAnchorDenials / finalizeInterruptedToolCalls, which run first) IS
1811
- * trailing narration: its rows render as absent, so only its text remains, and
1812
- * that text is precisely the reaction this redaction exists to blank. Treating
1813
- * it as a stop would strand every reaction message behind it (the production
1814
- * shape in aex_01kwj07f7g23c3wp9sn8496z5g: [gate][thinking][narration+workaround
1815
- * row] — the old walk stopped at the workaround message and redacted nothing).
1816
- * The first-denial stop in index.ts is the primary mechanism that keeps this
1817
- * block small (it ends the turn before the model produces inter-tool
1818
- * narration); this redaction is the backstop for any token that streamed before
1819
- * the cancel landed.
1820
- *
1821
- * Returns the blanked messages (for diagnostics); mutates `messages` in place.
1822
- */
1823
- export function clearProvisionalPostDenialNarration(
1824
- messages: AgentMessage[],
1825
- deniedToolCalls: ToolCall[],
1826
- ): AgentMessage[] {
1827
- if (deniedToolCalls.length === 0) return [];
1828
-
1829
- // reconcileDeniedToolCalls returns the very ToolCall protos held inside
1830
- // messages[].toolCalls (overlaid) or appended to the last AI message
1831
- // (synthesized), so object identity is a stable, exact match.
1832
- const denied = new Set(deniedToolCalls);
1833
-
1834
- let lastGatedIdx = -1;
1835
- for (let i = 0; i < messages.length; i++) {
1836
- if (messages[i].toolCalls.some((tc) => denied.has(tc))) {
1837
- lastGatedIdx = i;
1838
- }
1839
- }
1840
- if (lastGatedIdx < 0) return [];
1841
-
1842
- const redacted: AgentMessage[] = [];
1843
- for (let i = messages.length - 1; i > lastGatedIdx; i--) {
1844
- const msg = messages[i];
1845
- const isProvisionalNarration =
1846
- (msg.type === MessageType.MESSAGE_AI || msg.type === MessageType.MESSAGE_THINKING) &&
1847
- msg.toolCalls.every((tc) => isToolCallRowHidden(tc));
1848
- // Stop at the first message that is NOT trailing narration: a message
1849
- // bearing a visible (non-collapsed) tool call marks real activity we must
1850
- // preserve, and anything before it is no longer "trailing".
1851
- if (!isProvisionalNarration) break;
1852
- // Blank in place — keep the message so the transcript count never shrinks,
1853
- // but drop its provisional content so no consumer renders the defeatist
1854
- // verdict. Empty AI/THINKING messages are hidden by the SDK already; hidden
1855
- // SKIPPED rows already render as absent.
1856
- msg.content = "";
1857
- msg.isStreaming = false;
1858
- redacted.unshift(msg);
1859
- }
1860
- return redacted;
1861
- }
1862
-
1863
- /**
1864
- * Substrings of the error text Cursor stamps onto a tool call blocked by a
1865
- * `preToolUse`/`beforeMCPExecution` hook (its generic replacement for the
1866
- * hook's own agent_message — confirmed by the Phase 0 ground-truth capture in
1867
- * cursor_hitl_test.go). The SDK has NO structured "denied by hook" signal, so
1868
- * this marker family is the only stream-side trace of a hook block and is
1869
- * single-sourced here for every consumer (the issue #205 attribution detector
1870
- * below; tests). Matched case-insensitively against the FAILED call's error.
1871
- */
1872
- export const HOOK_BLOCK_ERROR_MARKERS: readonly string[] = ["blocked by a hook"];
1873
-
1874
- /** True when a tool call's error text reads as a hook block. */
1875
- function isHookBlockError(errorText: string): boolean {
1876
- if (!errorText) return false;
1877
- const lowered = errorText.toLowerCase();
1878
- return HOOK_BLOCK_ERROR_MARKERS.some((marker) => lowered.includes(marker));
1879
- }
1880
-
1881
- /** One hook-blocked tool call that no ledger entry accounts for (issue #205). */
1882
- export interface UnattributedHookBlock {
1883
- toolCallId: string;
1884
- toolName: string;
1885
- /** The hook-block error text Cursor stamped on the call. */
1886
- error: string;
1887
- }
1888
-
1889
- /**
1890
- * Detect tool calls blocked by a hook that STIGMER'S OWN hook did not deny —
1891
- * the issue #205 invariant check "a blocked tool must never silently complete".
1892
- *
1893
- * Cursor runs EVERY hook registered in the workspace's `.cursor/hooks.json`
1894
- * and a deny from any of them blocks the tool. Our hook records every deny it
1895
- * issues to the denial ledger (all kinds — see {@link DeniedLedgerEntry}), so a
1896
- * FAILED tool call carrying Cursor's hook-block error text with NO matching
1897
- * ledger entry was blocked by a FOREIGN hook (a user/team `preToolUse` policy
1898
- * hook the merge deliberately preserves) — or, equally fatally, by our own
1899
- * hook whose best-effort ledger append failed. Either way the runner cannot
1900
- * pause for approval (an approval grants a token only OUR hook reads; the
1901
- * foreign hook would deny the re-attempt forever), so the caller surfaces an
1902
- * explicit failure instead of completing with the work silently undone.
1903
- *
1904
- * Attribution, in order:
1905
- * 1. Any `fail-closed` ledger entry → the gate itself was broken this turn and
1906
- * denied EVERYTHING it saw; per-call correlation is meaningless, so every
1907
- * hook block is attributed to our own (broken) gate. Nothing is reported.
1908
- * 2. Exact identity: the call's {@link toolCallIdentityToken} appears in the
1909
- * ledger (any kind — approval gates were overlaid to WAITING_APPROVAL or
1910
- * collapsed by the reconcile that runs first, so a still-FAILED row here is
1911
- * typically a secret/capture-error deny, correctly attributed as ours).
1912
- * 3. Normalized-path fallback: the same abs-vs-rel drift the reconcile's
1913
- * second pass handles — a FILE call matches a ledger entry by (category,
1914
- * workspace-normalized path) even when the raw tokens differ.
1915
- *
1916
- * Scoped to THIS turn's messages (from `turnStartMessageIndex`): seeded
1917
- * prior-turn rows were already adjudicated and must never re-trigger.
1918
- * Deliberately conservative: an ordinary tool failure (no hook-block text)
1919
- * is never reported, and a foreign hook denying with fully custom text evades
1920
- * the marker match (the documented residual — the install-time
1921
- * foreignGatingHooks warning still fires for diagnosability).
1922
- */
1923
- export function detectUnattributedHookBlocks(
1924
- messages: readonly AgentMessage[],
1925
- turnStartMessageIndex: number,
1926
- ledger: readonly DeniedLedgerEntry[],
1927
- workspaceRoot?: string,
1928
- ): UnattributedHookBlock[] {
1929
- if (ledger.some((e) => denialKindOf(e) === "fail-closed")) return [];
1930
-
1931
- const ledgerTokens = new Set(ledger.map((e) => e.token));
1932
- const ledgerNormalizedSalients = new Set<string>();
1933
- if (workspaceRoot) {
1934
- for (const entry of ledger) {
1935
- const decoded = decodeIdentityToken(entry.token);
1936
- if (!decoded) continue;
1937
- const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
1938
- if (normalized) ledgerNormalizedSalients.add(normalized);
1939
- }
1940
- }
1941
-
1942
- const blocks: UnattributedHookBlock[] = [];
1943
- for (const msg of messages.slice(Math.max(0, turnStartMessageIndex))) {
1944
- for (const tc of msg.toolCalls) {
1945
- if (tc.status !== ToolCallStatus.TOOL_CALL_FAILED) continue;
1946
- if (!isHookBlockError(tc.error)) continue;
1947
- if (ledgerTokens.has(toolCallIdentityToken(tc))) continue;
1948
- if (workspaceRoot) {
1949
- const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
1950
- const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
1951
- if (normalized && ledgerNormalizedSalients.has(normalized)) continue;
1952
- }
1953
- blocks.push({ toolCallId: tc.id, toolName: tc.name, error: tc.error });
1954
- }
1955
- }
1956
- return blocks;
1957
- }
1958
-
1959
- /**
1960
- * The honest terminal error stamped on a tool call that never resolved (issue
1961
- * #965). Deliberately states all three negatives — not executed, not approved,
1962
- * not denied — because the incident's harm was the model claiming an approval
1963
- * was pending: this text is what the transcript shows INSTEAD of a spinner or
1964
- * a silent after-the-fact interruption, and it must leave no room for an
1965
- * approval-is-coming reading.
1966
- */
1967
- export const UNRESOLVED_TOOL_CALL_ERROR =
1968
- "The tool did not return a result before the turn ended. It was never " +
1969
- "executed, approved, or denied — no approval is pending for it.";
1970
-
1971
- /** One never-resolved tool call the turn boundary settled (issue #965). */
1972
- export interface UnresolvedToolCall {
1973
- toolCallId: string;
1974
- toolName: string;
1975
- }
1976
-
1977
- /**
1978
- * Settle this-turn tool calls that are still NON-TERMINAL when a turn
1979
- * completes without pausing — the issue #965 invariant, the sibling of
1980
- * {@link detectUnattributedHookBlocks}' #205 invariant ("a blocked tool must
1981
- * never silently complete" → "an unresolved tool must never silently
1982
- * complete").
1983
- *
1984
- * THE SHAPE THIS CATCHES. A tool that hangs INSIDE the Cursor agent runtime —
1985
- * the production case is `generateImage`, which rides the SDK's
1986
- * interaction-query channel rather than the ordinary tool path — streams a
1987
- * tool_call start, never streams a result, and is invisible to every Stigmer
1988
- * seam: the hook never denied it (no ledger entry), so the reconcile never
1989
- * gated it, and the turn completes with the row still PENDING/RUNNING. Before
1990
- * this sweep, the server's terminal settle (issue #207) stamped such rows
1991
- * TOOL_CALL_INTERRUPTED silently AFTER the runner reported completion — the
1992
- * transcript's last word stayed whatever the model claimed, which in
1993
- * aex_01m1a6ww3nmp4952ar5v0g4g85 was a promise that an approval was pending
1994
- * when none existed.
1995
- *
1996
- * Settling here instead makes the runner the author of the honest record: the
1997
- * row gets TOOL_CALL_INTERRUPTED with {@link UNRESOLVED_TOOL_CALL_ERROR}, and
1998
- * the caller (turn-boundary.ts) appends a system disclosure naming what never
1999
- * ran.
2000
- *
2001
- * WHY INTERRUPTED AND NEVER FAILED. TOOL_CALL_INTERRUPTED is deliberately the
2002
- * one settled status the monotonic merge guard lets live execution evidence
2003
- * supersede (see the guard's note in this file, ~line 331): if this FAILED
2004
- * execution is later RECOVERED, the harness checkpoint may re-execute the call
2005
- * under its original id, and the replayed events must be able to advance the
2006
- * row to its true outcome. A boundary-stamped FAILED would freeze it forever.
2007
- *
2008
- * WHY IT NEVER FAILS THE RUN (unlike #205). A foreign hook block is provably
2009
- * adversarial — approval semantics are permanently broken, so completing would
2010
- * always be a lie. An unresolved row can also be benign stream event-loss
2011
- * where the tool actually ran; failing the run would convert those into
2012
- * regressions. Disclosure restores honesty at zero regression risk.
2013
- *
2014
- * Scope and exclusions, in order:
2015
- * - THIS turn's parent-transcript rows only (from `turnStartMessageIndex`):
2016
- * seeded prior-turn rows were adjudicated by their own execution's settle,
2017
- * and sub-agent inner rows are the server settle's concern — the parent row
2018
- * (e.g. the Task call) is what the user sees. Mirrors #205's scoping.
2019
- * - Only PENDING/RUNNING rows: every terminal row was adjudicated, and
2020
- * WAITING_APPROVAL rows belong to the pause machinery (the caller only runs
2021
- * this sweep on a NON-pausing turn, so none should exist here anyway).
2022
- * - Only rows with NO denial-ledger entry of ANY kind (exact token, then the
2023
- * normalized-path fallback — the same two identities every sweep in this
2024
- * file uses): a ledger-attributed row is the unattended/secret/fail-closed
2025
- * machinery's to settle, not ours.
2026
- *
2027
- * Returns the settled calls so the boundary can disclose and log them.
2028
- */
2029
- export function settleUnresolvedToolCalls(
2030
- messages: readonly AgentMessage[],
2031
- turnStartMessageIndex: number,
2032
- ledger: readonly DeniedLedgerEntry[],
2033
- workspaceRoot?: string,
2034
- ): UnresolvedToolCall[] {
2035
- const ledgerTokens = new Set(ledger.map((e) => e.token));
2036
- const ledgerNormalizedSalients = new Set<string>();
2037
- if (workspaceRoot) {
2038
- for (const entry of ledger) {
2039
- const decoded = decodeIdentityToken(entry.token);
2040
- if (!decoded) continue;
2041
- const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
2042
- if (normalized) ledgerNormalizedSalients.add(normalized);
2043
- }
2044
- }
2045
-
2046
- const matchesLedger = (tc: ToolCall): boolean => {
2047
- if (ledgerTokens.has(toolCallIdentityToken(tc))) return true;
2048
- if (!workspaceRoot) return false;
2049
- const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
2050
- const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
2051
- return !!normalized && ledgerNormalizedSalients.has(normalized);
2052
- };
2053
-
2054
- const settled: UnresolvedToolCall[] = [];
2055
- for (const msg of messages.slice(Math.max(0, turnStartMessageIndex))) {
2056
- for (const tc of msg.toolCalls) {
2057
- if (
2058
- tc.status !== ToolCallStatus.TOOL_CALL_PENDING &&
2059
- tc.status !== ToolCallStatus.TOOL_CALL_RUNNING
2060
- ) {
2061
- continue;
2062
- }
2063
- if (matchesLedger(tc)) continue;
2064
- tc.status = ToolCallStatus.TOOL_CALL_INTERRUPTED;
2065
- tc.error = UNRESOLVED_TOOL_CALL_ERROR;
2066
- tc.isStreaming = false;
2067
- if (!tc.completedAt) tc.completedAt = utcTimestamp();
2068
- settled.push({ toolCallId: tc.id, toolName: tc.name });
2069
- }
2070
- }
2071
- return settled;
2072
- }
2073
-
2074
- /**
2075
- * Stamp the tool calls the hook denied under UNATTENDED approval mode
2076
- * (DD-014) as terminal TOOL_CALL_SKIPPED rows with UNATTENDED_SKIP
2077
- * provenance — the Cursor twin of the native harness's
2078
- * `reconcileUnattendedSkips`, so both harnesses persist the same honest
2079
- * shape for a platform-resolved skip.
2080
- *
2081
- * An unattended denial never pauses the run (its ledger kind is excluded
2082
- * from {@link approvalDenials}), so the stream leaves the denied call as a
2083
- * FAILED row carrying Cursor's generic hook-block error — dishonest ("the
2084
- * tool broke") and alarming in a transcript an org admin reviews. This pass
2085
- * correlates the unattended ledger entries to their streamed calls with the
2086
- * SAME two identities the rest of the file uses — exact token, then
2087
- * (category, workspace-normalized path) for the abs-vs-rel drift — and
2088
- * settles each match to SKIPPED with a result explaining the skip.
2089
- *
2090
- * Scope: hook-blocked FAILED rows and still-non-terminal (PENDING/RUNNING)
2091
- * rows only — a COMPLETED row is a real execution and is never rewritten
2092
- * (an unattended deny cannot produce one). `approval_action`/`approved_by`
2093
- * stay untouched: server-owned, human-decision-only fields (DD-014 D-e).
2094
- * Unmatched ledger entries need no synthesis — there is no pause to
2095
- * surface; the model already saw the deny and adapted in-turn.
2096
- *
2097
- * Returns how many tool calls were stamped.
2098
- */
2099
- export function stampUnattendedSkippedToolCalls(
2100
- messages: readonly AgentMessage[],
2101
- subAgentExecutions: readonly SubAgentExecution[],
2102
- unattendedLedger: readonly DeniedLedgerEntry[],
2103
- workspaceRoot?: string,
2104
- ): number {
2105
- if (unattendedLedger.length === 0) return 0;
2106
-
2107
- const ledgerTokens = new Set(unattendedLedger.map((e) => e.token));
2108
- const ledgerNormalizedSalients = new Set<string>();
2109
- if (workspaceRoot) {
2110
- for (const entry of unattendedLedger) {
2111
- const decoded = decodeIdentityToken(entry.token);
2112
- if (!decoded) continue;
2113
- const normalized = normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot);
2114
- if (normalized) ledgerNormalizedSalients.add(normalized);
2115
- }
2116
- }
2117
-
2118
- const matchesLedger = (tc: ToolCall): boolean => {
2119
- if (ledgerTokens.has(toolCallIdentityToken(tc))) return true;
2120
- if (!workspaceRoot) return false;
2121
- const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
2122
- const normalized = normalizedFileSalient(id.key, id.salient, workspaceRoot);
2123
- return !!normalized && ledgerNormalizedSalients.has(normalized);
2124
- };
2125
-
2126
- let stamped = 0;
2127
- const apply = (msgs: readonly AgentMessage[]): void => {
2128
- for (const msg of msgs) {
2129
- for (const tc of msg.toolCalls) {
2130
- const deniedShape =
2131
- (tc.status === ToolCallStatus.TOOL_CALL_FAILED && isHookBlockError(tc.error)) ||
2132
- tc.status === ToolCallStatus.TOOL_CALL_PENDING ||
2133
- tc.status === ToolCallStatus.TOOL_CALL_RUNNING;
2134
- if (!deniedShape || !matchesLedger(tc)) continue;
2135
- tc.status = ToolCallStatus.TOOL_CALL_SKIPPED;
2136
- tc.approvalPolicySource = ApprovalPolicySource.UNATTENDED_SKIP;
2137
- tc.policyEngineVersion = POLICY_ENGINE_VERSION;
2138
- tc.error = "";
2139
- tc.result = unattendedSkipMessage(tc.name);
2140
- tc.isStreaming = false;
2141
- if (!tc.completedAt) tc.completedAt = utcTimestamp();
2142
- stamped++;
2143
- }
2144
- }
2145
- };
2146
-
2147
- apply(messages);
2148
- for (const subAgent of subAgentExecutions) {
2149
- apply(subAgent.messages);
2150
- }
2151
- return stamped;
2152
- }
2153
-
2154
- /**
2155
- * Compute a streamed tool call's identity token in the same canonical space the
2156
- * preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
2157
- * The token keys on the cross-taxonomy category + salient resource PLUS, for a
2158
- * file edit/write, the {@link contentDigest} of the edit content — so a stream
2159
- * `edit` correlates to the hook's `Write` deny for the same path AND content,
2160
- * and an approval of one edit does not match a DIFFERENT edit to the same file.
2161
- *
2162
- * The digest is read from the persisted `approval_content_digest` field when
2163
- * present (a seeded gate carries it, stable even if `args` was elided), and is
2164
- * recomputed from the call's args otherwise (a freshly-streamed call). For a
2165
- * shell/delete/MCP call (no content) it falls back to the coarse token, exactly
2166
- * as before — so those identities are unchanged.
2167
- *
2168
- * Exported so the resume-grant round-trip can be locked against it: the grant a
2169
- * resume mints for an approved tool (buildApprovalGrants -> primaryToken) must
2170
- * equal THIS denial/overlay identity, or the re-issued call is re-gated forever
2171
- * (the dual-path drift the approval-state round-trip suite guards against).
2172
- */
2173
- export function toolCallIdentityToken(tc: ToolCall): string {
2174
- const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
2175
- const digest = tc.approvalContentDigest || contentDigest(toolCallArgs(tc));
2176
- return primaryToken(id.key, id.salient, digest);
2177
- }
2178
-
2179
- /**
2180
- * Decode a primary token back into its (key, salient, digest) for the synthesis
2181
- * fallback. The token is `base64(key \n salient)` (coarse) or
2182
- * `base64(key \n salient \n digest)` (content-exact); the digest is the optional
2183
- * third segment. salient never contains a newline (a path or shell command), so
2184
- * splitting on the first two newlines is unambiguous.
2185
- */
2186
- function decodeIdentityToken(
2187
- token: string,
2188
- ): { key: string; salient: string; digest: string } | undefined {
2189
- try {
2190
- const decoded = Buffer.from(token, "base64").toString("utf-8");
2191
- const first = decoded.indexOf("\n");
2192
- if (first < 0) return undefined;
2193
- const key = decoded.slice(0, first);
2194
- const rest = decoded.slice(first + 1);
2195
- const second = rest.indexOf("\n");
2196
- if (second < 0) return { key, salient: rest, digest: "" };
2197
- return { key, salient: rest.slice(0, second), digest: rest.slice(second + 1) };
2198
- } catch {
2199
- return undefined;
2200
- }
2201
- }
2202
-
2203
- /** Best-effort args record for a tool call (proto struct, else parsed preview). */
2204
- function toolCallArgs(tc: ToolCall): Record<string, unknown> {
2205
- if (tc.args && typeof tc.args === "object") {
2206
- return tc.args as Record<string, unknown>;
2207
- }
2208
- if (tc.argsPreview) {
2209
- try {
2210
- const parsed = JSON.parse(tc.argsPreview);
2211
- if (parsed && typeof parsed === "object") return parsed as Record<string, unknown>;
2212
- } catch {
2213
- // fall through
2214
- }
2215
- }
2216
- return {};
2217
- }
2218
-
2219
- /**
2220
- * Mark a denied tool call as awaiting approval, clearing the result/terminal
2221
- * fields the stream may have set (the tool never actually ran — it was gated).
2222
- */
2223
- function markWaitingApproval(
2224
- tc: ToolCall,
2225
- mergedPolicies?: ReadonlyMap<string, MergedToolPolicy>,
2226
- ): void {
2227
- tc.status = ToolCallStatus.TOOL_CALL_WAITING_APPROVAL;
2228
- tc.requiresApproval = true;
2229
- if (!tc.approvalMessage) {
2230
- tc.approvalMessage = resolveDeniedApprovalMessage(
2231
- tc.name, tc.mcpServerSlug, toolCallArgs(tc), mergedPolicies,
2232
- );
2233
- }
2234
- if (!tc.approvalRequestedAt) tc.approvalRequestedAt = utcTimestamp();
2235
- tc.completedAt = "";
2236
- tc.error = "";
2237
- tc.result = "";
2238
- }
2239
-
2240
- function synthesizeWaitingApprovalToolCall(
2241
- displayName: string,
2242
- salient: string,
2243
- digest: string,
2244
- token: string,
2245
- mergedPolicies?: ReadonlyMap<string, MergedToolPolicy>,
2246
- ): ToolCall {
2247
- const tc = create(ToolCallSchema, {
2248
- id: `approval:${token}`,
2249
- name: displayName,
2250
- status: ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
2251
- requiresApproval: true,
2252
- startedAt: utcTimestamp(),
2253
- approvalRequestedAt: utcTimestamp(),
2254
- toolKind: classifyTool(displayName),
2255
- // Carry the decoded digest so this placeholder's identity (and the grant
2256
- // rebuilt from it on resume) equals the anchor's content token. applyGateInput
2257
- // overwrites it from the authoritative input when one was captured.
2258
- approvalContentDigest: digest,
2259
- });
2260
- // Carry the salient resource so reconstructAdjudicatedApprovals -> the grant
2261
- // builder keys on the same resource the hook will see on the re-attempt.
2262
- if (salient) {
2263
- tc.argsPreview = JSON.stringify({ path: salient });
2264
- }
2265
- tc.approvalMessage = salient
2266
- ? `Tool requires approval: ${displayName} (${salient})`
2267
- : resolveDeniedApprovalMessage(displayName, "", {}, mergedPolicies);
2268
- // A synthesized call is a ledger denial — it was gated, so it has a governing
2269
- // layer. A denied call never occurs under a global bypass or a matching lease,
2270
- // so empty leases + no bypass faithfully attribute it (a built-in resolves to
2271
- // builtin_category; an MCP placeholder lacks a reconstructed slug and stays
2272
- // UNSPECIFIED rather than be mislabeled).
2273
- if (mergedPolicies) {
2274
- const source = resolveApprovalProvenance(
2275
- displayName, "", mergedPolicies, NO_LEASED_CATEGORIES, false,
2276
- );
2277
- tc.approvalPolicySource = toProtoPolicySource(source);
2278
- if (source) tc.policyEngineVersion = POLICY_ENGINE_VERSION;
2279
- }
2280
- return tc;
2281
- }
2282
-
2283
- /**
2284
- * Resolve a human-readable approval message for a denied tool, preferring the
2285
- * MCP policy template, then the built-in template, then a generic fallback.
2286
- */
2287
- function resolveDeniedApprovalMessage(
2288
- name: string,
2289
- mcpServerSlug: string,
2290
- args: Record<string, unknown>,
2291
- mergedPolicies?: ReadonlyMap<string, MergedToolPolicy>,
2292
- ): string {
2293
- if (mergedPolicies && mcpServerSlug) {
2294
- const policy = lookupMcpToolPolicy(name, mcpServerSlug, mergedPolicies);
2295
- if (policy) return resolveApprovalMessage(policy.approvalMessage, name, args);
2296
- }
2297
- if (!mcpServerSlug) {
2298
- const template = getBuiltInApprovalMessage(name);
2299
- if (template) return resolveApprovalMessage(template, name, args);
2300
- }
2301
- return `Tool requires approval: ${name}`;
2302
- }
2303
-
2304
- /** Append a tool call to the last AI message, creating one if none exists. */
2305
- function appendToolCallToLastAiMessage(messages: AgentMessage[], tc: ToolCall): void {
2306
- for (let i = messages.length - 1; i >= 0; i--) {
2307
- if (messages[i].type === MessageType.MESSAGE_AI) {
2308
- messages[i].toolCalls.push(tc);
2309
- return;
2310
- }
2311
- }
2312
- const msg = create(AgentMessageSchema, {
2313
- type: MessageType.MESSAGE_AI,
2314
- content: "",
2315
- timestamp: utcTimestamp(),
2316
- toolCalls: [tc],
2317
- });
2318
- messages.push(msg);
2319
- }