@sema-ai/core 1.275.1

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 (962) hide show
  1. package/CHANGELOG.md +5433 -0
  2. package/LICENSE +27 -0
  3. package/NOTICE.md +30 -0
  4. package/README.md +1040 -0
  5. package/dist/agents/agent-definition.d.ts +10 -0
  6. package/dist/agents/agent-definition.d.ts.map +1 -0
  7. package/dist/agents/agent-definition.js +18 -0
  8. package/dist/agents/agent-definition.js.map +1 -0
  9. package/dist/agents/builtin-agents.d.ts +69 -0
  10. package/dist/agents/builtin-agents.d.ts.map +1 -0
  11. package/dist/agents/builtin-agents.js +177 -0
  12. package/dist/agents/builtin-agents.js.map +1 -0
  13. package/dist/agents/cascade.d.ts +101 -0
  14. package/dist/agents/cascade.d.ts.map +1 -0
  15. package/dist/agents/cascade.js +189 -0
  16. package/dist/agents/cascade.js.map +1 -0
  17. package/dist/agents/observer.d.ts +209 -0
  18. package/dist/agents/observer.d.ts.map +1 -0
  19. package/dist/agents/observer.js +495 -0
  20. package/dist/agents/observer.js.map +1 -0
  21. package/dist/agents/repair-loop.d.ts +236 -0
  22. package/dist/agents/repair-loop.d.ts.map +1 -0
  23. package/dist/agents/repair-loop.js +293 -0
  24. package/dist/agents/repair-loop.js.map +1 -0
  25. package/dist/agents/session-util.d.ts +8 -0
  26. package/dist/agents/session-util.d.ts.map +1 -0
  27. package/dist/agents/session-util.js +9 -0
  28. package/dist/agents/session-util.js.map +1 -0
  29. package/dist/agents/subagent-steps.d.ts +93 -0
  30. package/dist/agents/subagent-steps.d.ts.map +1 -0
  31. package/dist/agents/subagent-steps.js +206 -0
  32. package/dist/agents/subagent-steps.js.map +1 -0
  33. package/dist/agents/subagent.d.ts +441 -0
  34. package/dist/agents/subagent.d.ts.map +1 -0
  35. package/dist/agents/subagent.js +2492 -0
  36. package/dist/agents/subagent.js.map +1 -0
  37. package/dist/agents/suspend-guard.d.ts +35 -0
  38. package/dist/agents/suspend-guard.d.ts.map +1 -0
  39. package/dist/agents/suspend-guard.js +48 -0
  40. package/dist/agents/suspend-guard.js.map +1 -0
  41. package/dist/agents/teacher.d.ts +137 -0
  42. package/dist/agents/teacher.d.ts.map +1 -0
  43. package/dist/agents/teacher.js +575 -0
  44. package/dist/agents/teacher.js.map +1 -0
  45. package/dist/agents/team.d.ts +211 -0
  46. package/dist/agents/team.d.ts.map +1 -0
  47. package/dist/agents/team.js +293 -0
  48. package/dist/agents/team.js.map +1 -0
  49. package/dist/agents/tool-filter.d.ts +29 -0
  50. package/dist/agents/tool-filter.d.ts.map +1 -0
  51. package/dist/agents/tool-filter.js +50 -0
  52. package/dist/agents/tool-filter.js.map +1 -0
  53. package/dist/agents/verify.d.ts +177 -0
  54. package/dist/agents/verify.d.ts.map +1 -0
  55. package/dist/agents/verify.js +305 -0
  56. package/dist/agents/verify.js.map +1 -0
  57. package/dist/bench/metrics.d.ts +714 -0
  58. package/dist/bench/metrics.d.ts.map +1 -0
  59. package/dist/bench/metrics.js +618 -0
  60. package/dist/bench/metrics.js.map +1 -0
  61. package/dist/bin/sema-tb.d.ts +3 -0
  62. package/dist/bin/sema-tb.d.ts.map +1 -0
  63. package/dist/bin/sema-tb.js +516 -0
  64. package/dist/bin/sema-tb.js.map +1 -0
  65. package/dist/bin/tb-env.d.ts +13 -0
  66. package/dist/bin/tb-env.d.ts.map +1 -0
  67. package/dist/bin/tb-env.js +26 -0
  68. package/dist/bin/tb-env.js.map +1 -0
  69. package/dist/brain/anthropic.d.ts +46 -0
  70. package/dist/brain/anthropic.d.ts.map +1 -0
  71. package/dist/brain/anthropic.js +830 -0
  72. package/dist/brain/anthropic.js.map +1 -0
  73. package/dist/brain/circuit-breaker.d.ts +46 -0
  74. package/dist/brain/circuit-breaker.d.ts.map +1 -0
  75. package/dist/brain/circuit-breaker.js +173 -0
  76. package/dist/brain/circuit-breaker.js.map +1 -0
  77. package/dist/brain/degrading.d.ts +98 -0
  78. package/dist/brain/degrading.d.ts.map +1 -0
  79. package/dist/brain/degrading.js +249 -0
  80. package/dist/brain/degrading.js.map +1 -0
  81. package/dist/brain/errors.d.ts +31 -0
  82. package/dist/brain/errors.d.ts.map +1 -0
  83. package/dist/brain/errors.js +58 -0
  84. package/dist/brain/errors.js.map +1 -0
  85. package/dist/brain/failover.d.ts +18 -0
  86. package/dist/brain/failover.d.ts.map +1 -0
  87. package/dist/brain/failover.js +118 -0
  88. package/dist/brain/failover.js.map +1 -0
  89. package/dist/brain/model-presets.d.ts +36 -0
  90. package/dist/brain/model-presets.d.ts.map +1 -0
  91. package/dist/brain/model-presets.js +54 -0
  92. package/dist/brain/model-presets.js.map +1 -0
  93. package/dist/brain/openai.d.ts +43 -0
  94. package/dist/brain/openai.d.ts.map +1 -0
  95. package/dist/brain/openai.js +784 -0
  96. package/dist/brain/openai.js.map +1 -0
  97. package/dist/brain/reasoning.d.ts +138 -0
  98. package/dist/brain/reasoning.d.ts.map +1 -0
  99. package/dist/brain/reasoning.js +146 -0
  100. package/dist/brain/reasoning.js.map +1 -0
  101. package/dist/brain/repetition.d.ts +60 -0
  102. package/dist/brain/repetition.d.ts.map +1 -0
  103. package/dist/brain/repetition.js +121 -0
  104. package/dist/brain/repetition.js.map +1 -0
  105. package/dist/brain/request-params.d.ts +29 -0
  106. package/dist/brain/request-params.d.ts.map +1 -0
  107. package/dist/brain/request-params.js +78 -0
  108. package/dist/brain/request-params.js.map +1 -0
  109. package/dist/brain/retry.d.ts +18 -0
  110. package/dist/brain/retry.d.ts.map +1 -0
  111. package/dist/brain/retry.js +42 -0
  112. package/dist/brain/retry.js.map +1 -0
  113. package/dist/brain/routing.d.ts +19 -0
  114. package/dist/brain/routing.d.ts.map +1 -0
  115. package/dist/brain/routing.js +44 -0
  116. package/dist/brain/routing.js.map +1 -0
  117. package/dist/brain/status-sink.d.ts +46 -0
  118. package/dist/brain/status-sink.d.ts.map +1 -0
  119. package/dist/brain/status-sink.js +36 -0
  120. package/dist/brain/status-sink.js.map +1 -0
  121. package/dist/brain/stream-engine.d.ts +85 -0
  122. package/dist/brain/stream-engine.d.ts.map +1 -0
  123. package/dist/brain/stream-engine.js +391 -0
  124. package/dist/brain/stream-engine.js.map +1 -0
  125. package/dist/brain/timeout.d.ts +44 -0
  126. package/dist/brain/timeout.d.ts.map +1 -0
  127. package/dist/brain/timeout.js +41 -0
  128. package/dist/brain/timeout.js.map +1 -0
  129. package/dist/brain/tool-call-repair.d.ts +15 -0
  130. package/dist/brain/tool-call-repair.d.ts.map +1 -0
  131. package/dist/brain/tool-call-repair.js +67 -0
  132. package/dist/brain/tool-call-repair.js.map +1 -0
  133. package/dist/brain/walltime.d.ts +14 -0
  134. package/dist/brain/walltime.d.ts.map +1 -0
  135. package/dist/brain/walltime.js +14 -0
  136. package/dist/brain/walltime.js.map +1 -0
  137. package/dist/core/arg-summary.d.ts +19 -0
  138. package/dist/core/arg-summary.d.ts.map +1 -0
  139. package/dist/core/arg-summary.js +96 -0
  140. package/dist/core/arg-summary.js.map +1 -0
  141. package/dist/core/ask-question.d.ts +113 -0
  142. package/dist/core/ask-question.d.ts.map +1 -0
  143. package/dist/core/ask-question.js +246 -0
  144. package/dist/core/ask-question.js.map +1 -0
  145. package/dist/core/auto-compaction.d.ts +341 -0
  146. package/dist/core/auto-compaction.d.ts.map +1 -0
  147. package/dist/core/auto-compaction.js +495 -0
  148. package/dist/core/auto-compaction.js.map +1 -0
  149. package/dist/core/auto-promote.d.ts +126 -0
  150. package/dist/core/auto-promote.d.ts.map +1 -0
  151. package/dist/core/auto-promote.js +171 -0
  152. package/dist/core/auto-promote.js.map +1 -0
  153. package/dist/core/background-shell.d.ts +128 -0
  154. package/dist/core/background-shell.d.ts.map +1 -0
  155. package/dist/core/background-shell.js +42 -0
  156. package/dist/core/background-shell.js.map +1 -0
  157. package/dist/core/cache-break-detector.d.ts +63 -0
  158. package/dist/core/cache-break-detector.d.ts.map +1 -0
  159. package/dist/core/cache-break-detector.js +0 -0
  160. package/dist/core/cache-break-detector.js.map +1 -0
  161. package/dist/core/canonical-json.d.ts +45 -0
  162. package/dist/core/canonical-json.d.ts.map +1 -0
  163. package/dist/core/canonical-json.js +132 -0
  164. package/dist/core/canonical-json.js.map +1 -0
  165. package/dist/core/checkpoint-store.d.ts +1101 -0
  166. package/dist/core/checkpoint-store.d.ts.map +1 -0
  167. package/dist/core/checkpoint-store.js +514 -0
  168. package/dist/core/checkpoint-store.js.map +1 -0
  169. package/dist/core/consolidate-scope.d.ts +94 -0
  170. package/dist/core/consolidate-scope.d.ts.map +1 -0
  171. package/dist/core/consolidate-scope.js +158 -0
  172. package/dist/core/consolidate-scope.js.map +1 -0
  173. package/dist/core/context-edit.d.ts +82 -0
  174. package/dist/core/context-edit.d.ts.map +1 -0
  175. package/dist/core/context-edit.js +149 -0
  176. package/dist/core/context-edit.js.map +1 -0
  177. package/dist/core/context-guard.d.ts +43 -0
  178. package/dist/core/context-guard.d.ts.map +1 -0
  179. package/dist/core/context-guard.js +171 -0
  180. package/dist/core/context-guard.js.map +1 -0
  181. package/dist/core/dynamic-recall.d.ts +100 -0
  182. package/dist/core/dynamic-recall.d.ts.map +1 -0
  183. package/dist/core/dynamic-recall.js +108 -0
  184. package/dist/core/dynamic-recall.js.map +1 -0
  185. package/dist/core/exec-gate.d.ts +74 -0
  186. package/dist/core/exec-gate.d.ts.map +1 -0
  187. package/dist/core/exec-gate.js +143 -0
  188. package/dist/core/exec-gate.js.map +1 -0
  189. package/dist/core/exec-output-tail.d.ts +37 -0
  190. package/dist/core/exec-output-tail.d.ts.map +1 -0
  191. package/dist/core/exec-output-tail.js +63 -0
  192. package/dist/core/exec-output-tail.js.map +1 -0
  193. package/dist/core/file-snapshot-store.d.ts +137 -0
  194. package/dist/core/file-snapshot-store.d.ts.map +1 -0
  195. package/dist/core/file-snapshot-store.js +319 -0
  196. package/dist/core/file-snapshot-store.js.map +1 -0
  197. package/dist/core/git-worktree-env.d.ts +41 -0
  198. package/dist/core/git-worktree-env.d.ts.map +1 -0
  199. package/dist/core/git-worktree-env.js +64 -0
  200. package/dist/core/git-worktree-env.js.map +1 -0
  201. package/dist/core/hooks.d.ts +372 -0
  202. package/dist/core/hooks.d.ts.map +1 -0
  203. package/dist/core/hooks.js +269 -0
  204. package/dist/core/hooks.js.map +1 -0
  205. package/dist/core/lsp-diagnostics.d.ts +74 -0
  206. package/dist/core/lsp-diagnostics.d.ts.map +1 -0
  207. package/dist/core/lsp-diagnostics.js +125 -0
  208. package/dist/core/lsp-diagnostics.js.map +1 -0
  209. package/dist/core/lsp-protocol.d.ts +28 -0
  210. package/dist/core/lsp-protocol.d.ts.map +1 -0
  211. package/dist/core/lsp-protocol.js +179 -0
  212. package/dist/core/lsp-protocol.js.map +1 -0
  213. package/dist/core/lsp-session.d.ts +56 -0
  214. package/dist/core/lsp-session.d.ts.map +1 -0
  215. package/dist/core/lsp-session.js +185 -0
  216. package/dist/core/lsp-session.js.map +1 -0
  217. package/dist/core/lsp.d.ts +132 -0
  218. package/dist/core/lsp.d.ts.map +1 -0
  219. package/dist/core/lsp.js +147 -0
  220. package/dist/core/lsp.js.map +1 -0
  221. package/dist/core/mcp.d.ts +294 -0
  222. package/dist/core/mcp.d.ts.map +1 -0
  223. package/dist/core/mcp.js +992 -0
  224. package/dist/core/mcp.js.map +1 -0
  225. package/dist/core/media-byte-cap.d.ts +34 -0
  226. package/dist/core/media-byte-cap.d.ts.map +1 -0
  227. package/dist/core/media-byte-cap.js +76 -0
  228. package/dist/core/media-byte-cap.js.map +1 -0
  229. package/dist/core/memory-engine/data-plane.d.ts +103 -0
  230. package/dist/core/memory-engine/data-plane.d.ts.map +1 -0
  231. package/dist/core/memory-engine/data-plane.js +272 -0
  232. package/dist/core/memory-engine/data-plane.js.map +1 -0
  233. package/dist/core/memory-engine/dual-root.d.ts +40 -0
  234. package/dist/core/memory-engine/dual-root.d.ts.map +1 -0
  235. package/dist/core/memory-engine/dual-root.js +95 -0
  236. package/dist/core/memory-engine/dual-root.js.map +1 -0
  237. package/dist/core/memory-engine/engine.d.ts +161 -0
  238. package/dist/core/memory-engine/engine.d.ts.map +1 -0
  239. package/dist/core/memory-engine/engine.js +1110 -0
  240. package/dist/core/memory-engine/engine.js.map +1 -0
  241. package/dist/core/memory-engine/file-backend.d.ts +162 -0
  242. package/dist/core/memory-engine/file-backend.d.ts.map +1 -0
  243. package/dist/core/memory-engine/file-backend.js +948 -0
  244. package/dist/core/memory-engine/file-backend.js.map +1 -0
  245. package/dist/core/memory-engine/frontmatter.d.ts +24 -0
  246. package/dist/core/memory-engine/frontmatter.d.ts.map +1 -0
  247. package/dist/core/memory-engine/frontmatter.js +135 -0
  248. package/dist/core/memory-engine/frontmatter.js.map +1 -0
  249. package/dist/core/memory-engine/index.d.ts +13 -0
  250. package/dist/core/memory-engine/index.d.ts.map +1 -0
  251. package/dist/core/memory-engine/index.js +28 -0
  252. package/dist/core/memory-engine/index.js.map +1 -0
  253. package/dist/core/memory-engine/layout.d.ts +123 -0
  254. package/dist/core/memory-engine/layout.d.ts.map +1 -0
  255. package/dist/core/memory-engine/layout.js +643 -0
  256. package/dist/core/memory-engine/layout.js.map +1 -0
  257. package/dist/core/memory-engine/memory-backend-contract.d.ts +22 -0
  258. package/dist/core/memory-engine/memory-backend-contract.d.ts.map +1 -0
  259. package/dist/core/memory-engine/memory-backend-contract.js +271 -0
  260. package/dist/core/memory-engine/memory-backend-contract.js.map +1 -0
  261. package/dist/core/memory-engine/migrate.d.ts +17 -0
  262. package/dist/core/memory-engine/migrate.d.ts.map +1 -0
  263. package/dist/core/memory-engine/migrate.js +35 -0
  264. package/dist/core/memory-engine/migrate.js.map +1 -0
  265. package/dist/core/memory-engine/scan.d.ts +20 -0
  266. package/dist/core/memory-engine/scan.d.ts.map +1 -0
  267. package/dist/core/memory-engine/scan.js +84 -0
  268. package/dist/core/memory-engine/scan.js.map +1 -0
  269. package/dist/core/memory-engine/scope-contract.d.ts +104 -0
  270. package/dist/core/memory-engine/scope-contract.d.ts.map +1 -0
  271. package/dist/core/memory-engine/scope-contract.js +288 -0
  272. package/dist/core/memory-engine/scope-contract.js.map +1 -0
  273. package/dist/core/memory-engine/sync-client.d.ts +100 -0
  274. package/dist/core/memory-engine/sync-client.d.ts.map +1 -0
  275. package/dist/core/memory-engine/sync-client.js +328 -0
  276. package/dist/core/memory-engine/sync-client.js.map +1 -0
  277. package/dist/core/memory-engine/sync.d.ts +97 -0
  278. package/dist/core/memory-engine/sync.d.ts.map +1 -0
  279. package/dist/core/memory-engine/sync.js +91 -0
  280. package/dist/core/memory-engine/sync.js.map +1 -0
  281. package/dist/core/memory-engine/types.d.ts +279 -0
  282. package/dist/core/memory-engine/types.d.ts.map +1 -0
  283. package/dist/core/memory-engine/types.js +9 -0
  284. package/dist/core/memory-engine/types.js.map +1 -0
  285. package/dist/core/memory-recall.d.ts +261 -0
  286. package/dist/core/memory-recall.d.ts.map +1 -0
  287. package/dist/core/memory-recall.js +600 -0
  288. package/dist/core/memory-recall.js.map +1 -0
  289. package/dist/core/memory-vector.d.ts +24 -0
  290. package/dist/core/memory-vector.d.ts.map +1 -0
  291. package/dist/core/memory-vector.js +51 -0
  292. package/dist/core/memory-vector.js.map +1 -0
  293. package/dist/core/memory.d.ts +502 -0
  294. package/dist/core/memory.d.ts.map +1 -0
  295. package/dist/core/memory.js +881 -0
  296. package/dist/core/memory.js.map +1 -0
  297. package/dist/core/message-utils.d.ts +7 -0
  298. package/dist/core/message-utils.d.ts.map +1 -0
  299. package/dist/core/message-utils.js +10 -0
  300. package/dist/core/message-utils.js.map +1 -0
  301. package/dist/core/oracle-isolation.d.ts +85 -0
  302. package/dist/core/oracle-isolation.d.ts.map +1 -0
  303. package/dist/core/oracle-isolation.js +121 -0
  304. package/dist/core/oracle-isolation.js.map +1 -0
  305. package/dist/core/permission-rules.d.ts +102 -0
  306. package/dist/core/permission-rules.d.ts.map +1 -0
  307. package/dist/core/permission-rules.js +357 -0
  308. package/dist/core/permission-rules.js.map +1 -0
  309. package/dist/core/present-plan-tool.d.ts +28 -0
  310. package/dist/core/present-plan-tool.d.ts.map +1 -0
  311. package/dist/core/present-plan-tool.js +119 -0
  312. package/dist/core/present-plan-tool.js.map +1 -0
  313. package/dist/core/pricing.d.ts +48 -0
  314. package/dist/core/pricing.d.ts.map +1 -0
  315. package/dist/core/pricing.js +46 -0
  316. package/dist/core/pricing.js.map +1 -0
  317. package/dist/core/property-harness.d.ts +126 -0
  318. package/dist/core/property-harness.d.ts.map +1 -0
  319. package/dist/core/property-harness.js +192 -0
  320. package/dist/core/property-harness.js.map +1 -0
  321. package/dist/core/push-queue.d.ts +10 -0
  322. package/dist/core/push-queue.d.ts.map +1 -0
  323. package/dist/core/push-queue.js +42 -0
  324. package/dist/core/push-queue.js.map +1 -0
  325. package/dist/core/remote-env.d.ts +432 -0
  326. package/dist/core/remote-env.d.ts.map +1 -0
  327. package/dist/core/remote-env.js +77 -0
  328. package/dist/core/remote-env.js.map +1 -0
  329. package/dist/core/roles.d.ts +74 -0
  330. package/dist/core/roles.d.ts.map +1 -0
  331. package/dist/core/roles.js +272 -0
  332. package/dist/core/roles.js.map +1 -0
  333. package/dist/core/runner/active-skill-scope.d.ts +64 -0
  334. package/dist/core/runner/active-skill-scope.d.ts.map +1 -0
  335. package/dist/core/runner/active-skill-scope.js +197 -0
  336. package/dist/core/runner/active-skill-scope.js.map +1 -0
  337. package/dist/core/runner/assemble-result.d.ts +191 -0
  338. package/dist/core/runner/assemble-result.d.ts.map +1 -0
  339. package/dist/core/runner/assemble-result.js +250 -0
  340. package/dist/core/runner/assemble-result.js.map +1 -0
  341. package/dist/core/runner/call-cap.d.ts +130 -0
  342. package/dist/core/runner/call-cap.d.ts.map +1 -0
  343. package/dist/core/runner/call-cap.js +151 -0
  344. package/dist/core/runner/call-cap.js.map +1 -0
  345. package/dist/core/runner/grounding-signal.d.ts +12 -0
  346. package/dist/core/runner/grounding-signal.d.ts.map +1 -0
  347. package/dist/core/runner/grounding-signal.js +94 -0
  348. package/dist/core/runner/grounding-signal.js.map +1 -0
  349. package/dist/core/runner/image.d.ts +23 -0
  350. package/dist/core/runner/image.d.ts.map +1 -0
  351. package/dist/core/runner/image.js +151 -0
  352. package/dist/core/runner/image.js.map +1 -0
  353. package/dist/core/runner/memory-consolidation.d.ts +112 -0
  354. package/dist/core/runner/memory-consolidation.d.ts.map +1 -0
  355. package/dist/core/runner/memory-consolidation.js +372 -0
  356. package/dist/core/runner/memory-consolidation.js.map +1 -0
  357. package/dist/core/runner/prepare-task.d.ts +583 -0
  358. package/dist/core/runner/prepare-task.d.ts.map +1 -0
  359. package/dist/core/runner/prepare-task.js +3612 -0
  360. package/dist/core/runner/prepare-task.js.map +1 -0
  361. package/dist/core/runner/prompt-suggestions.d.ts +33 -0
  362. package/dist/core/runner/prompt-suggestions.d.ts.map +1 -0
  363. package/dist/core/runner/prompt-suggestions.js +116 -0
  364. package/dist/core/runner/prompt-suggestions.js.map +1 -0
  365. package/dist/core/runner/runtask.d.ts +274 -0
  366. package/dist/core/runner/runtask.d.ts.map +1 -0
  367. package/dist/core/runner/runtask.js +4606 -0
  368. package/dist/core/runner/runtask.js.map +1 -0
  369. package/dist/core/runner/session-rule-policy.d.ts +28 -0
  370. package/dist/core/runner/session-rule-policy.d.ts.map +1 -0
  371. package/dist/core/runner/session-rule-policy.js +110 -0
  372. package/dist/core/runner/session-rule-policy.js.map +1 -0
  373. package/dist/core/runner/strict-output-schema.d.ts +36 -0
  374. package/dist/core/runner/strict-output-schema.d.ts.map +1 -0
  375. package/dist/core/runner/strict-output-schema.js +365 -0
  376. package/dist/core/runner/strict-output-schema.js.map +1 -0
  377. package/dist/core/runner/synthetic-tools.d.ts +72 -0
  378. package/dist/core/runner/synthetic-tools.d.ts.map +1 -0
  379. package/dist/core/runner/synthetic-tools.js +203 -0
  380. package/dist/core/runner/synthetic-tools.js.map +1 -0
  381. package/dist/core/runner/tool-disclosure.d.ts +117 -0
  382. package/dist/core/runner/tool-disclosure.d.ts.map +1 -0
  383. package/dist/core/runner/tool-disclosure.js +0 -0
  384. package/dist/core/runner/tool-disclosure.js.map +1 -0
  385. package/dist/core/runner/turn-attachments.d.ts +381 -0
  386. package/dist/core/runner/turn-attachments.d.ts.map +1 -0
  387. package/dist/core/runner/turn-attachments.js +596 -0
  388. package/dist/core/runner/turn-attachments.js.map +1 -0
  389. package/dist/core/runner/usage-accounting.d.ts +35 -0
  390. package/dist/core/runner/usage-accounting.d.ts.map +1 -0
  391. package/dist/core/runner/usage-accounting.js +45 -0
  392. package/dist/core/runner/usage-accounting.js.map +1 -0
  393. package/dist/core/runtime.d.ts +9 -0
  394. package/dist/core/runtime.d.ts.map +1 -0
  395. package/dist/core/runtime.js +17 -0
  396. package/dist/core/runtime.js.map +1 -0
  397. package/dist/core/safety-axis-vocab.d.ts +33 -0
  398. package/dist/core/safety-axis-vocab.d.ts.map +1 -0
  399. package/dist/core/safety-axis-vocab.js +35 -0
  400. package/dist/core/safety-axis-vocab.js.map +1 -0
  401. package/dist/core/safety-merge-corpus.d.ts +48 -0
  402. package/dist/core/safety-merge-corpus.d.ts.map +1 -0
  403. package/dist/core/safety-merge-corpus.js +203 -0
  404. package/dist/core/safety-merge-corpus.js.map +1 -0
  405. package/dist/core/scheduler.d.ts +126 -0
  406. package/dist/core/scheduler.d.ts.map +1 -0
  407. package/dist/core/scheduler.js +54 -0
  408. package/dist/core/scheduler.js.map +1 -0
  409. package/dist/core/secret-env.d.ts +23 -0
  410. package/dist/core/secret-env.d.ts.map +1 -0
  411. package/dist/core/secret-env.js +35 -0
  412. package/dist/core/secret-env.js.map +1 -0
  413. package/dist/core/select-model.d.ts +26 -0
  414. package/dist/core/select-model.d.ts.map +1 -0
  415. package/dist/core/select-model.js +58 -0
  416. package/dist/core/select-model.js.map +1 -0
  417. package/dist/core/sensitive-path-policy.d.ts +24 -0
  418. package/dist/core/sensitive-path-policy.d.ts.map +1 -0
  419. package/dist/core/sensitive-path-policy.js +96 -0
  420. package/dist/core/sensitive-path-policy.js.map +1 -0
  421. package/dist/core/session-policy-store.d.ts +102 -0
  422. package/dist/core/session-policy-store.d.ts.map +1 -0
  423. package/dist/core/session-policy-store.js +177 -0
  424. package/dist/core/session-policy-store.js.map +1 -0
  425. package/dist/core/session-reconcile.d.ts +63 -0
  426. package/dist/core/session-reconcile.d.ts.map +1 -0
  427. package/dist/core/session-reconcile.js +71 -0
  428. package/dist/core/session-reconcile.js.map +1 -0
  429. package/dist/core/session-store.d.ts +99 -0
  430. package/dist/core/session-store.d.ts.map +1 -0
  431. package/dist/core/session-store.js +298 -0
  432. package/dist/core/session-store.js.map +1 -0
  433. package/dist/core/session.d.ts +144 -0
  434. package/dist/core/session.d.ts.map +1 -0
  435. package/dist/core/session.js +56 -0
  436. package/dist/core/session.js.map +1 -0
  437. package/dist/core/spec-contract.d.ts +147 -0
  438. package/dist/core/spec-contract.d.ts.map +1 -0
  439. package/dist/core/spec-contract.js +340 -0
  440. package/dist/core/spec-contract.js.map +1 -0
  441. package/dist/core/strategy-store.d.ts +50 -0
  442. package/dist/core/strategy-store.d.ts.map +1 -0
  443. package/dist/core/strategy-store.js +100 -0
  444. package/dist/core/strategy-store.js.map +1 -0
  445. package/dist/core/stub-env.d.ts +35 -0
  446. package/dist/core/stub-env.d.ts.map +1 -0
  447. package/dist/core/stub-env.js +69 -0
  448. package/dist/core/stub-env.js.map +1 -0
  449. package/dist/core/task-notification.d.ts +115 -0
  450. package/dist/core/task-notification.d.ts.map +1 -0
  451. package/dist/core/task-notification.js +247 -0
  452. package/dist/core/task-notification.js.map +1 -0
  453. package/dist/core/task-outcome.d.ts +80 -0
  454. package/dist/core/task-outcome.d.ts.map +1 -0
  455. package/dist/core/task-outcome.js +40 -0
  456. package/dist/core/task-outcome.js.map +1 -0
  457. package/dist/core/task-registry.d.ts +349 -0
  458. package/dist/core/task-registry.d.ts.map +1 -0
  459. package/dist/core/task-registry.js +1751 -0
  460. package/dist/core/task-registry.js.map +1 -0
  461. package/dist/core/tighten-task-spec.d.ts +27 -0
  462. package/dist/core/tighten-task-spec.d.ts.map +1 -0
  463. package/dist/core/tighten-task-spec.js +95 -0
  464. package/dist/core/tighten-task-spec.js.map +1 -0
  465. package/dist/core/tool-detach.d.ts +31 -0
  466. package/dist/core/tool-detach.d.ts.map +1 -0
  467. package/dist/core/tool-detach.js +62 -0
  468. package/dist/core/tool-detach.js.map +1 -0
  469. package/dist/core/tool-errors.d.ts +72 -0
  470. package/dist/core/tool-errors.d.ts.map +1 -0
  471. package/dist/core/tool-errors.js +194 -0
  472. package/dist/core/tool-errors.js.map +1 -0
  473. package/dist/core/tool-name-aliases.d.ts +5 -0
  474. package/dist/core/tool-name-aliases.d.ts.map +1 -0
  475. package/dist/core/tool-name-aliases.js +130 -0
  476. package/dist/core/tool-name-aliases.js.map +1 -0
  477. package/dist/core/tool-policy.d.ts +295 -0
  478. package/dist/core/tool-policy.d.ts.map +1 -0
  479. package/dist/core/tool-policy.js +567 -0
  480. package/dist/core/tool-policy.js.map +1 -0
  481. package/dist/core/tool-result-budget.d.ts +44 -0
  482. package/dist/core/tool-result-budget.d.ts.map +1 -0
  483. package/dist/core/tool-result-budget.js +140 -0
  484. package/dist/core/tool-result-budget.js.map +1 -0
  485. package/dist/core/tool-result-store.d.ts +136 -0
  486. package/dist/core/tool-result-store.d.ts.map +1 -0
  487. package/dist/core/tool-result-store.js +219 -0
  488. package/dist/core/tool-result-store.js.map +1 -0
  489. package/dist/core/tools.d.ts +14 -0
  490. package/dist/core/tools.d.ts.map +1 -0
  491. package/dist/core/tools.js +98 -0
  492. package/dist/core/tools.js.map +1 -0
  493. package/dist/core/trace.d.ts +340 -0
  494. package/dist/core/trace.d.ts.map +1 -0
  495. package/dist/core/trace.js +12 -0
  496. package/dist/core/trace.js.map +1 -0
  497. package/dist/core/types.d.ts +2828 -0
  498. package/dist/core/types.d.ts.map +1 -0
  499. package/dist/core/types.js +2 -0
  500. package/dist/core/types.js.map +1 -0
  501. package/dist/core/untrusted-egress.d.ts +32 -0
  502. package/dist/core/untrusted-egress.d.ts.map +1 -0
  503. package/dist/core/untrusted-egress.js +49 -0
  504. package/dist/core/untrusted-egress.js.map +1 -0
  505. package/dist/core/untrusted-text.d.ts +64 -0
  506. package/dist/core/untrusted-text.d.ts.map +1 -0
  507. package/dist/core/untrusted-text.js +119 -0
  508. package/dist/core/untrusted-text.js.map +1 -0
  509. package/dist/core/warm-resume.d.ts +23 -0
  510. package/dist/core/warm-resume.d.ts.map +1 -0
  511. package/dist/core/warm-resume.js +28 -0
  512. package/dist/core/warm-resume.js.map +1 -0
  513. package/dist/core/with-retry.d.ts +43 -0
  514. package/dist/core/with-retry.d.ts.map +1 -0
  515. package/dist/core/with-retry.js +59 -0
  516. package/dist/core/with-retry.js.map +1 -0
  517. package/dist/core/workflow-journal-store.d.ts +44 -0
  518. package/dist/core/workflow-journal-store.d.ts.map +1 -0
  519. package/dist/core/workflow-journal-store.js +47 -0
  520. package/dist/core/workflow-journal-store.js.map +1 -0
  521. package/dist/core/workflow-run-store-contract.d.ts +31 -0
  522. package/dist/core/workflow-run-store-contract.d.ts.map +1 -0
  523. package/dist/core/workflow-run-store-contract.js +191 -0
  524. package/dist/core/workflow-run-store-contract.js.map +1 -0
  525. package/dist/core/workflow-run-store.d.ts +149 -0
  526. package/dist/core/workflow-run-store.d.ts.map +1 -0
  527. package/dist/core/workflow-run-store.js +110 -0
  528. package/dist/core/workflow-run-store.js.map +1 -0
  529. package/dist/engine/compaction/compaction.d.ts +139 -0
  530. package/dist/engine/compaction/compaction.d.ts.map +1 -0
  531. package/dist/engine/compaction/compaction.js +1030 -0
  532. package/dist/engine/compaction/compaction.js.map +1 -0
  533. package/dist/engine/compaction/utils.d.ts +45 -0
  534. package/dist/engine/compaction/utils.d.ts.map +1 -0
  535. package/dist/engine/compaction/utils.js +203 -0
  536. package/dist/engine/compaction/utils.js.map +1 -0
  537. package/dist/engine/execution-env/kill-tree.d.ts +20 -0
  538. package/dist/engine/execution-env/kill-tree.d.ts.map +1 -0
  539. package/dist/engine/execution-env/kill-tree.js +183 -0
  540. package/dist/engine/execution-env/kill-tree.js.map +1 -0
  541. package/dist/engine/execution-env/node-execution-env.d.ts +133 -0
  542. package/dist/engine/execution-env/node-execution-env.d.ts.map +1 -0
  543. package/dist/engine/execution-env/node-execution-env.js +1031 -0
  544. package/dist/engine/execution-env/node-execution-env.js.map +1 -0
  545. package/dist/engine/harness/agent-harness.d.ts +174 -0
  546. package/dist/engine/harness/agent-harness.d.ts.map +1 -0
  547. package/dist/engine/harness/agent-harness.js +1053 -0
  548. package/dist/engine/harness/agent-harness.js.map +1 -0
  549. package/dist/engine/harness/messages.d.ts +17 -0
  550. package/dist/engine/harness/messages.d.ts.map +1 -0
  551. package/dist/engine/harness/messages.js +122 -0
  552. package/dist/engine/harness/messages.js.map +1 -0
  553. package/dist/engine/harness/types.d.ts +713 -0
  554. package/dist/engine/harness/types.d.ts.map +1 -0
  555. package/dist/engine/harness/types.js +100 -0
  556. package/dist/engine/harness/types.js.map +1 -0
  557. package/dist/engine/llm/diagnostics.d.ts +23 -0
  558. package/dist/engine/llm/diagnostics.d.ts.map +1 -0
  559. package/dist/engine/llm/diagnostics.js +32 -0
  560. package/dist/engine/llm/diagnostics.js.map +1 -0
  561. package/dist/engine/llm/event-stream.d.ts +25 -0
  562. package/dist/engine/llm/event-stream.d.ts.map +1 -0
  563. package/dist/engine/llm/event-stream.js +103 -0
  564. package/dist/engine/llm/event-stream.js.map +1 -0
  565. package/dist/engine/llm/index.d.ts +12 -0
  566. package/dist/engine/llm/index.d.ts.map +1 -0
  567. package/dist/engine/llm/index.js +12 -0
  568. package/dist/engine/llm/index.js.map +1 -0
  569. package/dist/engine/llm/types.d.ts +753 -0
  570. package/dist/engine/llm/types.d.ts.map +1 -0
  571. package/dist/engine/llm/types.js +2 -0
  572. package/dist/engine/llm/types.js.map +1 -0
  573. package/dist/engine/llm/validation.d.ts +8 -0
  574. package/dist/engine/llm/validation.d.ts.map +1 -0
  575. package/dist/engine/llm/validation.js +318 -0
  576. package/dist/engine/llm/validation.js.map +1 -0
  577. package/dist/engine/loop/agent-loop.d.ts +74 -0
  578. package/dist/engine/loop/agent-loop.d.ts.map +1 -0
  579. package/dist/engine/loop/agent-loop.js +1490 -0
  580. package/dist/engine/loop/agent-loop.js.map +1 -0
  581. package/dist/engine/loop/runtime-deps.d.ts +17 -0
  582. package/dist/engine/loop/runtime-deps.d.ts.map +1 -0
  583. package/dist/engine/loop/runtime-deps.js +21 -0
  584. package/dist/engine/loop/runtime-deps.js.map +1 -0
  585. package/dist/engine/loop/types.d.ts +609 -0
  586. package/dist/engine/loop/types.d.ts.map +1 -0
  587. package/dist/engine/loop/types.js +2 -0
  588. package/dist/engine/loop/types.js.map +1 -0
  589. package/dist/engine/lsp/frame-decoder.d.ts +16 -0
  590. package/dist/engine/lsp/frame-decoder.d.ts.map +1 -0
  591. package/dist/engine/lsp/frame-decoder.js +53 -0
  592. package/dist/engine/lsp/frame-decoder.js.map +1 -0
  593. package/dist/engine/lsp/node-lsp-manager.d.ts +105 -0
  594. package/dist/engine/lsp/node-lsp-manager.d.ts.map +1 -0
  595. package/dist/engine/lsp/node-lsp-manager.js +343 -0
  596. package/dist/engine/lsp/node-lsp-manager.js.map +1 -0
  597. package/dist/engine/lsp/stdio-lsp-transport.d.ts +37 -0
  598. package/dist/engine/lsp/stdio-lsp-transport.d.ts.map +1 -0
  599. package/dist/engine/lsp/stdio-lsp-transport.js +185 -0
  600. package/dist/engine/lsp/stdio-lsp-transport.js.map +1 -0
  601. package/dist/engine/session/import-validate.d.ts +44 -0
  602. package/dist/engine/session/import-validate.d.ts.map +1 -0
  603. package/dist/engine/session/import-validate.js +92 -0
  604. package/dist/engine/session/import-validate.js.map +1 -0
  605. package/dist/engine/session/memory-repo.d.ts +25 -0
  606. package/dist/engine/session/memory-repo.d.ts.map +1 -0
  607. package/dist/engine/session/memory-repo.js +59 -0
  608. package/dist/engine/session/memory-repo.js.map +1 -0
  609. package/dist/engine/session/memory-storage.d.ts +14 -0
  610. package/dist/engine/session/memory-storage.d.ts.map +1 -0
  611. package/dist/engine/session/memory-storage.js +17 -0
  612. package/dist/engine/session/memory-storage.js.map +1 -0
  613. package/dist/engine/session/repo-utils.d.ts +11 -0
  614. package/dist/engine/session/repo-utils.d.ts.map +1 -0
  615. package/dist/engine/session/repo-utils.js +40 -0
  616. package/dist/engine/session/repo-utils.js.map +1 -0
  617. package/dist/engine/session/session.d.ts +34 -0
  618. package/dist/engine/session/session.d.ts.map +1 -0
  619. package/dist/engine/session/session.js +227 -0
  620. package/dist/engine/session/session.js.map +1 -0
  621. package/dist/engine/session/storage-base.d.ts +38 -0
  622. package/dist/engine/session/storage-base.d.ts.map +1 -0
  623. package/dist/engine/session/storage-base.js +157 -0
  624. package/dist/engine/session/storage-base.js.map +1 -0
  625. package/dist/engine/session/timestamps.d.ts +3 -0
  626. package/dist/engine/session/timestamps.d.ts.map +1 -0
  627. package/dist/engine/session/timestamps.js +15 -0
  628. package/dist/engine/session/timestamps.js.map +1 -0
  629. package/dist/engine/session/uuid.d.ts +2 -0
  630. package/dist/engine/session/uuid.d.ts.map +1 -0
  631. package/dist/engine/session/uuid.js +50 -0
  632. package/dist/engine/session/uuid.js.map +1 -0
  633. package/dist/index.d.ts +160 -0
  634. package/dist/index.d.ts.map +1 -0
  635. package/dist/index.js +374 -0
  636. package/dist/index.js.map +1 -0
  637. package/dist/internal/harness.d.ts +26 -0
  638. package/dist/internal/harness.d.ts.map +1 -0
  639. package/dist/internal/harness.js +24 -0
  640. package/dist/internal/harness.js.map +1 -0
  641. package/dist/internal/llm.d.ts +9 -0
  642. package/dist/internal/llm.d.ts.map +1 -0
  643. package/dist/internal/llm.js +8 -0
  644. package/dist/internal/llm.js.map +1 -0
  645. package/dist/orchestration/builtin-workflows.d.ts +44 -0
  646. package/dist/orchestration/builtin-workflows.d.ts.map +1 -0
  647. package/dist/orchestration/builtin-workflows.js +165 -0
  648. package/dist/orchestration/builtin-workflows.js.map +1 -0
  649. package/dist/orchestration/dev-vm-script-runner.d.ts +24 -0
  650. package/dist/orchestration/dev-vm-script-runner.d.ts.map +1 -0
  651. package/dist/orchestration/dev-vm-script-runner.js +106 -0
  652. package/dist/orchestration/dev-vm-script-runner.js.map +1 -0
  653. package/dist/orchestration/goal.d.ts +97 -0
  654. package/dist/orchestration/goal.d.ts.map +1 -0
  655. package/dist/orchestration/goal.js +248 -0
  656. package/dist/orchestration/goal.js.map +1 -0
  657. package/dist/orchestration/run-spec.d.ts +63 -0
  658. package/dist/orchestration/run-spec.d.ts.map +1 -0
  659. package/dist/orchestration/run-spec.js +274 -0
  660. package/dist/orchestration/run-spec.js.map +1 -0
  661. package/dist/orchestration/run-workflow-tool.d.ts +187 -0
  662. package/dist/orchestration/run-workflow-tool.d.ts.map +1 -0
  663. package/dist/orchestration/run-workflow-tool.js +612 -0
  664. package/dist/orchestration/run-workflow-tool.js.map +1 -0
  665. package/dist/orchestration/workflow-governance.d.ts +70 -0
  666. package/dist/orchestration/workflow-governance.d.ts.map +1 -0
  667. package/dist/orchestration/workflow-governance.js +176 -0
  668. package/dist/orchestration/workflow-governance.js.map +1 -0
  669. package/dist/orchestration/workflow-meta.d.ts +41 -0
  670. package/dist/orchestration/workflow-meta.d.ts.map +1 -0
  671. package/dist/orchestration/workflow-meta.js +426 -0
  672. package/dist/orchestration/workflow-meta.js.map +1 -0
  673. package/dist/orchestration/workflow-observe.d.ts +77 -0
  674. package/dist/orchestration/workflow-observe.d.ts.map +1 -0
  675. package/dist/orchestration/workflow-observe.js +177 -0
  676. package/dist/orchestration/workflow-observe.js.map +1 -0
  677. package/dist/orchestration/workflow-primitives.d.ts +31 -0
  678. package/dist/orchestration/workflow-primitives.d.ts.map +1 -0
  679. package/dist/orchestration/workflow-primitives.js +104 -0
  680. package/dist/orchestration/workflow-primitives.js.map +1 -0
  681. package/dist/orchestration/workflow-sandbox-conformance.d.ts +34 -0
  682. package/dist/orchestration/workflow-sandbox-conformance.d.ts.map +1 -0
  683. package/dist/orchestration/workflow-sandbox-conformance.js +301 -0
  684. package/dist/orchestration/workflow-sandbox-conformance.js.map +1 -0
  685. package/dist/orchestration/workflow-script-runner.d.ts +127 -0
  686. package/dist/orchestration/workflow-script-runner.d.ts.map +1 -0
  687. package/dist/orchestration/workflow-script-runner.js +48 -0
  688. package/dist/orchestration/workflow-script-runner.js.map +1 -0
  689. package/dist/orchestration/workflow-script-store.d.ts +99 -0
  690. package/dist/orchestration/workflow-script-store.d.ts.map +1 -0
  691. package/dist/orchestration/workflow-script-store.js +133 -0
  692. package/dist/orchestration/workflow-script-store.js.map +1 -0
  693. package/dist/orchestration/workflow-size-guideline.d.ts +50 -0
  694. package/dist/orchestration/workflow-size-guideline.d.ts.map +1 -0
  695. package/dist/orchestration/workflow-size-guideline.js +78 -0
  696. package/dist/orchestration/workflow-size-guideline.js.map +1 -0
  697. package/dist/orchestration/workflow.d.ts +624 -0
  698. package/dist/orchestration/workflow.d.ts.map +1 -0
  699. package/dist/orchestration/workflow.js +1596 -0
  700. package/dist/orchestration/workflow.js.map +1 -0
  701. package/dist/prompts/default.d.ts +474 -0
  702. package/dist/prompts/default.d.ts.map +1 -0
  703. package/dist/prompts/default.js +676 -0
  704. package/dist/prompts/default.js.map +1 -0
  705. package/dist/prompts/supervisor.d.ts +60 -0
  706. package/dist/prompts/supervisor.d.ts.map +1 -0
  707. package/dist/prompts/supervisor.js +162 -0
  708. package/dist/prompts/supervisor.js.map +1 -0
  709. package/dist/scenarios/env.d.ts +41 -0
  710. package/dist/scenarios/env.d.ts.map +1 -0
  711. package/dist/scenarios/env.js +65 -0
  712. package/dist/scenarios/env.js.map +1 -0
  713. package/dist/scenarios/full-body.d.ts +47 -0
  714. package/dist/scenarios/full-body.d.ts.map +1 -0
  715. package/dist/scenarios/full-body.js +36 -0
  716. package/dist/scenarios/full-body.js.map +1 -0
  717. package/dist/scenarios/scenario-registry.d.ts +107 -0
  718. package/dist/scenarios/scenario-registry.d.ts.map +1 -0
  719. package/dist/scenarios/scenario-registry.js +192 -0
  720. package/dist/scenarios/scenario-registry.js.map +1 -0
  721. package/dist/scenarios/teacher-quickstart.d.ts +41 -0
  722. package/dist/scenarios/teacher-quickstart.d.ts.map +1 -0
  723. package/dist/scenarios/teacher-quickstart.js +32 -0
  724. package/dist/scenarios/teacher-quickstart.js.map +1 -0
  725. package/dist/server/http.d.ts +36 -0
  726. package/dist/server/http.d.ts.map +1 -0
  727. package/dist/server/http.js +144 -0
  728. package/dist/server/http.js.map +1 -0
  729. package/dist/stores/file/checkpoint-store.d.ts +62 -0
  730. package/dist/stores/file/checkpoint-store.d.ts.map +1 -0
  731. package/dist/stores/file/checkpoint-store.js +250 -0
  732. package/dist/stores/file/checkpoint-store.js.map +1 -0
  733. package/dist/stores/file/file-snapshot-store.d.ts +43 -0
  734. package/dist/stores/file/file-snapshot-store.d.ts.map +1 -0
  735. package/dist/stores/file/file-snapshot-store.js +374 -0
  736. package/dist/stores/file/file-snapshot-store.js.map +1 -0
  737. package/dist/stores/file/fs-atomic.d.ts +95 -0
  738. package/dist/stores/file/fs-atomic.d.ts.map +1 -0
  739. package/dist/stores/file/fs-atomic.js +415 -0
  740. package/dist/stores/file/fs-atomic.js.map +1 -0
  741. package/dist/stores/file/index.d.ts +98 -0
  742. package/dist/stores/file/index.d.ts.map +1 -0
  743. package/dist/stores/file/index.js +109 -0
  744. package/dist/stores/file/index.js.map +1 -0
  745. package/dist/stores/file/memory-store.d.ts +95 -0
  746. package/dist/stores/file/memory-store.d.ts.map +1 -0
  747. package/dist/stores/file/memory-store.js +393 -0
  748. package/dist/stores/file/memory-store.js.map +1 -0
  749. package/dist/stores/file/session-policy-store.d.ts +24 -0
  750. package/dist/stores/file/session-policy-store.d.ts.map +1 -0
  751. package/dist/stores/file/session-policy-store.js +123 -0
  752. package/dist/stores/file/session-policy-store.js.map +1 -0
  753. package/dist/stores/file/session-store.d.ts +34 -0
  754. package/dist/stores/file/session-store.d.ts.map +1 -0
  755. package/dist/stores/file/session-store.js +175 -0
  756. package/dist/stores/file/session-store.js.map +1 -0
  757. package/dist/stores/file/tool-result-store.d.ts +23 -0
  758. package/dist/stores/file/tool-result-store.d.ts.map +1 -0
  759. package/dist/stores/file/tool-result-store.js +61 -0
  760. package/dist/stores/file/tool-result-store.js.map +1 -0
  761. package/dist/stores/file/workflow-journal-store.d.ts +36 -0
  762. package/dist/stores/file/workflow-journal-store.d.ts.map +1 -0
  763. package/dist/stores/file/workflow-journal-store.js +157 -0
  764. package/dist/stores/file/workflow-journal-store.js.map +1 -0
  765. package/dist/stores/file/workflow-run-store.d.ts +58 -0
  766. package/dist/stores/file/workflow-run-store.d.ts.map +1 -0
  767. package/dist/stores/file/workflow-run-store.js +178 -0
  768. package/dist/stores/file/workflow-run-store.js.map +1 -0
  769. package/dist/stores/pg.d.ts +210 -0
  770. package/dist/stores/pg.d.ts.map +1 -0
  771. package/dist/stores/pg.js +652 -0
  772. package/dist/stores/pg.js.map +1 -0
  773. package/dist/tools/fs/encoding.d.ts +51 -0
  774. package/dist/tools/fs/encoding.d.ts.map +1 -0
  775. package/dist/tools/fs/encoding.js +74 -0
  776. package/dist/tools/fs/encoding.js.map +1 -0
  777. package/dist/tools/fs/gh-rate-limit.d.ts +19 -0
  778. package/dist/tools/fs/gh-rate-limit.d.ts.map +1 -0
  779. package/dist/tools/fs/gh-rate-limit.js +35 -0
  780. package/dist/tools/fs/gh-rate-limit.js.map +1 -0
  781. package/dist/tools/fs/index.d.ts +246 -0
  782. package/dist/tools/fs/index.d.ts.map +1 -0
  783. package/dist/tools/fs/index.js +2130 -0
  784. package/dist/tools/fs/index.js.map +1 -0
  785. package/dist/tools/fs/pdf.d.ts +106 -0
  786. package/dist/tools/fs/pdf.d.ts.map +1 -0
  787. package/dist/tools/fs/pdf.js +178 -0
  788. package/dist/tools/fs/pdf.js.map +1 -0
  789. package/dist/tools/fs/repo-map.d.ts +15 -0
  790. package/dist/tools/fs/repo-map.d.ts.map +1 -0
  791. package/dist/tools/fs/repo-map.js +251 -0
  792. package/dist/tools/fs/repo-map.js.map +1 -0
  793. package/dist/tools/fs/safety.d.ts +168 -0
  794. package/dist/tools/fs/safety.d.ts.map +1 -0
  795. package/dist/tools/fs/safety.js +479 -0
  796. package/dist/tools/fs/safety.js.map +1 -0
  797. package/dist/tools/fs/search.d.ts +118 -0
  798. package/dist/tools/fs/search.d.ts.map +1 -0
  799. package/dist/tools/fs/search.js +650 -0
  800. package/dist/tools/fs/search.js.map +1 -0
  801. package/dist/tools/gitea-issue.d.ts +39 -0
  802. package/dist/tools/gitea-issue.d.ts.map +1 -0
  803. package/dist/tools/gitea-issue.js +91 -0
  804. package/dist/tools/gitea-issue.js.map +1 -0
  805. package/dist/tools/monitor.d.ts +64 -0
  806. package/dist/tools/monitor.d.ts.map +1 -0
  807. package/dist/tools/monitor.js +135 -0
  808. package/dist/tools/monitor.js.map +1 -0
  809. package/dist/tools/scheduler-tools.d.ts +48 -0
  810. package/dist/tools/scheduler-tools.d.ts.map +1 -0
  811. package/dist/tools/scheduler-tools.js +449 -0
  812. package/dist/tools/scheduler-tools.js.map +1 -0
  813. package/dist/tools/sql-adapters.d.ts +32 -0
  814. package/dist/tools/sql-adapters.d.ts.map +1 -0
  815. package/dist/tools/sql-adapters.js +31 -0
  816. package/dist/tools/sql-adapters.js.map +1 -0
  817. package/dist/tools/sql.d.ts +36 -0
  818. package/dist/tools/sql.d.ts.map +1 -0
  819. package/dist/tools/sql.js +131 -0
  820. package/dist/tools/sql.js.map +1 -0
  821. package/dist/tools/task-list.d.ts +26 -0
  822. package/dist/tools/task-list.d.ts.map +1 -0
  823. package/dist/tools/task-list.js +352 -0
  824. package/dist/tools/task-list.js.map +1 -0
  825. package/dist/tools/todo.d.ts +11 -0
  826. package/dist/tools/todo.d.ts.map +1 -0
  827. package/dist/tools/todo.js +126 -0
  828. package/dist/tools/todo.js.map +1 -0
  829. package/dist/tools/web.d.ts +59 -0
  830. package/dist/tools/web.d.ts.map +1 -0
  831. package/dist/tools/web.js +607 -0
  832. package/dist/tools/web.js.map +1 -0
  833. package/dist/tools/worktree.d.ts +88 -0
  834. package/dist/tools/worktree.d.ts.map +1 -0
  835. package/dist/tools/worktree.js +434 -0
  836. package/dist/tools/worktree.js.map +1 -0
  837. package/docs/A1-ATTRIBUTION-ROUND1-2026-07-09.md +181 -0
  838. package/docs/AB-ORACLE-GATE-VERDICT-2026-07-10.md +100 -0
  839. package/docs/ANCHOR-GAP-INVENTORY-2026-07-09.md +143 -0
  840. package/docs/ARCHITECTURE.md +125 -0
  841. package/docs/BUGHUNT-2026-07-05-NIGHT.md +98 -0
  842. package/docs/BUILD.md +63 -0
  843. package/docs/CANCEL-ASYNC-ATTRIBUTION-2026-07-09.md +98 -0
  844. package/docs/CC-198-VS-201-DRIFT-2026-07-09.md +70 -0
  845. package/docs/CC-BASELINE-FAKE-ZERO-AUDIT-2026-07-09.md +51 -0
  846. package/docs/CC-DRIFT-198-206-AGENT-TYPES-2026-07-10.md +82 -0
  847. package/docs/CC-DRIFT-198-206-CHANGELOG-2026-07-10.md +82 -0
  848. package/docs/CC-DRIFT-198-206-REMINDERS-2026-07-10.md +96 -0
  849. package/docs/CC-DRIFT-198-206-ROADMAP-2026-07-10.md +110 -0
  850. package/docs/CC-DRIFT-198-206-TOOL-INVENTORY-2026-07-10.md +53 -0
  851. package/docs/CC-DRIFT-198-206-USAGE-CONSTANTS-2026-07-10.md +64 -0
  852. package/docs/CC-DRIFT-198-206-WORKFLOW-2026-07-10.md +83 -0
  853. package/docs/CC-PARITY-CHECKLIST.md +220 -0
  854. package/docs/CC-PARITY-DEEP-SWEEP-2026-07-07.md +149 -0
  855. package/docs/CC-SUBAGENT-PROMPT-STUDY-2026-07-09.md +219 -0
  856. package/docs/CC-TASK-REMINDER-CADENCE-STUDY-2026-07-09.md +396 -0
  857. package/docs/CC-TOOL-PARITY-SWEEP-2026-07.md +56 -0
  858. package/docs/CC-TOOL-PROMPT-DEEP-DIFF-2026-07-08.md +274 -0
  859. package/docs/CC-TOOLSURFACE-DEEP-DIVE-2026-07-07.md +197 -0
  860. package/docs/CC206-OBSERVER-ANCHORS-2026-07-11.md +463 -0
  861. package/docs/CC207-CORE-SWEEP-BACKLOG-2026-07-12.md +155 -0
  862. package/docs/CENTER-CONTROL-PLANE-SEAM.md +86 -0
  863. package/docs/CLEANUP-CONTRACT-ANCHOR-2026-07-09.md +83 -0
  864. package/docs/COLD-START-RSI-2026-07-09.md +72 -0
  865. package/docs/COMPACTION-LIVE-AUDIT-2026-07-09.md +114 -0
  866. package/docs/CONTEXT-lifecycle-extension-points-and-stop-attribution.md +71 -0
  867. package/docs/COREWARS-POLYGLOT-TRIAGE-2026-07-09.md +85 -0
  868. package/docs/DEADTARGET-OVERFULL-HBOX-2026-07-10.md +92 -0
  869. package/docs/DEADTARGET-PATH-TRACING-REVERSE-2026-07-10.md +87 -0
  870. package/docs/DEADTARGET-WINNING-AVG-COREWARS-2026-07-10.md +96 -0
  871. package/docs/DEEPSEEK-API-LIMITS-PROBE-2026-07-09.md +118 -0
  872. package/docs/DESIGN-140-MOCK-VERIFY-2026-07-11.md +79 -0
  873. package/docs/DESIGN-140-VERIFICATION-2026-07-11.md +43 -0
  874. package/docs/DESIGN-DRAFT-workflow-collab-2026-07-11.md +56 -0
  875. package/docs/DESIGN-ORACLE-GROUNDING-GATE-2026-07-10.md +167 -0
  876. package/docs/DESIGN-TFRAG-RECONCILE-SCAFFOLD-2026-07-09.md +102 -0
  877. package/docs/EXP-PA2-PE-DESIGN-2026-07-11.md +220 -0
  878. package/docs/EXT-DOGFOOD-2026-07-12.md +50 -0
  879. package/docs/FINAL-VERIFY-SURFACE-AUDIT-2026-07-09.md +44 -0
  880. package/docs/FRAG-RECON-INVESTIGATION-2026-07-09.md +183 -0
  881. package/docs/HANDOFF.md +121 -0
  882. package/docs/KNOWN-ISSUES.md +96 -0
  883. package/docs/LEGW-WINDOW-TAX-2026-07-10.md +82 -0
  884. package/docs/LIVE-PROBE-BATCH-2026-07-11.md +207 -0
  885. package/docs/M2-CERT-ATTRIBUTION-2026-07-10.md +61 -0
  886. package/docs/M25-LEG-VARIANCE-ATTRIBUTION-2026-07-10.md +81 -0
  887. package/docs/MAILMAN-TIMEOUT-ATTRIBUTION-2026-07-10.md +88 -0
  888. package/docs/MEMORY-BACKEND-DIALECT-NOTES.md +69 -0
  889. package/docs/MTEB-ESCAPE-HATCH-ATTRIBUTION-2026-07-09.md +57 -0
  890. package/docs/MTEB-LEADERBOARD-ATTRIBUTION-2026-07-10.md +134 -0
  891. package/docs/ORACLE-GATE-NEGATIVE-SAMPLE-2026-07-11.md +28 -0
  892. package/docs/PARITY-CONTEXT-TRANSACTIONS-2026-07-10.md +67 -0
  893. package/docs/PARITY-PROGRESS-MATRIX-2026-07-10.md +37 -0
  894. package/docs/PARITY-SPOT-AGENT-TYPES-2026-07-10.md +70 -0
  895. package/docs/PARITY-SPOT-REMINDERS-2026-07-10.md +144 -0
  896. package/docs/PARITY-SPOT-TOOL-INVENTORY-2026-07-10.md +61 -0
  897. package/docs/PARITY-SPOT-USAGE-CONSTANTS-2026-07-10.md +95 -0
  898. package/docs/PARITY-SPOT-WORKFLOW-2026-07-10.md +93 -0
  899. package/docs/PASSWORD-RECOVERY-PANEL-VERDICT-2026-07-10.md +62 -0
  900. package/docs/R8-R9-FINALIZE-COORDINATION-2026-07-09.md +124 -0
  901. package/docs/REPL-CODE-MODE-EVALUATION-2026-07-10.md +187 -0
  902. package/docs/REPORT-1249-TARGETED.md +48 -0
  903. package/docs/RESEARCH-3P-MEMORY-SWAP-2026-07-12.md +273 -0
  904. package/docs/ROADMAP.md +111 -0
  905. package/docs/RSI-CLOUD-DEPLOY-2026-07-06.md +289 -0
  906. package/docs/RSI-PROCESS-AI-COLDSTART-2026-07-11.md +68 -0
  907. package/docs/S18-THOROUGHNESS-AUDIT-2026-07-09.md +113 -0
  908. package/docs/SERVICE-INTEGRATION-GUIDE.md +298 -0
  909. package/docs/SUBAGENT-STREAM-RESILIENCE-PARITY-2026-07-10.md +118 -0
  910. package/docs/SWEBENCH-PRO-RECON-2026-07-11.md +31 -0
  911. package/docs/TB-EFFICIENCY-TELEMETRY-2026-07-08.md +114 -0
  912. package/docs/TB-FAILURE-AUTOPSY-2026-07-05-RERUN.md +67 -0
  913. package/docs/TB-FAIRNESS-AUDIT-2026-07-08.md +141 -0
  914. package/docs/TB-FULL-1257-2026-07-08.md +77 -0
  915. package/docs/TB-LEG-GAP-ATTRIBUTION-1261-1263-2026-07-11.md +154 -0
  916. package/docs/TB-REGRESSION-DAEMON-KILL-2026-07-08.md +58 -0
  917. package/docs/TB-RSI-CROSSCHECK-2026-07-06.md +68 -0
  918. package/docs/TB-RSI-LEDGER.md +30 -0
  919. package/docs/TB-STABLE-SET-2026-07-08.md +127 -0
  920. package/docs/TB21-RECON-2026-07-11.md +28 -0
  921. package/docs/TEST-DESIGN-BATCH-1-2-3.md +97 -0
  922. package/docs/THIRD-PARTY-INTEGRATION.md +45 -0
  923. package/docs/TOKEN-ESTIMATE-CALIBRATION-LIVE-2026-07-09.md +102 -0
  924. package/docs/audit-2026-06-10-/347/254/254/344/272/214/350/275/256-/347/251/272/347/231/275/345/214/272/344/270/216/345/277/230/346/216/245/347/272/277.md +147 -0
  925. package/docs/audit-2026-06-10-/350/256/241/347/256/227bug/346/211/253/346/217/217.md +163 -0
  926. package/docs/audit-2026-06-11-/344/277/256/345/244/215/345/244/215/345/256/241/344/270/216/345/233/236/345/275/222/344/270/223/346/211/253.md +95 -0
  927. package/docs/audit-2026-06-12-design71-P1/346/275/234/344/274/217/351/235/242/350/243/201/345/206/263/344/270/216/345/220/236/351/224/231/345/256/241/350/256/241.md +45 -0
  928. package/docs/audit-2026-06-12-/345/205/254/345/274/200/346/227/213/351/222/256/346/264/273/346/200/247/344/270/216vendored/346/255/273/350/267/257.md +63 -0
  929. package/docs/audit-2026-06-15/ADDENDUM-second-pass.md +88 -0
  930. package/docs/audit-2026-06-15/ARCHITECTURE-AND-ROADMAP.md +187 -0
  931. package/docs/audit-2026-06-15/DOC-CORRECTIONS.md +105 -0
  932. package/docs/audit-2026-06-15/INDEX.md +49 -0
  933. package/docs/audit-2026-06-15/ORCHESTRATION-STATUS.md +98 -0
  934. package/docs/audit-2026-06-15/PRODUCTION-ISSUES.md +189 -0
  935. package/docs/audit-2026-06-15/REFERENCES-literature.md +139 -0
  936. package/docs/audit-2026-06-15/VENDOR-REFACTOR-PLAN.md +96 -0
  937. package/docs/cc-probe-198/README.md +72 -0
  938. package/docs/cc-probe-198/STABILITY-AUDIT.md +118 -0
  939. package/docs/cc-probe-198/live-schema-monitor-worktree-2026-07-09.json +56 -0
  940. package/docs/cc-probe-198/mount-variants.json +536 -0
  941. package/docs/cc-probe-198/plan-mode-run.json +1 -0
  942. package/docs/cc-probe-198/probe-bash-bg.json +224 -0
  943. package/docs/cc-probe-198/probe-edit-mismatch.json +120 -0
  944. package/docs/cc-probe-198/probe-read-missing.json +62 -0
  945. package/docs/cc-probe-198/probe-search-empty.json +127 -0
  946. package/docs/cc-probe-198/probe-task-family.json +227 -0
  947. package/docs/cc-probe-198/probes.status +6 -0
  948. package/docs/cc-probe-198/raw-capture-interactive.json +948 -0
  949. package/docs/cc-probe-198/raw-capture-print.json +787 -0
  950. package/docs/cc-probe-198/raw-capture2-interactive.json +948 -0
  951. package/docs/cc-probe-198/raw-capture2-print.json +787 -0
  952. package/docs/cc-probe-198/system-prompt.txt +135 -0
  953. package/docs/cc-probe-198/tools-array.json +962 -0
  954. package/docs/durability-boundary.md +63 -0
  955. package/docs/fix-2026-06-10-/350/256/241/347/256/227bug/344/277/256/345/244/215-search/346/211/271.md +75 -0
  956. package/docs/fix-2026-06-11-/347/254/254/344/272/214/350/275/256-/344/277/256/345/244/215-search/346/211/271.md +62 -0
  957. package/docs/parity-sweep/REPORT-DYNAMIC.md +97 -0
  958. package/docs/parity-sweep/REPORT-STATIC-GGB.md +127 -0
  959. package/docs/parity-sweep/REPORT-STATIC-RWE.md +133 -0
  960. package/docs/vendor-history.md +385 -0
  961. package/docs/vendored-upstream-diff-2026-07.md +83 -0
  962. package/package.json +70 -0
@@ -0,0 +1,3612 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { AgentHarness, DEFAULT_CHARS_PER_TOKEN, estimateContextTokens } from "../../internal/harness.js";
3
+ // Per-process salt for the `prompt.assembled` manifest hashes (codex [E]: dictionary-preimage guard for
4
+ // short/guessable blocks). Process-lifetime constant → same-process drift detection works; never persisted.
5
+ const PROMPT_HASH_SALT = randomBytes(16);
6
+ import { resolveModel, resolveTaskModel, roleModelIfSet } from "../roles.js";
7
+ import { primaryActivityArg } from "../arg-summary.js";
8
+ import { materializeMcpTools } from "../mcp.js";
9
+ import { brainToRuntime } from "../runtime.js";
10
+ import { MemoryEngine } from "../memory-engine/engine.js";
11
+ import { deriveControlPlaneDir, deriveProjectControlDir, deriveProjectMemoryDir, deriveRepoMemoryDir, drainMemoryAnnouncements, enqueueMemoryAnnouncement, lookupProjectIdHint, recordProjectIdHint, resolveMemoryEngineRoot } from "../memory-engine/layout.js";
12
+ import { assertScopeContractPlacement, parseScopeKey, resolveProjectId } from "../memory-engine/scope-contract.js";
13
+ import { classifyScopePlanes, derivePersonalControlDir, derivePersonalMemoryDir, mergeHarvestReports, mergeInjections, needsDualRoots, parsedProjectPlane } from "../memory-engine/dual-root.js";
14
+ import { FileMemoryEngineBackend } from "../memory-engine/file-backend.js";
15
+ import { Session, isSessionConflict, hasSessionFork } from "../session.js";
16
+ import { createAgentTranscriptTool, createSendMessageTool, createSubagentWorktreeHelper, forkGovernanceDenial, AGENT_TRANSCRIPT_TOOL_NAME, SEND_MESSAGE_TOOL_NAME, SubagentRetainLedger } from "../../agents/subagent.js";
17
+ import { combinePolicies, createTranscriptIntegrityPolicy, createUnverifiableDeletePolicy, resolveAsk } from "../tool-policy.js";
18
+ import { ActiveSkillScope, createActiveSkillScopePolicy } from "./active-skill-scope.js";
19
+ import { computeCallCap, createCallCapRef, softExecDeadlineMs } from "./call-cap.js";
20
+ import { CHANGED_FILES_MTIME_EPS_MS } from "./turn-attachments.js";
21
+ import { emitTrace } from "../trace.js";
22
+ import { createSessionRulePolicy } from "./session-rule-policy.js";
23
+ import { cloneObserverInput, formatHookFeedback, runToolGate } from "../hooks.js";
24
+ import { reconcileInterruptedSession } from "../session-reconcile.js";
25
+ import { CacheBreakDetector, toolsToFingerprintInputs } from "../cache-break-detector.js";
26
+ import { reservedCollisions, reservedFor } from "../../brain/request-params.js";
27
+ import { defineTool } from "../tools.js";
28
+ import { pathToUri } from "../lsp-protocol.js";
29
+ import { DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, firstPartyOffloadPolicy, InMemoryToolResultStore, RunnerSharedToolResultStore, ScopedToolResultStore, isVolatileOffloadStore, OFFLOAD_TOOL_NAME, makeReadToolResultTool, withToolResultOffload, } from "../tool-result-store.js";
30
+ import { OUTPUT_TOOL_NAME, SKILL_TOOL_NAME, buildSkillsBlock, makeOutputTool, makeReportBlockedTool, makeSkillTool, normalizeSkills } from "./synthetic-tools.js";
31
+ import { compileOutputSchema } from "./strict-output-schema.js";
32
+ import { TOOL_SEARCH_NAME, buildDeferredRegistry, classifyDeferred, extractDiscoveredToolNames, makePlaceholderTool, makeToolSearchTool, } from "./tool-disclosure.js";
33
+ import { composeMemoryBlock, normalizeMemorySpec } from "../memory.js";
34
+ import {} from "./memory-consolidation.js";
35
+ import { composeSystemPrompt, composeConstitution, defaultPromptProvider, buildEnvironmentContext, buildGitSnapshot, formatLocalDate, isValidTimeZone, CYBER_RISK, URL_SAFETY, DEFAULT_SYSTEM_PROMPT, PROJECT_CONTEXT_FRAMING } from "../../prompts/default.js";
36
+ import { clearStaleToolResults, dropEmptyFailureAssistants, editBudget } from "../context-edit.js";
37
+ import { capAggregateToolResults } from "../tool-result-budget.js";
38
+ import { capAggregateMediaBytes, AGGREGATE_MEDIA_BUDGET_BYTES } from "../media-byte-cap.js";
39
+ import { dropOrphanToolResults, guardBudget, trimToBudget } from "../context-guard.js";
40
+ import { StubExecutionEnv } from "../stub-env.js";
41
+ import { hasDestroy, isIsolated, isRemoteExecutionEnv, isSuspendable } from "../remote-env.js";
42
+ import { hasBackgroundShell } from "../background-shell.js";
43
+ import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../task-registry.js";
44
+ import { createMonitorTool } from "../../tools/monitor.js";
45
+ import { createWorktreeTools } from "../../tools/worktree.js";
46
+ import { bashReversibilityProbe, createHandsToolkit, seedReadFileStateFromContext, HAND_TOOL_EFFECTS, pdfModelCapabilitiesOf } from "../../tools/fs/index.js";
47
+ import { decodeTextBytes } from "../../tools/fs/encoding.js";
48
+ import { createAskUserQuestionTool } from "../ask-question.js";
49
+ import { createSchedulerTools } from "../../tools/scheduler-tools.js";
50
+ import { createPresentPlanTool, createEnterPlanModeTool, PRESENT_PLAN_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME } from "../present-plan-tool.js";
51
+ import { isSelfOrchestrationActive, selfOrchestrationFailClosedReason } from "../../orchestration/workflow-script-runner.js";
52
+ import { createRunWorkflowTool, RUN_WORKFLOW_TOOL_NAME } from "../../orchestration/run-workflow-tool.js";
53
+ import { createLspTool, gitCheckIgnoreFilter } from "../lsp.js";
54
+ import { resolveKey } from "../../tools/fs/safety.js";
55
+ import { BINDING_CHECKPOINT_VERSION, mintCheckpointToken, RESOURCE_CHECKPOINT_VERSION, buildRiskDescriptor, debitLedger, remainingBudgetMicroUsd, } from "../checkpoint-store.js";
56
+ import { boundInputHashOf } from "../canonical-json.js";
57
+ /**
58
+ * How many times to re-wake + re-reconcile a resumed session when a concurrent writer (another
59
+ * instance) wins the optimistic lock during reconciliation. Bounded so a persistently-contended
60
+ * session fails fast (with `errorCode="conflict"`) rather than spinning.
61
+ */
62
+ const RECONCILE_MAX_RETRIES = 3;
63
+ /** design/72 §2.2 (B): default suspend-loop cap. Loose by design — a real human-approval flow re-suspends
64
+ * rarely, so 5 cleanly separates a normal resume (1–2 suspends) from a pathological restart loop (a model
65
+ * re-issuing the gated call every resume). A task/deployment that genuinely needs more overrides via
66
+ * `TaskSpec.maxSuspends` / `RunnerDeps.maxSuspends`. (Briefly raised to 100 on 2026-06-14, reverted 06-15 —
67
+ * same misguided net-inflation as DEFAULT_MAX_TURNS; the cap is a runaway guard, not the long-run knob.) */
68
+ const DEFAULT_MAX_SUSPENDS = 5;
69
+ /** design/74 (search [136] HIGH-1): default resource-SLICE cap — the resumable-long-run analogue of
70
+ * `DEFAULT_MAX_SUSPENDS`, kept SEPARATE so a legitimate multi-slice resource run (the whole point of
71
+ * design/74) is not capped by the restart-loop safety. High (the design/74 spec's worker-suspend ceiling, here implemented as maxSlices): the $ ledger /
72
+ * turns / walltime are the real bounds, this only backstops a $0-progress slice loop. Override via
73
+ * `TaskSpec.resourceSuspend.maxSlices` (the leader sizes it to budget/slice). */
74
+ const DEFAULT_MAX_SLICES = 100;
75
+ /** design/96 §D (S4, P0): the "ultra" reasoning tiers that warrant the high-intensity ORCHESTRATION_AWARENESS
76
+ * nudge — the TOP of the vendor ThinkingLevel ladder (off|minimal|low|medium|high|xhigh|max). Membership-based
77
+ * (not a `=== "max"` point-check) so a future top tier slots in by adding it here, and so the threshold reads
78
+ * as "ultra tiers" rather than a magic string. Caller-declared via the resolved `thinking`, never keyword-scanned. */
79
+ const ULTRA_REASONING_TIERS = new Set(["xhigh", "max"]);
80
+ /** design/74 (search [136] HIGH-2): default resource-checkpoint retention (30 days, the spec's
81
+ * `RESOURCE_SUSPEND_TTL_SEC`). Stamped onto the checkpoint `deadline` so the durable reaper can GC an
82
+ * ORPHANED resource suspend; a legitimate auto-resume fires long before this. Override via
83
+ * `TaskSpec.resourceSuspend.ttlMs`. */
84
+ const DEFAULT_RESOURCE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
85
+ /** design/80 D-2 (BLOCKER): abandonment TTL stamped on an UNATTENDED human/irreversible_ask suspend (no
86
+ * `durableApproval` = no operator/resolver wired). Without it the checkpoint has `deadline=undefined` and the
87
+ * reaper (which filters on a non-null deadline) can NEVER GC it → a pinned session + a stranded paused VM +
88
+ * an orphan checkpoint leak forever. A `durableApproval` suspend keeps the operator's `ttlMs` (undefined =
89
+ * unbounded by the operator's choice; D-D `terminalAt` is the eventual liveness backstop there). 30 days. */
90
+ const DEFAULT_UNATTENDED_APPROVAL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
91
+ /** 飞轮 [520]③ — per-deployment dedupe for the LOUD ungated-write warning below: keyed by the RunnerDeps
92
+ * object (a deployment's stable identity), holding the ungated tool-set shapes already warned about.
93
+ * WeakMap: a discarded deployment's entry goes with it. */
94
+ const ungatedWarnedShapes = new WeakMap();
95
+ /** design/77 §4.4: the multi-tenant scope used when a durable suspend fires for an IRREVERSIBLE tool in an
96
+ * unattended deployment that did NOT opt into `durableApproval` (so there is no caller-supplied scope). The
97
+ * checkpoint carries this scope; resume reads it back from the checkpoint (`cp.scope`), so it is
98
+ * self-consistent without needing the original `TaskSpec`. Kept distinct from any tenant key to make an
99
+ * unattended irreversible suspend auditable as such. */
100
+ export const DEFAULT_IRREVERSIBLE_SCOPE = "irreversible";
101
+ /**
102
+ * From the resumed/active transcript, the batch position of `currentId` (design/45 §4.ter): the tool-call
103
+ * ids of the assistant message that issued it (the batch), and the subset already resolved (executed
104
+ * before the suspend — #1..k-1). ID-based, not positional (council Question #1): immune to reordering.
105
+ */
106
+ function batchContextAt(messages, currentId) {
107
+ let batch = [];
108
+ for (const m of messages) {
109
+ if (m.role === "assistant") {
110
+ const ids = m.content.filter((c) => c.type === "toolCall").map((c) => c.id);
111
+ if (ids.includes(currentId))
112
+ batch = ids; // the latest assistant msg holding currentId wins
113
+ }
114
+ }
115
+ const resolved = new Set(messages.filter((m) => m.role === "toolResult").map((m) => m.toolCallId));
116
+ const completedCallIds = batch.filter((id) => id !== currentId && resolved.has(id));
117
+ return { batchToolCallIds: batch, completedCallIds };
118
+ }
119
+ /**
120
+ * Wrap a storage so a `SessionError("conflict")` from any append/leaf write flips `ref.hit`. The
121
+ * harness flattens a mid-run storage failure into a generic error (and, since a lost CAS makes every
122
+ * subsequent append fail, even its failure-reporting append fails) — so the conflict code is gone by
123
+ * the time the task result is assembled. Catching it here, at the storage boundary, keeps it
124
+ * recoverable as `TaskResult.errorCode = "conflict"`. Per-task and non-mutating (a Proxy over the
125
+ * shared storage), so it never stacks across tasks on a cached session.
126
+ */
127
+ function detectConflicts(storage, ref) {
128
+ const guard = (fn) => async (...args) => {
129
+ try {
130
+ return await fn(...args);
131
+ }
132
+ catch (err) {
133
+ if (isSessionConflict(err)) {
134
+ ref.hit = true;
135
+ }
136
+ throw err;
137
+ }
138
+ };
139
+ return new Proxy(storage, {
140
+ get(target, prop, receiver) {
141
+ const value = Reflect.get(target, prop, receiver);
142
+ if (typeof value !== "function") {
143
+ return value;
144
+ }
145
+ const bound = value.bind(target);
146
+ return prop === "appendEntry" || prop === "setLeafId"
147
+ ? guard(bound)
148
+ : bound;
149
+ },
150
+ });
151
+ }
152
+ /** Race a promise against an abort signal; on abort, resolve with `onAbort()`. Cleans up its listener. */
153
+ function raceAbort(p, signal, onAbort) {
154
+ if (signal.aborted)
155
+ return Promise.resolve(onAbort());
156
+ return new Promise((resolve) => {
157
+ let settled = false;
158
+ const finish = (v) => {
159
+ if (settled)
160
+ return;
161
+ settled = true;
162
+ // Necessary even with `{ once }`: `p` usually settles first, leaving the listener dangling on the
163
+ // signal (a leaked closure per check) until the signal is GC'd. Remove it explicitly.
164
+ signal.removeEventListener("abort", onAbortEvent);
165
+ resolve(v);
166
+ };
167
+ const onAbortEvent = () => finish(onAbort());
168
+ signal.addEventListener("abort", onAbortEvent, { once: true });
169
+ p.then(finish, () => finish(onAbort()));
170
+ });
171
+ }
172
+ /**
173
+ * Build everything a task run needs (council design/34 ②: a free function with EXPLICIT deps, not a
174
+ * Runner method — testable and decoupled). Resolves the model/role/thinking, acquires + reconciles the
175
+ * session (with the conflict-detecting Proxy + bounded re-wake on a lost CAS), assembles the tool set
176
+ * (caller tools + offload wrap + report_blocked + submit_output + MCP + memory + read_tool_result),
177
+ * builds the system prompt, constructs the harness, wires the tool-policy gate + context-edit/guard
178
+ * hooks + external-signal cancellation, and sets up the cache-break detector. **No run-loop control
179
+ * flow** lives here — that stays in the Runner loop.
180
+ */
181
+ /** Best-effort cached-view drop on a prepare-phase throw (B-17-safe: `forget` only — NEVER `release`,
182
+ * whose contract permits history deletion). No-op for stores without `forget`. */
183
+ async function forgetQuietly(sessions, sessionId) {
184
+ try {
185
+ if (sessions.forget)
186
+ await sessions.forget(sessionId);
187
+ }
188
+ catch {
189
+ /* best-effort; the TTL sweep is the backstop */
190
+ }
191
+ }
192
+ export async function prepareTask(spec, deps, sessions, resume,
193
+ /** @deprecated design/138 S4 — served only the legacy selective/dynamic recall paths (retired);
194
+ * ignored. Positional slot kept one major so the runtask call site compiles unchanged. */
195
+ _memorySelector, internals,
196
+ /** design/98 §3.1 (S8c): a TRUSTED self-reference to the Runner, passed by the Runner itself (never a
197
+ * TaskSpec field) so the `run_workflow` tool can execute child tasks via `runner.runTask`. Undefined when
198
+ * prepareTask is exercised standalone (then run_workflow is simply not mounted). */
199
+ runnerSelf) {
200
+ // design/99 §E18: config guards BEFORE acquiring anything (a throw here needs no session cleanup). resume-at
201
+ // needs a session to branch, and cannot combine with a durable `resume` (which sets the leaf from its checkpoint).
202
+ if (spec.resumeAt !== undefined) {
203
+ // Order matters: a durable `resume` already sets the leaf from its checkpoint, so resume+resumeAt is a caller
204
+ // bug REGARDLESS of sessionId — check it FIRST (the resume path may not have populated spec.sessionId yet).
205
+ if (resume) {
206
+ const e = new Error("resumeAt cannot be combined with a durable resume");
207
+ e.code = "resume_at.conflicts_resume";
208
+ throw e;
209
+ }
210
+ if (!spec.sessionId) {
211
+ const e = new Error("resumeAt requires a sessionId (the session to branch)");
212
+ e.code = "resume_at.no_session";
213
+ throw e;
214
+ }
215
+ }
216
+ // Resolve the model (and a role-supplied default thinking level) from spec.model or the role map.
217
+ const resolvedRole = resolveTaskModel(spec, deps);
218
+ const model = resolvedRole.model;
219
+ // design/111 — thinking resolution chain: caller-explicit > role default > MODEL default > undefined(off).
220
+ // `model.defaultThinking` lets a model "think by default" without every task/role re-specifying; the
221
+ // per-endpoint clamp (`resolveReasoning`) still bounds the resolved tier.
222
+ const thinking = spec.thinking ?? resolvedRole.thinking ?? model.defaultThinking;
223
+ // Compaction model: explicit wins; otherwise the `summarize` role if configured; else fall back
224
+ // to the main model inside maybeCompact (unchanged).
225
+ const compModel = spec.compactionModel
226
+ ? resolveModel(spec.compactionModel, deps.models)
227
+ : roleModelIfSet("summarize", spec, deps);
228
+ // Acquire the session and, when resuming, reconcile any interrupted (orphan) tool call before
229
+ // the turn. Both are retried together on a F2 optimistic-lock conflict: a concurrent writer on
230
+ // another instance can move the leaf during reconcile's append, and the fix can't be applied to
231
+ // a now-stale in-memory view — so we drop it and **re-wake**. Bounded; exhaustion → the conflict
232
+ // propagates and the task fails with `errorCode="conflict"` for the caller to retry.
233
+ const toolEffects = new Map();
234
+ // design/70: egress-marked tools (external writes — the gate never auto-allows them). Collected on the
235
+ // same pass as effects; validated here (BEFORE session acquire, so the throw needs no cleanup).
236
+ const egressTools = new Set();
237
+ // design/77 §4 (Gate 4): tools whose effect is irreversible (`always`, or `maybe` either explicitly or by
238
+ // declaring a `reversibilityProbe`). Collected on the SAME pass as egress — this set drives BOTH activation
239
+ // predicates (a no-policy/no-hook/no-egress unattended deployment must still register the gate for an
240
+ // irreversible tool, else it would silently auto-allow) and the per-call `irreversibility` gate wiring.
241
+ const irreversibleTools = new Set();
242
+ // Static tier resolution (read from the spec at prepare-time — not a runtime arg, so the model cannot
243
+ // monkey-patch it): an explicit `irreversibility`, else `"maybe"` when a `reversibilityProbe` is declared.
244
+ const irreversibilityTier = new Map();
245
+ const reversibilityProbes = new Map();
246
+ // design/80 D-E: set true when `bash` was tightened SOLELY by the coarse `shellGate` (no explicit per-tool
247
+ // mark) — drives the mint-site riskDescriptor's `shell` axis (severity 3, NOT the irreversible-marked 4).
248
+ let shellGatedBash = false;
249
+ // design/135 G2: same coarse-shellGate marker for the Monitor tool (its `command` is an arbitrary shell
250
+ // script — the tighten and the riskDescriptor grading mirror Bash exactly; 修类不修点).
251
+ let shellGatedMonitor = false;
252
+ for (const t of spec.tools ?? []) {
253
+ // design/110 F1 (① MCP-namespace alignment): a caller tool name MUST NOT contain `__`. That separator is
254
+ // reserved for the MCP namespace (`mcp__<server>__<tool>`); allowing it lets `canonicalToolName`'s old→new
255
+ // durable-resume heuristic mis-rewrite a persisted `my__tool` into `mcp__my__tool`, which never matches the
256
+ // live roster → a stranded orphan tool-call / approval across an upgrade. Reject up front — this runs BEFORE
257
+ // session acquire, so the throw needs no cleanup. (Injected first-party tools are added later, not via spec.tools.)
258
+ if (t.name.includes("__")) {
259
+ const e = new Error(`Tool name "${t.name}" is invalid: "__" is reserved for the MCP namespace (mcp__<server>__<tool>) and must not appear in a caller tool name.`);
260
+ e.code = "config.tool_name_invalid";
261
+ throw e;
262
+ }
263
+ if (t.effect) {
264
+ toolEffects.set(t.name, t.effect);
265
+ }
266
+ const tier = t.irreversibility ?? (t.reversibilityProbe ? "maybe" : undefined);
267
+ if (tier !== undefined) {
268
+ irreversibilityTier.set(t.name, tier);
269
+ if (tier === "always" || tier === "maybe")
270
+ irreversibleTools.add(t.name);
271
+ }
272
+ if (t.reversibilityProbe)
273
+ reversibilityProbes.set(t.name, t.reversibilityProbe);
274
+ if (t.egress) {
275
+ // design/70 council minor#1: an egress tool must be repeat-unsafe. An explicit read/idempotent
276
+ // would let wake/resume reconciliation auto-retry an interrupted EXTERNAL write (e.g. open the
277
+ // same PR twice). Omitted `effect` is fine — the default IS `write`.
278
+ if (t.effect !== undefined && t.effect !== "write") {
279
+ const e = new Error(`Tool "${t.name}" declares egress:true with effect:"${t.effect}" — an egress tool (external write) must have effect:"write" (or omit effect; write is the default).`);
280
+ e.code = "config.egress_requires_write_effect";
281
+ throw e;
282
+ }
283
+ egressTools.add(t.name);
284
+ }
285
+ }
286
+ // The injected reader is read-only; register it up front so an interrupted call reconciles as
287
+ // safe-to-retry instead of falling to the conservative "write" default (the tool is added later).
288
+ toolEffects.set(OFFLOAD_TOOL_NAME, "read");
289
+ // Same for the hand band (design/44): register every hand tool's effect up front (the tools mount
290
+ // later) so an interrupted hand call reconciles by its true effect, not the conservative "write".
291
+ // A per-task `executionEnvFactory` (design/48 remote seam) activates the hand exactly like a static
292
+ // `executionEnv`, so gate on either — this only needs to know the hand is enabled, not the env instance.
293
+ if (deps.executionEnvFactory || deps.executionEnv) {
294
+ for (const [name, effect] of Object.entries(HAND_TOOL_EFFECTS))
295
+ toolEffects.set(name, effect);
296
+ // Q6 (design/102 §3.6, release-gate review): `bash` + `bash_readonly` merged to the single name "Bash" with one
297
+ // `HAND_TOOL_EFFECTS` Bash:"write". Under `handsReadOnly` the MOUNTED Bash is the read-only band (effect:read).
298
+ // Set its effect HERE — before the wake-time `reconcileInterruptedSession` (~:683) AND the path/skill policies
299
+ // (active-skill-scope / session-rule-policy) read `toolEffects` — else a read-only Bash orphan is mis-classified
300
+ // as write (over-conservative reconcile note) and a read-only Bash command is FALSE-DENIED under allowPaths
301
+ // confinement (each Bash def still carries its own `effect` for the live gate; this is the by-name fallback).
302
+ if (spec.handsReadOnly === true)
303
+ toolEffects.set("Bash", "read");
304
+ }
305
+ // design/108 (review): register the plan pair's effects HERE — BEFORE the wake-time `reconcileInterruptedSession`
306
+ // (~:690), same as Bash above — so an orphaned present_plan/enter_plan_mode call (both effect:"read") reconciles
307
+ // as INTERRUPTED_SAFE, not mis-classified write→UNKNOWN. (The post-assembly sync below also covers them for the
308
+ // live plan-mode gate; this early set is purely for reconcile/path-policy reads that run before tool assembly.)
309
+ if (spec.enablePlanMode === true) {
310
+ toolEffects.set(PRESENT_PLAN_TOOL_NAME, "read");
311
+ toolEffects.set(ENTER_PLAN_MODE_TOOL_NAME, "read");
312
+ }
313
+ // design/114 Phase3 (M2): `requireExistingSession` without a `sessionId` is a caller error — there is no
314
+ // session to require. Silently creating a fresh one is exactly the footgun the flag exists to prevent, so
315
+ // fail loud with the same code a genuinely-missing session yields.
316
+ if (spec.requireExistingSession && !spec.sessionId) {
317
+ const e = new Error(`requireExistingSession requires a sessionId — cannot require an existing session without one (design/114 Phase3)`);
318
+ e.code = "resume.session_not_found";
319
+ throw e;
320
+ }
321
+ let acquired;
322
+ let session;
323
+ let conflictRef;
324
+ for (let attempt = 0;; attempt++) {
325
+ try {
326
+ // design/114 Phase3: `requireExistingSession` makes acquire FAIL LOUD on a missing session (never the
327
+ // silent create-on-miss that turns a reuse warm-resume into a fresh empty run). Map the store's `not_found`
328
+ // to a clear, caller-facing errorCode rather than a raw storage error.
329
+ acquired = await sessions.acquire(spec.sessionId, spec.requireExistingSession ? { requireExisting: true } : undefined);
330
+ }
331
+ catch (err) {
332
+ if (spec.requireExistingSession && err?.code === "not_found") {
333
+ const e = new Error(`requireExistingSession: session "${spec.sessionId}" does not exist — refusing a silent fresh run (design/114 Phase3)`);
334
+ e.code = "resume.session_not_found";
335
+ throw e;
336
+ }
337
+ throw err;
338
+ }
339
+ // Wrap the storage so a lost-CAS conflict on any append is recoverable (the harness would
340
+ // otherwise flatten it). Per-task Proxy; the underlying storage is unchanged.
341
+ conflictRef = { hit: false };
342
+ session = new Session(detectConflicts(acquired.session.getStorage(), conflictRef));
343
+ if (!spec.sessionId) {
344
+ break; // fresh session — nothing persisted yet, nothing to reconcile
345
+ }
346
+ try {
347
+ if (resume) {
348
+ // design/45 resume: rewind the branch leaf to the suspension point BEFORE reconcile. This
349
+ // discards the suspend abort's off-branch writes, and (with `suspendedBatch`) makes wake-
350
+ // reconcile SKIP the pending batch instead of orphan-closing it — the resume engine resolves
351
+ // those calls itself. Goes through the conflict proxy, so a lost CAS re-wakes like any append.
352
+ await session.getStorage().setLeafId(resume.leafId);
353
+ }
354
+ else if (spec.resumeAt !== undefined) {
355
+ // design/99 §E18: branch the session at an arbitrary prior MESSAGE entry, then run the new objective as
356
+ // the next turn. Validate the target is a real message-boundary entry on THIS session first (a non-
357
+ // message entry would make the reconcile below orphan-close a mid-batch call). `setLeafId` is
358
+ // non-destructive (appends a `leaf` entry) and goes through the same conflict proxy as a durable resume.
359
+ const entry = await session.getEntry(spec.resumeAt);
360
+ if (!entry) {
361
+ const e = new Error(`resumeAt entry "${spec.resumeAt}" not found in session "${spec.sessionId}"`);
362
+ e.code = "resume_at.not_found";
363
+ throw e;
364
+ }
365
+ if (entry.type !== "message" && entry.type !== "custom_message") {
366
+ const e = new Error(`resumeAt entry "${spec.resumeAt}" is a "${entry.type}" entry; resume-at requires a message boundary`);
367
+ e.code = "resume_at.not_a_message";
368
+ throw e;
369
+ }
370
+ // §E18: a resume-at target must be a SETTLED turn boundary (a user message or a finished assistant turn).
371
+ // Two intra-turn shapes are stored as `type:"message"` and slip the type guard above — reject both.
372
+ if (entry.type === "message") {
373
+ const msg = entry.message;
374
+ // A `toolResult` is stored as a `type:"message"` entry (so it slips the type guard above) but it sits
375
+ // INSIDE a turn — it is the result of a tool call, not a turn boundary. Branching at a tool-result
376
+ // preserves the very tool effect the caller most likely wants to redo, and `reconcileInterruptedSession`
377
+ // would mishandle the open batch. Reject it (service [260]① — fail-loud for ALL deployments, not only
378
+ // those with a service-side capture-gate). Valid targets: a user message or a finished assistant turn.
379
+ if (msg?.role === "toolResult") {
380
+ const e = new Error(`resumeAt entry "${spec.resumeAt}" is a tool-result (mid-turn); resume-at requires a settled turn boundary (a user message or a finished assistant turn)`);
381
+ e.code = "resume_at.not_a_message";
382
+ throw e;
383
+ }
384
+ // An assistant message carrying an open `toolCall` is likewise an INTRA-turn boundary, not a turn
385
+ // boundary — branching there leaves the call unresolved (its toolResults are off-branch children), so
386
+ // `reconcileInterruptedSession` below would inject a synthetic [INTERRUPTED] for a call that actually
387
+ // completed. Only branch at user messages or finished assistant-text turns.
388
+ if (msg?.role === "assistant" && Array.isArray(msg.content) && msg.content.some((p) => p?.type === "toolCall")) {
389
+ const e = new Error(`resumeAt entry "${spec.resumeAt}" ends a turn mid-tool-call; resume-at requires a settled turn boundary`);
390
+ e.code = "resume_at.not_a_message";
391
+ throw e;
392
+ }
393
+ }
394
+ await session.getStorage().setLeafId(spec.resumeAt);
395
+ }
396
+ await reconcileInterruptedSession(session, toolEffects, resume?.suspendedBatch);
397
+ break;
398
+ }
399
+ catch (err) {
400
+ if (isSessionConflict(err) && attempt < RECONCILE_MAX_RETRIES) {
401
+ // Drop the STALE CACHED VIEW only, then re-wake. This must be `forget`, NEVER `release` (audit
402
+ // B-17): with the default in-memory store's `evict: "delete"`, release deleted the whole session
403
+ // from the repo — the re-acquire then created an EMPTY same-id session and the task continued on
404
+ // it (silent total context loss). A custom store WITHOUT `forget` gets NO fallback (B-17 council:
405
+ // its `release` contract permits deletion, so falling back would recreate the bug there) — the
406
+ // retry then re-acquires the same cached view and, if the conflict persists, exhausts the budget
407
+ // and propagates `errorCode="conflict"` to the caller. Fail-safe beats data loss.
408
+ if (sessions.forget) {
409
+ await Promise.resolve(sessions.forget(spec.sessionId)).catch(() => undefined);
410
+ }
411
+ continue;
412
+ }
413
+ // Conflict budget exhausted / non-conflict reconcile failure: the caller never gets a Prepared to
414
+ // release, so drop our cached view on the way out (1.93.0 throw-cleanup gap; forget = history-safe).
415
+ await Promise.resolve(sessions.forget ? sessions.forget(acquired.sessionId) : undefined).catch(() => undefined);
416
+ throw err;
417
+ }
418
+ }
419
+ const sessionId = acquired.sessionId;
420
+ const hostTaskId = spec.taskId ?? sessionId;
421
+ const taskScope = spec.principal ?? "default";
422
+ defaultTaskRegistry.maybeGc(); // throttled sweep (design/115 review B5): at most once/min, not per prepare
423
+ // B-17 + 1.93.0 throw-cleanup contract: ANY prepare throw below leaves the acquired session unreachable
424
+ // to the caller (no Prepared is returned, so finish()/release never run). Forget the cached view on the
425
+ // way out — deliberately NOT release(): on the default in-memory store an existing session's history
426
+ // would be deleted by a transient prepare failure (the same silent-empty-rewake class as B-17).
427
+ // Catch-on-throw-ONLY, mirroring the ownedEnv teardown (design/48 council BUG#1 precedent).
428
+ const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
429
+ // Request-param passthrough (design/40): the brain strips any `extraBody` key it owns (core wins), but
430
+ // the brain has no `onError`. Warn here, once at load time, if a deployment put a reserved key in
431
+ // `extraBody` so its silent drop is visible — the brain still enforces it regardless.
432
+ const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
433
+ if (extraBodyCollisions.length > 0) {
434
+ deps.onError?.(new Error(`model.extraBody contains reserved key(s) [${extraBodyCollisions.join(", ")}] that core owns and sets itself — they are ignored. Remove them from extraBody.`), { phase: "config", sessionId });
435
+ }
436
+ // Accumulate usage of any nested sub-runs (sub-agents) a tool spawns, so delegated cost (the
437
+ // multi-agent "~15×") shows up in the parent's `stats.nested`. Injected into each task tool's
438
+ // execute context; `createSubagentTool` reports its child's subtree total through it.
439
+ const nestedStats = { tokens: 0, turns: 0, tasks: 0, costUsd: 0, costMicroUsd: 0 };
440
+ if (resume) {
441
+ // design/45 §4.bis: re-seed pre-suspend nested sub-agent cost so delegated cost isn't lost on resume.
442
+ nestedStats.tokens = resume.seed.nestedStats.tokens;
443
+ nestedStats.turns = resume.seed.nestedStats.turns;
444
+ nestedStats.tasks = resume.seed.nestedStats.tasks;
445
+ nestedStats.costUsd = resume.seed.nestedStats.costUsd;
446
+ nestedStats.costMicroUsd = resume.seed.nestedStats.costMicroUsd;
447
+ }
448
+ const reportUsage = (u) => {
449
+ nestedStats.tokens += u.tokens;
450
+ nestedStats.turns += u.turns;
451
+ nestedStats.tasks += u.tasks;
452
+ nestedStats.costUsd += u.costUsd ?? 0;
453
+ nestedStats.costMicroUsd += u.costMicroUsd ?? 0;
454
+ };
455
+ // Large-tool-result offload (design/30): wrap caller (and below, MCP) tools so an oversized text
456
+ // result is moved to the store and replaced with a preview + ref before it reaches the session.
457
+ // An EXPLICIT global (spec/deps) is a deployment's coarse knob and wins over the CC per-tool defaults
458
+ // table below; only Read's structural exemption outranks it (see firstPartyOffloadPolicy).
459
+ const explicitGlobalThreshold = spec.toolResultThresholdChars ?? deps.toolResultThresholdChars;
460
+ const offloadThreshold = explicitGlobalThreshold ?? DEFAULT_TOOL_RESULT_THRESHOLD_CHARS;
461
+ const offloadEnabled = Number.isFinite(offloadThreshold) && offloadThreshold > 0;
462
+ // impl-review 2026-07-03 (codex 2 BLOCKER on the A2 shared fallback): the Runner-SHARED fallback
463
+ // instance is wrapped in a per-task SCOPED view — refs namespaced by trust scope (tenant B can't
464
+ // deref tenant A's ref; same-scope cross-task deref still works) and isEmpty() reflecting only THIS
465
+ // task's offloads (the D-2 durable-suspend gate's per-task semantics). An INJECTED store is used
466
+ // as-is (its deployment owns scoping); a per-prepare fresh InMemory (no runner self) stays task-local.
467
+ const rawOffloadStore = offloadEnabled ? (deps.toolResultStore ?? new InMemoryToolResultStore()) : undefined;
468
+ const offloadStore = rawOffloadStore instanceof RunnerSharedToolResultStore
469
+ ? new ScopedToolResultStore(rawOffloadStore, taskScope)
470
+ : rawOffloadStore;
471
+ const maybeOffload = (tool, perTool) => {
472
+ if (!offloadStore || perTool?.offload === false)
473
+ return tool;
474
+ return withToolResultOffload(tool, offloadStore, perTool?.offloadThresholdChars ?? offloadThreshold, sessionId);
475
+ };
476
+ // First-party mounts (band/task/monitor/worktree/SendMessage): CC per-tool persistence thresholds
477
+ // (PARITY-SPOT-USAGE-CONSTANTS F1/F2). Read's exemption is structural (its own paging gate owns the
478
+ // bound — offloading would re-truncate a page the tool already sized); the numeric table applies only
479
+ // when the deployment did NOT set an explicit global threshold (coarse knob wins).
480
+ const firstPartyOffload = (tool) => {
481
+ const policy = firstPartyOffloadPolicy(tool.name);
482
+ if (policy.offload === false)
483
+ return maybeOffload(tool, policy);
484
+ return maybeOffload(tool, explicitGlobalThreshold === undefined ? policy : undefined);
485
+ };
486
+ // MCP mounts: a server-declared `_meta["anthropic/maxResultSizeChars"]` (capped 500K in mcp.ts) is the
487
+ // server's own contract and wins over our defaults; else CC's MCP base declares 1e5 → min(1e5, 50K).
488
+ // An explicit deployment global still outranks both (caller = trust root, design F).
489
+ const mcpOffload = (tool) => {
490
+ if (explicitGlobalThreshold !== undefined)
491
+ return maybeOffload(tool);
492
+ return maybeOffload(tool, { offloadThresholdChars: tool.mcpMaxResultSizeChars ?? 50_000 });
493
+ };
494
+ if (offloadStore && (spec.tools ?? []).some((t) => t.name === OFFLOAD_TOOL_NAME)) {
495
+ // Same fail-fast as submit_output: a caller tool of the reserved name would shadow the injected reader.
496
+ const e = new Error(`Tool name "${OFFLOAD_TOOL_NAME}" is reserved when large-result offload is enabled.`);
497
+ e.code = "config.reserved_tool_name";
498
+ await forgetOnThrow();
499
+ throw e;
500
+ }
501
+ // Holds the harness once built, so a tool's execute can read the model the parent is CURRENTLY on
502
+ // (design/38 1B: `ToolExecuteContext.model` snapshot — a delegation tool uses it for `inherit`). Also
503
+ // breaks the harness↔tool_search build cycle (design/36 rematerialize). Populated before any run.
504
+ // The agent's "hand" execution env (design/44): injected, or the no-I/O stub (current default). Shared
505
+ // by the harness and the file tool band so both act on the same fs/shell.
506
+ // design/48 remote seam: a trusted control-plane `executionEnvFactory` mints a FRESH env per task (one
507
+ // isolated container per task in the remote model). It wins over a static `executionEnv`; the env it
508
+ // produces is `ownedEnv` — the Runner destroys it on task end (`finish()`), since this task owns its
509
+ // lifetime. Order: factory(ctx) → static executionEnv → no-I/O stub.
510
+ let ownedEnv;
511
+ // P-8 fix: hoist `mcp` to function scope so the prepare-throw catch (below) can dispose it. The MCP clients (stdio
512
+ // subprocesses / HTTP transports) are materialized INSIDE the big try; a throw after materialize (egress check /
513
+ // reserved-name guard / a throwing await) would otherwise strand them — the catch only disposed ownedEnv.
514
+ let mcp;
515
+ try {
516
+ ownedEnv = deps.executionEnvFactory
517
+ ? await deps.executionEnvFactory({
518
+ sessionId,
519
+ taskId: spec.taskId,
520
+ ...(internals?.isolation ? { isolation: internals.isolation } : {}),
521
+ // Sub-agent cwd inheritance (CC parity): let a TOC factory root the child at the parent's cwd.
522
+ ...(internals?.parentCwd ? { parentCwd: internals.parentCwd } : {}),
523
+ })
524
+ : undefined;
525
+ }
526
+ catch (factoryErr) {
527
+ await forgetOnThrow(); // factory threw before any env existed — only the session view needs cleanup
528
+ throw factoryErr;
529
+ }
530
+ const executionEnv = ownedEnv ?? deps.executionEnv ?? new StubExecutionEnv();
531
+ const handsEnabled = ownedEnv !== undefined || deps.executionEnv !== undefined;
532
+ // design/97 CORE-6 audit MAJOR (codex): the task's effective root. For a worktree-isolated agent the factory
533
+ // rooted the env's cwd AT the worktree, so the root is the env's own cwd — `deps.rootPath` (a Runner-level base
534
+ // repo default) must NOT override it, or the agent's hands AND its LSP / prompt env-facts / session-rule
535
+ // policies would point at the SHARED base repo, defeating isolation. Computed ONCE here and used at every
536
+ // root-resolution site below (previously only the hands band applied the isolation rule).
537
+ const taskRootPath = internals?.isolation === "worktree" || !deps.rootPath ? executionEnv.cwd : deps.rootPath;
538
+ // Created early (before ANY env operation) so the resume VM-restore below can use it as the resumeVM
539
+ // abortSignal. The live listener that catches LATER external aborts is wired after the harness exists (it
540
+ // needs `harness.abort`); a signal already aborted at prepare start is propagated here so the restore honors it.
541
+ const abortController = new AbortController();
542
+ if (spec.signal?.aborted)
543
+ abortController.abort();
544
+ // NOTE: the LIVE `spec.signal` listener is attached below, after the harness exists. An abort that fires
545
+ // between this check and that listener is caught by the late check; the resume restore below additionally
546
+ // composes `spec.signal` into its abortSignal so a hung restore in that window is still short-circuited.
547
+ // design/48 (council BUG, minor/high): if a later prepare step throws AFTER the factory minted
548
+ // `ownedEnv`, it never reaches finish()'s teardown → a leaked remote container. Catch-on-throw-ONLY
549
+ // (deliberately NOT try/finally: on success the task owns the env and MUST keep it). Best-effort +
550
+ // destroy() is contractually idempotent. Mirrors the service reaper as belt-and-suspenders.
551
+ try {
552
+ // design/49 v1.5 + service[26]/[29] fix: restore the remote workspace snapshot (`resumeVM`) BEFORE any
553
+ // other env operation. The hand-toolkit setup below calls `executionEnv.canonicalPath()` (and tools later
554
+ // do fs/exec); a LAZY-CONNECT adapter would otherwise `doConnect` a FRESH empty sandbox on that first
555
+ // touch, after which a `resumeVM` (commonly idempotency-guarded to skip when already connected) is a no-op
556
+ // → the snapshot is never restored and the resumed task silently runs on an empty workspace (gated-write
557
+ // files lost). Restoring FIRST makes the snapshot the env's first and only connection. POST-CAS, so any
558
+ // failure here = resolved-but-unfinished (design/49 §4②): throw `resume.env_failed` → the prepare-throw
559
+ // catch destroys `ownedEnv` → `runTaskStream` surfaces a failed TaskResult (the human approval was
560
+ // consumed — re-initiate; the pre-CAS guard already rejected this resume if no factory was wired).
561
+ // abortSignal on resumeVM: a hung restore (network partition) cannot pin the resuming worker (BUG#2).
562
+ if (resume?.workspaceHandle !== undefined) {
563
+ const failResume = (message, cause) => {
564
+ const e = new Error(message, cause ? { cause } : undefined);
565
+ e.code = "resume.env_failed";
566
+ throw e;
567
+ };
568
+ const handle = resume.workspaceHandle;
569
+ if (ownedEnv === undefined || !isRemoteExecutionEnv(ownedEnv)) {
570
+ failResume("resume needs a RemoteExecutionEnv from executionEnvFactory to restore the workspace snapshot");
571
+ }
572
+ else if (handle.snapshotId === undefined) {
573
+ // service [500]② durable-park-only resume: a handle WITHOUT a snapshotId was minted by the park-only
574
+ // suspend degrade (non-suspendable remote env — SSH/ADB host; capabilities.suspendable:false). The
575
+ // suspend leg never paused a VM: the workspace persists on the target itself, and the factory above
576
+ // just reconnected to it — so there is nothing to restore; skip resumeVM/postResumeInit and proceed.
577
+ // (Before [500]② every remote suspend stamped a snapshotId, so this branch was a hard failResume.)
578
+ //
579
+ // codex review hardening (1.257.2): a missing snapshotId is only LEGITIMATE when the handle says so
580
+ // (`restoreMode:"park_only"`, stamped at park mint) — or, tolerance for park handles minted by
581
+ // 1.257.1 before the field existed, when the resumed env is itself non-suspendable. Any OTHER
582
+ // combination (no mode + suspendable env) = a snapshot handle that LOST its snapshotId (corrupt
583
+ // checkpoint / capability drift) → fail-closed rather than silently running on an unrestored fs.
584
+ // Deployment contract (unchanged, documented): the factory reconnects park-only workspaces by
585
+ // session identity — the engine has no seam to verify workspace identity beyond this guard.
586
+ if (handle.restoreMode !== "park_only" && isSuspendable(ownedEnv)) {
587
+ failResume("checkpoint workspaceHandle has no snapshotId and is not a park_only handle, but the resumed env is suspendable — refusing to resume on a possibly-unrestored workspace (corrupt checkpoint?)");
588
+ }
589
+ }
590
+ else {
591
+ // Compose `spec.signal` into the restore's abortSignal (council finding): the live external-cancel
592
+ // listener is attached only after the harness exists (below), so without this a caller cancel that
593
+ // fires DURING the restore would not reach `abortController` until after it returns — a hung restore
594
+ // could pin the worker until the provider's own timeout. `AbortSignal.any` covers that window.
595
+ const restoreSignal = spec.signal
596
+ ? AbortSignal.any([abortController.signal, spec.signal])
597
+ : abortController.signal;
598
+ const restored = await ownedEnv.resumeVM(handle.snapshotId, { abortSignal: restoreSignal });
599
+ if (!restored.ok) {
600
+ failResume(`resumeVM failed (${restored.error.code}): ${restored.error.message}`, restored.error);
601
+ }
602
+ const init = await ownedEnv.postResumeInit();
603
+ if (!init.ok) {
604
+ failResume(`postResumeInit failed (${init.error.code}): ${init.error.message}`, init.error);
605
+ }
606
+ }
607
+ }
608
+ // design/101 §E19: when this task BRANCHED at a prior message (E18 `resumeAt`) AND rewind is enabled, restore
609
+ // the working tree to that message's snapshot. MUST be here (AFTER env resolution at :697 + the remote
610
+ // resumeVM block above) — at the `setLeafId` site in the acquire loop there is no env yet (grounding BLOCKER).
611
+ // GATE-SPLIT (service [263]): gate on STORE-PRESENCE + `handsEnabled`, NOT env locality. `applyManifest` runs
612
+ // against ANY ExecutionEnv's FileSystem ops (local OR remote host/e2b/k8s/…); the old `!isRemoteExecutionEnv`
613
+ // blocked E19 for host adapters (no VM) + cloud. `handsEnabled` (Opus M2) stays as the "real fs env" proxy —
614
+ // a stub-env deployment with rewindFiles on would otherwise enumerate_failed + spuriously warn.
615
+ // `not_found` (the target entry was never snapshotted — e.g. not a turn-end leaf) is an expected no-op; any
616
+ // OTHER failure is surfaced via onError(phase:"rewind") and prepare PROCEEDS — the session branch already
617
+ // committed, so the files are honestly reported as not-fully-restored rather than silently wrong.
618
+ // design/101 §E19 + R8 (CC Rewind modes): restore the working tree to a prior message's snapshot. Two
619
+ // entrypoints, mutually exclusive by construction:
620
+ // - `rewindFiles` + `resumeAt` → CC "both": the setLeafId fork above already moved the conversation; restore
621
+ // code to that SAME target. (`resumeAt` alone, rewindFiles false = CC "conversation" — no restore here.)
622
+ // - `rewindFilesTo` (honored ONLY when `resumeAt` is ABSENT) → CC "code"-only: restore code to that target
623
+ // WITHOUT a fork — the fork block (above) keys on `resumeAt`, so the leaf is untouched, conversation continues.
624
+ const rewindTarget = spec.resumeAt !== undefined ? (spec.rewindFiles ? spec.resumeAt : undefined) : spec.rewindFilesTo;
625
+ if (rewindTarget !== undefined && deps.fileSnapshotStore && handsEnabled) {
626
+ const restoreRoot = taskRootPath;
627
+ // Compose spec.signal (the live external-cancel listener attaches only after the harness exists below) so a
628
+ // caller cancel during a hung restore is honored, mirroring the remote resumeVM block above.
629
+ const restoreSignal = spec.signal ? AbortSignal.any([abortController.signal, spec.signal]) : abortController.signal;
630
+ const restored = await deps.fileSnapshotStore.restore(sessionId, rewindTarget, executionEnv, restoreRoot, restoreSignal);
631
+ if (!restored.ok) {
632
+ if (restored.error.code === "not_found") {
633
+ // Opus M4: OBSERVABLE no-op — the target entry was never snapshotted (v1 snapshots only turn-end
634
+ // leaves), so the session branch committed but the files are UNCHANGED. Surface it so a shell can tell
635
+ // the user "no file snapshot at this point" rather than believing the tree was rewound.
636
+ try {
637
+ deps.onError?.(new Error(`rewind-files: no snapshot for entry "${rewindTarget}" — files left unchanged`), { phase: "rewind", sessionId });
638
+ }
639
+ catch {
640
+ /* a throwing onError must not break prepare */
641
+ }
642
+ }
643
+ else {
644
+ // Opus M5: a real restore failure leaves a HALF-CONVERGED tree — FAIL the task (prepare-throw →
645
+ // status:"failed" + errorCode) rather than run the agent on a tree it believes is clean. The session
646
+ // branch already committed (non-destructive leaf); the caller can retry (restore is re-convergent).
647
+ const e = new Error(`rewind-files restore failed (${restored.error.code}): ${restored.error.message}`);
648
+ e.code = "rewind.restore_failed";
649
+ throw e;
650
+ }
651
+ }
652
+ }
653
+ const harnessRef = {};
654
+ // design/77 §3 Gate-3: the per-task LIFO scope stack. Declared HERE (before the tool wrap below) so the
655
+ // wrapped `execute` can hand a delegation tool a live snapshot via `ctx.activeSkillScope()`. Frames are
656
+ // pushed later: the inherited-from-parent seed (design/77 §7) and the `skill` tool on a manifest load.
657
+ const skillScope = new ActiveSkillScope();
658
+ // design/99 (nested-subagent live tree, dual-review MINOR): the opt-in display sink. WRAPPED to forward ONLY
659
+ // `task_progress` — so a (3rd-party) tool that calls `ctx.forwardEvent` cannot push other event types onto the
660
+ // deployment's display channel — and EXPOSED on the tool ctx only when the deployment opted in (no `forwardEvent`
661
+ // key otherwise, so a non-forwarding run's ctx is unchanged). The forwarded tick is still an UNTRUSTED display
662
+ // hint (a tool could call `ctx.forwardEvent` with a forged `task_progress` / `parentTaskId`). Its blast is
663
+ // STRUCTURALLY bounded to the forwarding run's OWN scope: the deployment's fleet projection attributes the
664
+ // tick to THIS run (scope = run.scope), so a forged `parentTaskId` can at worst mis-nest a node within that
665
+ // scope's view — never cross-run / cross-tenant — i.e. cosmetic. A deployment MAY additionally validate
666
+ // `parentTaskId` against its known runs as defense-in-depth, but is NOT required to for isolation (service
667
+ // [305] #3 ruling: doc-as-LOW — the scope bound, not parentTaskId validation, is the load-bearing guarantee).
668
+ const forwardSink = internals?.onForwardEvent;
669
+ const forwardEvent = forwardSink
670
+ ? (e) => {
671
+ if (e.type === "task_progress")
672
+ forwardSink(e);
673
+ // Blackboard 2026-07-03 (subagent viewing pane): the spec-level opt-in widens the render
674
+ // channel to the child's CONTENT events (still filtered — a tool can never push other
675
+ // types onto the deployment's display channel). Same UNTRUSTED-RAW/redact contract as the
676
+ // main stream's tool events (§E1); the child stream stays out of the model context.
677
+ else if (spec.forwardSubagentEvents === true &&
678
+ (e.type === "text_delta" || e.type === "reasoning_delta" || e.type === "tool_start" || e.type === "tool_end")) {
679
+ forwardSink(e);
680
+ }
681
+ }
682
+ : undefined;
683
+ // design/80 D-B: a tool's `ctx.requestReview()` / `present_plan` sets `pending` here; the run loop honors it
684
+ // at the next clean turn boundary (mints a `plan_review` checkpoint). First request in a batch wins. Declared
685
+ // BEFORE the tool-ctx build so the ctx closure can capture `requestReview`.
686
+ const reviewRequestRef = {};
687
+ const requestReview = (opts) => {
688
+ if (reviewRequestRef.pending === undefined) {
689
+ reviewRequestRef.pending = opts?.reason !== undefined ? { reason: opts.reason } : {};
690
+ }
691
+ };
692
+ // design/105: clean run-stop binding to the harness's existing one-way flag — exposed as the ctx primitive
693
+ // (for spec.tools) and still threaded to the scheduler-tool factory's context slot (design/136 §2.2: the
694
+ // Sleep tool that consumed it there was retired; the primitive itself is load-bearing for spec.tools).
695
+ const requestStopAfterTurn = () => {
696
+ harnessRef.current?.requestStopAfterTurn();
697
+ };
698
+ // design/108: mid-run read-only plan mode — a run-local one-way flag the first-party `enter_plan_mode` tool sets
699
+ // (via ctx) and the tool-call handler below enforces (reject write-effect tools). Run-local (NOT durable): a
700
+ // later approve-resume re-prepares with this clear, so the approved plan can write. NOT in the safety gate —
701
+ // plan mode is a fidelity mode, not a security boundary (the real gates apply independently).
702
+ const planModeRef = { active: false };
703
+ const enterPlanMode = () => {
704
+ planModeRef.active = true;
705
+ };
706
+ // design/122 D1: the parent-run subagent-retain LEDGER — created ONLY when the spec opts in
707
+ // (`retainSubagentSessions`; default OFF ⇒ undefined ⇒ the ctx gains no key and delegation is
708
+ // byte-identical). Threaded to delegation tools as the TRUSTED `ctx.subagentRetain`; the Runner
709
+ // disposes it (abort in-flight resumes + unpin + release every retained session) in the task's
710
+ // terminal `finally` (D4).
711
+ const subagentRetain = spec.retainSubagentSessions ? new SubagentRetainLedger(spec.retainSubagentSessions) : undefined;
712
+ // design/135 §0 — the Agent tool's per-call `isolation:"worktree"` capability: built ONCE over the run's
713
+ // trusted env + task root (lazy — no I/O until a delegation actually mints a worktree). Write-capable runs
714
+ // only: a read-only run must not create worktrees, so the ctx key is simply absent there and the Agent tool
715
+ // reports isolation as honestly unavailable (design/135: silent degrade is the disease this batch kills).
716
+ const worktreeIsolation = spec.handsReadOnly !== true ? createSubagentWorktreeHelper(executionEnv, taskRootPath) : undefined;
717
+ // design/136 §6 盲点①: the run's fork-governance verdict. Declared BEFORE the ctx-injection map below
718
+ // (whose execute closures read it at tool-call time) but ASSIGNED after runtimeCaps resolve (~:1290) —
719
+ // prepare completes before any tool executes, so the closure never observes the pre-assignment value.
720
+ let agentForkDenial;
721
+ // OBS-2: the observer gate verdict (RuntimeCaps.allowObservers === true, EXPLICIT opt-in — types.ts
722
+ // polarity note). Declared here, ASSIGNED after runtimeCaps resolve (same call-time-closure posture
723
+ // as agentForkDenial above); the delegation tool reads it through ctx at each call.
724
+ let observersActive = false;
725
+ const tools = (spec.tools ?? []).map((t) => maybeOffload(defineTool({
726
+ ...t,
727
+ // `?.` is type-level optionality (a tool can run outside a Runner); at task runtime harnessRef is
728
+ // always populated, so this resolves to the parent's current model — never undefined here.
729
+ // `activeSkillScope`: a read-only snapshot of THIS (parent) task's live skill-manifest frames so a
730
+ // delegation tool can propagate them into a spawned subagent (design/77 §3/§7). Captured lazily at
731
+ // call time → reflects manifests active at the spawn moment (LIFO grows/shrinks during the run).
732
+ execute: (args, ctx) => t.execute(args, {
733
+ ...ctx,
734
+ reportUsage,
735
+ model: harnessRef.current?.getModel(),
736
+ thinkingLevel: harnessRef.current?.getThinkingLevel(), // the model snapshot's companion (same call-time semantics)
737
+ principal: spec.principal,
738
+ clientContext: spec.clientContext, // design/112 C1/C5: inherit user TZ/email down the delegation tree
739
+ parentCwd: taskRootPath, // sub-agent cwd inheritance (CC parity) — delegation tools thread this down
740
+ activeSkillScope: () => skillScope.active(),
741
+ // design/99 (nested-subagent live tree): the host run's canonical taskId (SAME fallback as runtask's
742
+ // `taskId = spec.taskId ?? sessionId`, so a child's parentTaskId matches the parent's task_progress
743
+ // node id), plus the opt-in display sink — `forwardEvent` is OMITTED unless the deployment opted in
744
+ // (a non-forwarding run's ctx gains no sink key). A delegation tool threads both into the child.
745
+ taskId: hostTaskId,
746
+ // design/129: the session id + the deployment's background-lifetime scope, threaded read-only
747
+ // so a background child can register session-scoped (outlive the turn) when opted in.
748
+ sessionId,
749
+ ...(spec.backgroundScope !== undefined ? { backgroundScope: spec.backgroundScope } : {}),
750
+ // design/129-B ([444]): the process-level background-child observer — per-leg sinks go stale
751
+ // at leg settle; this one is wired once at Runner construction and never does.
752
+ ...(deps.onBackgroundChildEvent ? { onBackgroundChildEvent: deps.onBackgroundChildEvent } : {}),
753
+ // design/115 P3: the run-local notification lane, so a caller-mounted subagent tool's
754
+ // background completions reach THIS task's next turn (same lane as workflow/bash).
755
+ ...(internals?.onTaskNotification ? { onTaskNotification: internals.onTaskNotification } : {}),
756
+ ...(forwardEvent ? { forwardEvent } : {}),
757
+ // Subagent steer verb (blackboard 2026-07-03): the deployment's steer-handle sink.
758
+ ...(internals?.onSubagentSpawn ? { onSubagentSpawn: internals.onSubagentSpawn } : {}),
759
+ // design/122 D1: the retain ledger (present only when `spec.retainSubagentSessions` opted in).
760
+ ...(subagentRetain ? { subagentRetain } : {}),
761
+ // design/135 §0: the Agent tool's worktree-isolation capability (write-capable runs only) + the
762
+ // inside-fork marker (so a forked child's Agent tool refuses a nested `subagent_type:"fork"`).
763
+ ...(worktreeIsolation ? { worktreeIsolation } : {}),
764
+ ...(internals?.insideFork === true ? { insideFork: true } : {}),
765
+ // design/136 §6 盲点① + §2.1.a: thread the fork-GOVERNANCE verdict (read at call time — see
766
+ // the `agentForkDenial` declaration above) so Agent(subagent_type:"fork") answers to the
767
+ // enableFork/allowFork governance axes. Present only when DENIED.
768
+ ...(agentForkDenial !== undefined ? { forkAccess: { denied: agentForkDenial } } : {}),
769
+ // OBS-2: the observer auto-spawn gate (read at call time — see the `observersActive`
770
+ // declaration above). Present only when the principal's caps EXPLICITLY opted in.
771
+ ...(observersActive ? { observersAllowed: true } : {}),
772
+ // design/80 D-B: the general "yield to a human plan-review" primitive. Always present (it only sets
773
+ // a flag); the EFFECT is gated at the turn boundary — honored ONLY when `suspendForReview` exists (a
774
+ // checkpointStore is wired). A read-only/headless run sets the flag but the boundary drops it (no-op).
775
+ requestReview,
776
+ // design/105: clean run-stop after the current turn — a thin binding to the harness's existing one-way
777
+ // flag (NOT abort, so no orphan reconcile). E.g. a tool that persisted a delayed self-wake intent
778
+ // ends the run cleanly so the daemon owns the re-wake.
779
+ requestStopAfterTurn,
780
+ // design/108 (review fix): expose the plan-mode primitive ONLY when the deployment opted in
781
+ // (`enablePlanMode`). Otherwise a non-plan task's tool could call `ctx.enterPlanMode()` and — if the
782
+ // tool-call gate happens to be registered (e.g. a policy task) — flip the run read-only. Strictly
783
+ // opt-in keeps non-plan tasks byte-identical (no reachable way to set the flag).
784
+ ...(spec.enablePlanMode === true ? { enterPlanMode } : {}),
785
+ }),
786
+ }), t));
787
+ const blockedRef = {};
788
+ if (spec.enableBlockedReport !== false) {
789
+ tools.push(makeReportBlockedTool(blockedRef));
790
+ }
791
+ // design/80 D-B: mount the first-party `present_plan` tool ONLY when the task opted into plan mode. It is a
792
+ // thin caller of the `requestReview` primitive (which the engine honors at the turn boundary). When
793
+ // `enablePlanMode` is unset the tool is NOT mounted, NOT disclosed, and a non-plan-mode task is byte-identical.
794
+ if (spec.enablePlanMode === true) {
795
+ tools.push(defineTool(createPresentPlanTool(requestReview)));
796
+ // design/108: the model-callable `enter_plan_mode` (CC EnterPlanMode parity) — self-impose mid-run read-only
797
+ // plan mode (enforced by the tool-call handler's plan-mode short-circuit). Mounted alongside present_plan (the
798
+ // exit half). Sets `planModeRef` via the captured `enterPlanMode`. (Effect:"read"; lands in `toolEffects` via
799
+ // the early set above + the post-assembly sync, so the plan-mode write-deny doesn't block the read-only exit.)
800
+ //
801
+ // (review fix) Gate on a wired checkpointStore: the ONLY exit from plan mode is present_plan → plan_review →
802
+ // human approve-resume, which REQUIRES a checkpointStore. Without one, present_plan's pause is dropped (no-op)
803
+ // while `planModeRef.active` persists → the model would be trapped read-only until the turn limit. So we do
804
+ // NOT offer `enter_plan_mode` when it cannot be exited. (present_plan stays mounted — its no-op degradation is
805
+ // harmless: it's just plan text, no flag is set.) The user-driven path (enablePlanMode + handsReadOnly, often
806
+ // headless) is unaffected: that run is read-only by construction and never needed `enter_plan_mode`.
807
+ if ((spec.checkpointStore ?? deps.checkpointStore) !== undefined) {
808
+ tools.push(defineTool(createEnterPlanModeTool(enterPlanMode)));
809
+ }
810
+ }
811
+ // design/99 §K (service [305]/center [318]): per-principal runtime ENTITLEMENTS, resolved server-side by
812
+ // the deployment (the SERVICE maps center `GET /api/config/effective?principal=` → these caps). Resolved
813
+ // here ONCE. Entitlements are NOT a caller-facing TaskSpec field — an untrusted caller must never
814
+ // self-grant — so the engine pulls them from a TRUSTED deployment resolver keyed by `spec.principal`
815
+ // (symmetric with `sessionPolicyStore.getRules`). `undefined` (no resolver / unset cap) = NO per-principal
816
+ // restriction (tighten-only: a cap only ever DENIES/forces; absence falls back to the deployment default).
817
+ let runtimeCaps;
818
+ if (deps.runtimeCapsResolver) {
819
+ try {
820
+ runtimeCaps = (await deps.runtimeCapsResolver(spec.principal)) ?? undefined;
821
+ }
822
+ catch (err) {
823
+ // FAIL CLOSED, never open (security review BLOCKER): an entitlement-resolver outage must NOT silently
824
+ // GRANT a capability center may have denied. Deny EVERY optional per-principal privilege (workflows AND
825
+ // fork — both gate on `!== false`, so an undefined cap here would AUTO-GRANT) + surface the failure so the
826
+ // operator can repair the resolver. The deployment-level gates (capability + toolPolicy) still apply, and a
827
+ // remote policy `ask` with no forced durable gate falls to a sync onAsk → headless deny (more restrictive).
828
+ // We do NOT force the durable gate on error (that could strand an autonomous run on an absent operator);
829
+ // `{allowWorkflows:false, allowFork:false}` is the safe, minimal, SYMMETRIC degrade (service [356]②).
830
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId });
831
+ runtimeCaps = { allowWorkflows: false, allowFork: false };
832
+ }
833
+ }
834
+ // design/136 §6 盲点① (1.256) + §2.1.a (BREAKING batch) — the run's fork-GOVERNANCE verdict, computed
835
+ // ONCE from the shared predicate (subagent.ts `forkGovernanceDenial`) and threaded to the Agent tool as
836
+ // `ctx.forkAccess` in the spec.tools ctx-injection map ABOVE (~:1204, same lane as `insideFork`; the
837
+ // closure reads this at CALL time, long after this assignment — prepare completes before any tool
838
+ // executes). `enableFork` is opt-OUT since the standalone Fork tool's removal: only an EXPLICIT
839
+ // `enableFork:false` / `allowFork:false` denies (undefined = Agent-fork available, bounded by the
840
+ // durable session capability — CC's default posture).
841
+ agentForkDenial = forkGovernanceDenial(spec.enableFork, runtimeCaps?.allowFork);
842
+ // OBS-2: `=== true` EXPLICIT opt-in (polarity note on RuntimeCaps.allowObservers — undefined ships dark).
843
+ observersActive = runtimeCaps?.allowObservers === true;
844
+ // design/98 §1.2/§3.1 (S8c): mount the `run_workflow` tool ONLY when self-orchestration is active (opt-in
845
+ // + hard sandbox + governance baseline) AND a trusted Runner self-ref is wired. The SAME gate
846
+ // (`selfOrchestrationActive`) drives the orchestration-prompt injection below (§6.3 lockstep). The runner
847
+ // is a TRUSTED internal (the Runner passes `this`); it is never a TaskSpec field. design/99 §K: a
848
+ // per-principal `allowWorkflows:false` entitlement DENIES it server-side even on a capable deployment
849
+ // (the third stage of the workflows gate: task opt-in ∧ deployment capability ∧ principal entitlement).
850
+ const deploymentWorkflowReady = runnerSelf !== undefined && isSelfOrchestrationActive(spec, deps);
851
+ const selfOrchestrationActive = deploymentWorkflowReady && runtimeCaps?.allowWorkflows !== false;
852
+ // Observability (security review MINOR): distinguish an ENTITLEMENT denial from a deployment misconfig — an
853
+ // opted-in run on a CAPABLE deployment gated purely by `allowWorkflows:false` emits a (governance, NOT
854
+ // misconfiguration) operator note, so "capable + opted-in + no workflow" is never silently confusing.
855
+ if (deploymentWorkflowReady && runtimeCaps?.allowWorkflows === false) {
856
+ deps.onError?.(new Error(`self-orchestration DENIED for principal "${spec.principal ?? ""}" by runtimeCaps.allowWorkflows=false ` +
857
+ `(per-principal entitlement governance, not a misconfiguration — the run_workflow tool is not mounted)`), { phase: "config", sessionId });
858
+ }
859
+ let workflowToolsActive = false;
860
+ if (selfOrchestrationActive && runnerSelf && deps.workflowScriptRunner && deps.workflowGovernanceBaseline) {
861
+ workflowToolsActive = true;
862
+ toolEffects.set(RUN_WORKFLOW_TOOL_NAME, "write");
863
+ tools.push(await createRunWorkflowTool({
864
+ runner: runnerSelf,
865
+ scriptRunner: deps.workflowScriptRunner,
866
+ governanceBaseline: deps.workflowGovernanceBaseline,
867
+ models: deps.models,
868
+ agents: deps.agents, // F4 agentType registry (deployment SHADOW over built-ins)
869
+ builtinAgents: deps.builtinAgents,
870
+ store: deps.workflowRunStore,
871
+ journalStore: deps.workflowJournalStore, // CORE-9 Part A: durable resume for tool-launched workflows
872
+ scriptStore: deps.workflowScriptStore, // B5/F2 + design/140 1a: scriptPath/name surface + defaultArgs registry
873
+ builtinWorkflows: deps.builtinWorkflows, // design/140 1c: built-in named workflows opt-out
874
+ onAgentSpawn: deps.onWorkflowAgentSpawn, // CORE-9 Part B: steerable handle sink (opt-in)
875
+ scope: taskScope,
876
+ notifier: deps.workflowCompletionNotifier,
877
+ originatingSessionId: sessionId, // service [368]: lookup-free completion-inbox routing
878
+ taskRegistry: defaultTaskRegistry,
879
+ taskNotification: internals?.onTaskNotification,
880
+ taskOwner: hostTaskId,
881
+ limits: deps.workflowLimits,
882
+ sourceTaskId: hostTaskId,
883
+ principal: spec.principal,
884
+ // TRUSTED nesting depth (cross-process): a workflow-child task carries it so its run_workflow is
885
+ // rejected by the one-level guard. In-process nesting is caught by the engine's AsyncLocalStorage.
886
+ workflowDepth: internals?.workflowDepth,
887
+ // Sub-agent cwd inheritance (CC parity): every agent this workflow spawns carries the HOST task's
888
+ // effective root so a TOC env factory roots it there instead of an empty sandbox.
889
+ parentCwd: taskRootPath,
890
+ // Thinking inheritance: a spawned agent with no explicit script/baseline thinking inherits the
891
+ // HOST task's CURRENT level (call-time getter — mid-run degrade/boost included).
892
+ parentThinking: () => harnessRef.current?.getThinkingLevel() ?? thinking,
893
+ }));
894
+ // design/136 §2.3: the standalone WorkflowStatus poll tool was retired — the POLL companion to
895
+ // Workflow is TaskOutput (mounted below with `workflowStore: deps.workflowRunStore`, live alias
896
+ // "WorkflowStatus"), which carries the field superset + delimitUntrusted fencing + the same
897
+ // cross-replica store fallback. No separate mount here.
898
+ }
899
+ // design/45: holder the durable-suspend gate writes (token+gate); read by the run loop. Plus the
900
+ // hand's read-file state, captured here so the suspend closure can serialize it into the checkpoint
901
+ // (it lives in the hands-toolkit closure, not Prepared — §15.2 net-add #8). Undefined when no hand.
902
+ const suspendRef = {};
903
+ // design/76 §2.5: the DUAL holder for a dry-run `{kind:"needs_review"}` pause. The commit-side
904
+ // discriminant (`publishCommittedSuspend`) routes a committed checkpoint into THIS or `suspendRef` by
905
+ // `gate.kind` — never both, so assemble-result's slot 8.6 `needs_review` branch is never dead (v4 MAJOR-A).
906
+ const reviewRef = {};
907
+ const suspendLoopRef = { hit: false }; // design/72 §2.2 (B): set when a re-suspend is refused (loop cap)
908
+ const compactionReuseRef = { consecutive: 0 }; // design/84 Seam C: run-scoped consecutive provider-reuse counter
909
+ const trimPressureRef = { droppedMessages: false }; // design/123 D4: request-layer trim dropped messages → force next boundary compaction
910
+ // design/80 D-E-core (A3): the run loop sets `.get` (after `stats` exists) so an approval suspend can debit
911
+ // this leg's live cumulative spend onto the durable approval ledger. Captured by the `suspendAsk` closure.
912
+ const liveSpendRef = {};
913
+ // design/91: human-review timing. `now` is the injectable wall-clock (default Date.now) — ALL human-time
914
+ // timestamps (synchronous resolveAsk wait, checkpoint.suspendedAt, resume latency) read it, so a test can
915
+ // pin them with a controllable clock (design/87 §4.3 flaky-guard: no bare Date.now in a hard-gate test).
916
+ // `humanReviewRef` is the per-task accumulator: seeded from the resumed checkpoint's carried prior-leg
917
+ // burden (cross-leg accumulation, like suspendCount/ledger), then appended to by each synchronous ask
918
+ // here (resolveAskBound) and by the durable-resume latency (runtask). Snapshotted onto a re-suspend's
919
+ // checkpoint (`humanReview`) so the chain reports the WHOLE burden. **Budget-EXCLUDED** — never a cost/gate input.
920
+ const now = deps.now ?? Date.now;
921
+ const humanReviewRef = {
922
+ count: resume?.priorHumanReview?.count ?? 0,
923
+ totalWaitMs: resume?.priorHumanReview?.totalWaitMs ?? 0,
924
+ gates: resume?.priorHumanReview?.gates ? [...resume.priorHumanReview.gates] : [],
925
+ };
926
+ // design/72 §2.2 (B): suspend-loop cap. `priorSuspendCount` is how many times this task already
927
+ // suspended (carried on the resumed checkpoint); the next suspend would be `+1`. Past `maxSuspends`
928
+ // we fail the run rather than mint another checkpoint — a restart-prone model re-issuing the gated
929
+ // call every resume can otherwise suspend forever. Default deliberately loose (a real HITL flow
930
+ // rarely re-suspends): legit multi-step approvals stay well under it.
931
+ const priorSuspendCount = resume?.priorSuspendCount ?? 0;
932
+ const maxSuspends = spec.maxSuspends ?? deps.maxSuspends ?? DEFAULT_MAX_SUSPENDS;
933
+ // design/74 (search [136] HIGH-1): the resource-SLICE cap — INDEPENDENT of the restart-loop `maxSuspends`.
934
+ // A resource suspend is counted on the ledger's `sliceCount` (priorLedger below), NOT the shared
935
+ // `suspendCount`: a legitimate long resource run must not trip the (low, default 5) restart-loop safety,
936
+ // and restart-loop suspends must not consume the slice allowance. The cross-slice $ ledger is the PRIMARY
937
+ // bound; this is a backstop against a $0-progress slice loop. Default 100 (the design/74 spec's worker-suspend ceiling).
938
+ const maxSlices = spec.resourceSuspend?.maxSlices ?? DEFAULT_MAX_SLICES;
939
+ // design/74 Slice 4: the cross-slice budget ledger carried forward from the resumed checkpoint (undefined on
940
+ // the first slice), plus the human total ($→micro) seeded ONLY on the first slice (later slices read the
941
+ // frozen total off the ledger — `debitLedger` keeps `prior` wins). `prepared.resourceLedger` exposes the
942
+ // prior ledger so the run loop can size this slice's effective ceiling = min(maxCostUsd, remaining).
943
+ const priorLedger = resume?.priorLedger;
944
+ const resourceTotal = spec.resourceSuspend
945
+ ? {
946
+ totalBudgetMicroUsd: spec.resourceSuspend.totalBudgetUsd !== undefined ? Math.round(spec.resourceSuspend.totalBudgetUsd * 1e6) : undefined,
947
+ }
948
+ : undefined;
949
+ // design/80 D-E-core (Part A): the read-only per-leg budget snapshot a STATELESS policy self-limits
950
+ // against (a fresh in-memory counter resets every resume leg). Computed ONCE here from the DURABLE state
951
+ // (the resumed resource ledger + the durable suspend chain) and frozen onto every `ToolCallRequest` the
952
+ // gate builds (per-leg-immutable, read-only — the policy cannot write it back). The prior ledger
953
+ // (`priorLedger`, carried on the resumed checkpoint regardless of gate kind — design/80 attaches it to
954
+ // approval suspends too, A3) gives cumulative spend + remaining across legs; on the FIRST leg (no ledger)
955
+ // the remaining falls back to the configured total so the policy still sees the leg-1 ceiling (mirrors
956
+ // runtask's first-slice `remainingMicroUsd`). NEVER falsy-test the remaining (0 = exhausted, not unlimited).
957
+ const budgetSnapshot = {
958
+ resourceRemainingMicroUsd: priorLedger
959
+ ? remainingBudgetMicroUsd(priorLedger)
960
+ : resourceTotal?.totalBudgetMicroUsd,
961
+ resourceSpentMicroUsd: priorLedger?.spentMicroUsd ?? 0,
962
+ suspendCount: priorSuspendCount,
963
+ };
964
+ let readFileStateForCheckpoint;
965
+ // parity-204 (CC seededFromContext): set inside the hands block below (needs the env + canonical
966
+ // roots), consumed by the loadProjectMemory block — undefined on a hands-less task (nothing to seed).
967
+ let seedContextFiles;
968
+ // design/99 §E13: the per-task logical cwd ref, OWNED here (not inside createHandsToolkit) when a real
969
+ // shell is mounted, so the Runner can observe `cd` moves and emit `workspace_changed`. Undefined when no
970
+ // shell / readOnly (no `cd` possible).
971
+ let handsCwdRef;
972
+ // codex 终审 1.255 F2: the EnterWorktree session's shared serializable state — owned HERE (like
973
+ // handsCwdRef) so the durable-suspend serialization below can stamp it into the checkpoint and a
974
+ // resume can re-seed it. Undefined ⇔ worktree tools not mounted (no tracked cwd).
975
+ let worktreeSessionRef;
976
+ let backgroundTaskToolsActive = false;
977
+ // design/74 Slice 3c: forward-declared at TASK scope (assigned inside the tool-gate block below, which is
978
+ // widened to run when resource-suspend is eligible even with no policy). Lives here — not block-local — so
979
+ // the Prepared return can expose it to the run loop's turn-boundary trigger. Undefined ⇒ not eligible. The
980
+ // `sliceSpend` arg (Slice 4) is THIS slice's cost/tokens/turns, debited onto the ledger at suspend.
981
+ let suspendForResource;
982
+ // design/80 D-B: forward-declared (like `suspendForResource`) so it is in scope at the Prepared return;
983
+ // ASSIGNED inside the suspend-setup block below (where the commit-saga helpers live).
984
+ let suspendForReview;
985
+ // Structured output (1.41): inject `submit_output` whose params ARE the schema. Reject a caller tool
986
+ // of the same name up front (the harness tool Map is last-write-wins → silent shadowing otherwise).
987
+ const outputRef = {};
988
+ if (resume?.seed.outputRef) {
989
+ // design/45 §4.bis: restore a pre-suspend submit_output so resume doesn't lose the structured result.
990
+ outputRef.value = resume.seed.outputRef.value;
991
+ outputRef.set = resume.seed.outputRef.set;
992
+ }
993
+ if (spec.outputSchema) {
994
+ if ((spec.tools ?? []).some((t) => t.name === OUTPUT_TOOL_NAME)) {
995
+ const e = new Error(`Tool name "${OUTPUT_TOOL_NAME}" is reserved when TaskSpec.outputSchema is set.`);
996
+ e.code = "config.reserved_tool_name";
997
+ throw e;
998
+ }
999
+ // CC 2.1.205 parity (strict-output-schema.ts): validate + strict-derive the caller's schema.
1000
+ // Invalid schema → FAIL FAST to the caller (CC: Ajv `validateSchema` error surfaces as a thrown
1001
+ // TypeError in workflow `agent({schema})` / a hard CLI error — never a silent no-validation run).
1002
+ // In-subset schema → the strict form (additionalProperties:false stamped) is what the model sees.
1003
+ // Valid-but-unsupported → serve as-is, with an observable warning (CC: telemetry outcome=fallback).
1004
+ const compiled = compileOutputSchema(spec.outputSchema);
1005
+ if ("error" in compiled) {
1006
+ const e = new Error(`TaskSpec.outputSchema is not a valid JSON Schema: ${compiled.error}`);
1007
+ e.code = "config.invalid_output_schema";
1008
+ throw e;
1009
+ }
1010
+ if (!compiled.strict) {
1011
+ try {
1012
+ deps.onError?.(new Error(`structured-output: strict schema derivation fell back to non-strict (${compiled.fallbackReason}) — the schema is served to the model as-is`), { phase: "config", sessionId });
1013
+ }
1014
+ catch {
1015
+ /* a throwing onError must not break prepare */
1016
+ }
1017
+ }
1018
+ tools.push(makeOutputTool(outputRef, spec.outputSchema, compiled.strict ? compiled.modelSchema : undefined));
1019
+ }
1020
+ mcp = spec.mcp?.length
1021
+ ? await materializeMcpTools(spec.mcp, spec.principal, deps.onElicit, deps.mcpImageResizer) // design/62 principal → MCP header; §E23 elicit; design/116 image-resize seam
1022
+ : { tools: [], toolAxes: [], warnings: [], serverInstructions: [], instructionsDelta: { pendingAdds: [], pendingRemovals: [] }, statuses: [], dispose: async () => { } };
1023
+ // Fail-open (design/29): servers that couldn't connect were skipped, not thrown — surface each as
1024
+ // an operational warning so a brick'd MCP server is visible without failing the task.
1025
+ for (const w of mcp.warnings)
1026
+ deps.onError?.(w, { phase: "mcp", sessionId });
1027
+ // Reserved-name fail-fast (codex review): an injected MCP tool name/alias (esp. the first-party global resource
1028
+ // tools ListMcpResourcesTool/ReadMcpResourceTool) colliding with a caller tool would SILENTLY shadow (harness Map is
1029
+ // last-write-wins) — reject up front, like submit_output / read_tool_result do above.
1030
+ {
1031
+ const callerNames = new Set((spec.tools ?? []).flatMap((t) => [t.name, ...(t.aliases ?? [])]));
1032
+ const clash = mcp.tools.find((t) => [t.name, ...(t.aliases ?? [])].some((name) => callerNames.has(name)));
1033
+ if (clash) {
1034
+ await mcp.dispose();
1035
+ const e = new Error(`Tool name "${clash.name}" is reserved by an injected MCP tool — a caller tool of the same name would silently shadow it.`);
1036
+ e.code = "config.reserved_tool_name";
1037
+ throw e;
1038
+ }
1039
+ }
1040
+ tools.push(...mcp.tools.map((t) => mcpOffload(t))); // MCP results can be large too (per-tool: _meta-declared → 50K default)
1041
+ // MAJOR-1 fix (design/77 §4 / design/70): a materialized MCP tool carries its safety axes in a sidecar
1042
+ // (the vendored AgentTool has no axis fields). Fold them into the SAME irreversibleTools/egressTools sets
1043
+ // that `spec.tools` fed above — so a `destructiveHint` MCP tool REGISTERS the gate + tightens (else a
1044
+ // no-policy deployment would silently auto-allow it). TIGHTEN-ONLY: a server's hints can add an ask/egress,
1045
+ // never remove one. The effect comes from the axis (`read` for a `readOnlyHint` tool — so it is NOT treated
1046
+ // as write-capable: no spurious LOUD ungated-write warning, no path-confine deny under a skill); otherwise
1047
+ // it defaults to `write` (the gate's repeat-unsafe default) for any other tightened tool.
1048
+ for (const axis of mcp.toolAxes) {
1049
+ if (axis.irreversibility === "always") {
1050
+ irreversibilityTier.set(axis.name, "always");
1051
+ irreversibleTools.add(axis.name);
1052
+ }
1053
+ if (axis.egress) {
1054
+ // SAME invariant as first-party tools above (design/70 council minor#1): an egress tool must be
1055
+ // repeat-UNSAFE — an effect:read/idempotent would let wake/resume reconciliation auto-replay an
1056
+ // interrupted EXTERNAL write. A caller `toolAxes` that lowers a server open-world tool's effect WITHOUT
1057
+ // clearing egress reaches this (the server hint set egress, the override set effect:read). Fail-fast,
1058
+ // exactly like first-party, so the caller resolves it: `egress:false` to vouch a pure read, or
1059
+ // `effect:"write"`. (design F — the MCP fold previously skipped this validation = the bypass.)
1060
+ if (axis.effect !== undefined && axis.effect !== "write") {
1061
+ const e = new Error(`MCP tool "${axis.name}" resolves to egress:true with effect:"${axis.effect}" — an egress tool (external write) must have effect:"write". Clear egress (toolAxes egress:false) if it is a pure read, or set effect:"write".`);
1062
+ e.code = "config.egress_requires_write_effect";
1063
+ throw e;
1064
+ }
1065
+ egressTools.add(axis.name);
1066
+ }
1067
+ if (!toolEffects.has(axis.name))
1068
+ toolEffects.set(axis.name, axis.effect ?? "write");
1069
+ }
1070
+ // design/130 P1+P2: wall-clock-aware per-call cap + finalize estimation state. The ref exists
1071
+ // when EITHER knob is on (codex delta ⑤: P2's est() needs the sample EWMAs even with the cap
1072
+ // disabled — `callCapByDeadline:false` must not silently kill the finalize); the cap PROVIDER
1073
+ // on the harness below is wired only when P1 itself is on. Inert until the run loop arms
1074
+ // `deadlineMs` (non-resource-suspend tasks only, codex ⑤). Static ceiling: the spec cap when
1075
+ // set, else the model's maxTokens — a mid-run degrade tightens it via the ref (codex delta ②).
1076
+ // P2b: hoisted ABOVE the hands toolkit so the shell's soft tool-deadline clamp can close over it.
1077
+ const callCapOn = spec.limits?.callCapByDeadline !== false;
1078
+ const callCapRef = spec.limits?.timeoutSec &&
1079
+ spec.limits.timeoutSec > 0 &&
1080
+ (callCapOn || spec.limits.gracefulFinalize !== false)
1081
+ ? createCallCapRef()
1082
+ : undefined;
1083
+ if (callCapRef) {
1084
+ callCapRef.staticCapTokens =
1085
+ spec.limits?.maxOutputTokens !== undefined && spec.limits.maxOutputTokens > 0
1086
+ ? spec.limits.maxOutputTokens
1087
+ : model.maxTokens;
1088
+ }
1089
+ // design/130 P2b: the soft tool-deadline clamp handed to the hands toolkit — inert until runtask
1090
+ // arms BOTH deadlineMs and cushionMs on the ref (softExecDeadlineMs returns undefined before).
1091
+ const execClamp = callCapRef
1092
+ ? {
1093
+ deadlineMs: () => softExecDeadlineMs(callCapRef),
1094
+ onClamp: () => {
1095
+ callCapRef.toolClamps = (callCapRef.toolClamps ?? 0) + 1; // → stats.mechanisms.toolClamps
1096
+ },
1097
+ }
1098
+ : undefined;
1099
+ // The "hand" (design/44): injecting `executionEnv` is the opt-in — mount the hand tool band over it,
1100
+ // closure-bound to a FRESH per-task read state (§11 ruling A) + the canonicalized containment root
1101
+ // (§4 inv 7). `includeShell` gates the shell tool on a real (non-stub) shell so a no-op bash never
1102
+ // reaches the model (S1); `handsReadOnly` mounts the effect:read-only band — the verifier boundary
1103
+ // (§6). Large read/grep results offload like any other tool. Default stub (no inject) → no hand tools.
1104
+ // design/119: hoisted so the # Environment block announces the EFFECTIVE canonical allow-roots,
1105
+ // not the raw spec values (a skipped/dead entry must not be advertised to the model).
1106
+ // design/138 S2-C — the memory write gate seam. The hands toolkit is built BEFORE the memory
1107
+ // engine session (which owns the writable-root coordinate), so the toolkit gets a late-bound ref:
1108
+ // until the session block below installs the gate, every write passes untouched (byte-compat);
1109
+ // afterwards Write/Edit/NotebookEdit run their FINAL text through MemoryEngine.gateWrite (memory
1110
+ // domain only — one prefix comparison for everything else).
1111
+ const memoryWriteGateRef = {};
1112
+ const additionalRootsCanonical = [];
1113
+ if (handsEnabled) {
1114
+ // design/97 CORE-6: an isolated (worktree) agent's env IS the worktree (the factory rooted its cwd there),
1115
+ // so use the env's own cwd — `deps.rootPath` (a Runner-level default repo root) must NOT override it, else
1116
+ // the agent's hands would confine to the shared base repo, defeating the isolation.
1117
+ const rootRaw = taskRootPath;
1118
+ const canon = await executionEnv.canonicalPath(rootRaw);
1119
+ const rootCanonical = canon.ok ? canon.value : rootRaw;
1120
+ // design/119 (CC --add-dir parity): canonicalize the extra allowed roots once (containment
1121
+ // compares canonical keys, so a raw/symlinked entry here would silently never match).
1122
+ for (const dir of spec.additionalDirectories ?? []) {
1123
+ if (!dir || !dir.trim())
1124
+ continue; // an empty entry must never reach the allowlist (within("") = allow-all)
1125
+ const c = await executionEnv.canonicalPath(dir);
1126
+ // Fail-closed (codex review MAJOR): a directory that fails to canonicalize (missing, or a
1127
+ // dangling/rejected symlink) is SKIPPED, never admitted raw — a raw prefix in the allowlist
1128
+ // plus the ancestor-canonicalize fallback would let a symlinked entry smuggle writes outside
1129
+ // every root. Skipping only narrows access (the primary root still applies).
1130
+ if (c.ok) {
1131
+ additionalRootsCanonical.push(c.value);
1132
+ }
1133
+ else {
1134
+ deps.onError?.(new Error(`additionalDirectories entry skipped (cannot canonicalize): ${dir}`), {
1135
+ phase: "config",
1136
+ sessionId,
1137
+ });
1138
+ }
1139
+ }
1140
+ // per-task; design/45 serializes this into a Checkpoint and re-seeds it on resume (§15.2 #8) — else
1141
+ // a resumed `edit_file` is rejected "file not read in this task".
1142
+ const readFileState = new Map(resume?.seed.readFileState ?? []);
1143
+ readFileStateForCheckpoint = readFileState; // expose to the suspend closure (§15.2 net-add #8)
1144
+ // parity-204 (CC 2.1.204 seededFromContext, cc204-bundle @17917159): the deployment declares
1145
+ // startup-seeded context files via ProjectMemoryLoad.seededFiles (loadProjectMemory block below);
1146
+ // seed the hands' read state through the SAME resolveKey containment as a real Read, so the
1147
+ // canonical keys collide with real Read/Edit entries and an out-of-root declaration is silently
1148
+ // skipped (best-effort, never blocks the task — matching the loadProjectMemory posture).
1149
+ seedContextFiles = async (files) => {
1150
+ for (const f of files) {
1151
+ const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, additionalRootsCanonical);
1152
+ if (rk.ok)
1153
+ seedReadFileStateFromContext(readFileState, rk.key, f.content);
1154
+ }
1155
+ };
1156
+ const handsIncludeShell = !(executionEnv instanceof StubExecutionEnv);
1157
+ const handsReadOnly = spec.handsReadOnly === true;
1158
+ backgroundTaskToolsActive = handsIncludeShell && !handsReadOnly && hasBackgroundShell(executionEnv);
1159
+ if (handsIncludeShell && !handsReadOnly) {
1160
+ handsCwdRef = { current: rootCanonical }; // §E13: observable `cd`
1161
+ // codex 终审 1.255 F2: a durable resume restores the pre-suspend LOGICAL cwd (a `cd` or an
1162
+ // EnterWorktree switch) — without this the cwd silently reset to the task root while the model's
1163
+ // history says otherwise. The directory is plain on-disk state that survived the suspend; if it
1164
+ // was removed externally, tools fail with their ordinary honest ENOENT texts (no special case).
1165
+ if (resume?.seed.handsCwd !== undefined)
1166
+ handsCwdRef.current = resume.seed.handsCwd;
1167
+ }
1168
+ const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, {
1169
+ ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}),
1170
+ includeShell: handsIncludeShell,
1171
+ readOnly: handsReadOnly,
1172
+ ...(handsCwdRef ? { cwdRef: handsCwdRef } : {}),
1173
+ taskRegistry: defaultTaskRegistry,
1174
+ taskOwner: hostTaskId,
1175
+ taskScope,
1176
+ // 黑板 [636]① (design/129, same unconditional posture as the Monitor mount): inside a session a
1177
+ // background command registers session-resident and survives the run teardown — the per-task
1178
+ // ISOLATED env case is handled at the teardown (envDying settle + empty keep-alive list), not here.
1179
+ ...(sessionId !== undefined ? { sessionId } : {}),
1180
+ // design/116 §7 G2b: a finished background command notifies the SOURCE task ("next" boundary interrupt).
1181
+ ...(internals?.onTaskNotification !== undefined ? { taskNotification: internals.onTaskNotification } : {}),
1182
+ // design/116 detach: mid-flight ctrl+b — Bash threads the per-call detach signal into env.exec.
1183
+ ...(internals?.detachHub !== undefined ? { detachHub: internals.detachHub } : {}),
1184
+ // design/130 P2b: foreground shell timeouts clamp to the task's soft tool deadline.
1185
+ ...(execClamp !== undefined ? { execClamp } : {}),
1186
+ // PDF degradation chain v2: Read forks on the SERVING model's capability profile — a model without
1187
+ // native document input degrades to pdftotext text extraction / rendered pages IN the tool (the
1188
+ // layer holding env.exec), instead of a brain-level placeholder.
1189
+ pdfModelCapabilities: pdfModelCapabilitiesOf(model),
1190
+ // design/141 件2 — RunnerDeps.hands: the deployment's SAFE hands subset, synthesized here (the
1191
+ // factory itself stays unexported — self-assembly into spec.tools would be name-shadowed by this
1192
+ // band). Runner-internal fields (registry/detachHub/execClamp/cwdRef/mount) are NOT reachable.
1193
+ ...(deps.hands?.bashReadonlyAllow !== undefined ? { bashReadonlyAllow: deps.hands.bashReadonlyAllow } : {}),
1194
+ ...(deps.hands?.commitCoAuthor !== undefined ? { commitCoAuthor: deps.hands.commitCoAuthor } : {}),
1195
+ ...(deps.hands?.readImageDownsampler !== undefined ? { readImageDownsampler: deps.hands.readImageDownsampler } : {}),
1196
+ // design/138 S2-C: late-bound memory write gate — COMPOSED with the deployment's hands.beforeWrite
1197
+ // (design/141 件2): the deployment hook judges first (a rejection short-circuits; a throw is the
1198
+ // band's fail-closed), then the engine's memory-scan gate ALWAYS runs. Deployment config can only
1199
+ // tighten the write path, never bypass the engine gate.
1200
+ beforeWrite: async (w) => {
1201
+ const deploymentGate = deps.hands?.beforeWrite;
1202
+ if (deploymentGate) {
1203
+ const verdict = await deploymentGate(w);
1204
+ if (verdict !== undefined && verdict.ok === false)
1205
+ return verdict;
1206
+ }
1207
+ return memoryWriteGateRef.current?.(w);
1208
+ },
1209
+ mountBackgroundTaskTools: false,
1210
+ });
1211
+ // design/141 Q4 — hands 同名 shadow 保护: this band pushes AFTER spec.tools and the harness tool
1212
+ // Map is last-write-wins, so a caller tool sharing a hands name (Read/Write/Bash/…) is SILENTLY
1213
+ // dropped — the deployment loses its tool with no trace (and the duplicate name rides into the
1214
+ // default activeToolNames, duplicating the band tool's definition in the provider payload). The
1215
+ // shadow direction is the SAFE one (the gated built-in stays in force: beforeWrite/shellGate keep
1216
+ // judging), so this is ONE operator-visible warning per prepare (onError phase "config", the
1217
+ // design/138 S4 posture) — NOT the reserved-name fail-fast: 下游可能已依赖现状,先 warn 观察
1218
+ // (design/141 §5). Name+alias matching mirrors the MCP clash check above (~:1558). CC 无此锚:
1219
+ // 其三方工具结构性带 mcp__ 前缀,与内置名不可能相撞——自研口径。
1220
+ {
1221
+ const bandNames = new Set(band.flatMap((t) => [t.name, ...(t.aliases ?? [])]));
1222
+ const shadowed = (spec.tools ?? []).filter((t) => [t.name, ...(t.aliases ?? [])].some((n) => bandNames.has(n)));
1223
+ if (shadowed.length > 0) {
1224
+ const names = shadowed.map((t) => `"${t.name}"`).join(", ");
1225
+ deps.onError?.(new Error(`spec.tools ${shadowed.length > 1 ? "entries" : "entry"} ${names} collide${shadowed.length > 1 ? "" : "s"} with the built-in hands tool band — the built-in wins (registered later; the harness tool Map is last-write-wins) and the caller tool never dispatches this task. Rename the caller tool, or don't inject executionEnv if you intend to replace the band.`), { phase: "config", sessionId });
1226
+ }
1227
+ }
1228
+ tools.push(...band.map((t) => firstPartyOffload(t)));
1229
+ // (Q6 read-only-Bash effect override is hoisted to the toolEffects seeding above, so the wake-time reconcile
1230
+ // sees it too — design/102 §3.6.)
1231
+ // design/80 D-2 (part-1): gate the hands `bash` per the deployment's shellGate doctrine — a full shell is
1232
+ // treated as egress+irreversible (a command can `curl`/`git push`/`rm`, which the per-tool axis marks
1233
+ // otherwise miss). Fold into the SAME irreversibleTools/irreversibilityTier/reversibilityProbes the
1234
+ // gate reads below (the irreversibility tighten covers egress too via the classifier). "always" → every
1235
+ // command tightens to an irreversible_ask suspend (fail-closed); "classify" → a provably-benign single
1236
+ // allowlisted read-only command auto-allows, everything else (writes, egress, pipes, unknown) tightens.
1237
+ // No effect under `handsReadOnly` (mounts the already-allowlisted bash_readonly) or with no real shell.
1238
+ const shellGate = spec.shellGate ?? "off";
1239
+ if (shellGate !== "off" && !(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) {
1240
+ // design/80 D-E: `bash`'s irreversibility comes from the COARSE shellGate tighten UNLESS the deployment
1241
+ // also wired an explicit per-tool mark on a tool literally named `bash` (egress, or an irreversibility
1242
+ // tier set in the spec loop above). The coarse case grades as the `shell` axis (severity 3) at the mint,
1243
+ // never the `irreversible` axis (4) of an explicitly-marked tool — capture that BEFORE shellGate sets the
1244
+ // tier (else its own `set` would look like an explicit mark).
1245
+ shellGatedBash = !egressTools.has("Bash") && !irreversibilityTier.has("Bash");
1246
+ irreversibilityTier.set("Bash", shellGate === "always" ? "always" : "maybe");
1247
+ irreversibleTools.add("Bash");
1248
+ if (shellGate === "classify")
1249
+ reversibilityProbes.set("Bash", bashReversibilityProbe());
1250
+ // design/135 G2: Monitor runs an arbitrary shell `command` through the SAME env seam — it must sit
1251
+ // behind the SAME shellGate tighten (else it becomes a policy side-door around a gated Bash). The
1252
+ // probe reuses bashReversibilityProbe verbatim (same `command` arg shape). Marked only when the
1253
+ // Monitor tool will actually mount (backgroundTaskToolsActive), mirroring the Bash-present premise.
1254
+ if (backgroundTaskToolsActive) {
1255
+ shellGatedMonitor = !egressTools.has("Monitor") && !irreversibilityTier.has("Monitor");
1256
+ irreversibilityTier.set("Monitor", shellGate === "always" ? "always" : "maybe");
1257
+ irreversibleTools.add("Monitor");
1258
+ if (shellGate === "classify")
1259
+ reversibilityProbes.set("Monitor", bashReversibilityProbe());
1260
+ }
1261
+ }
1262
+ }
1263
+ if (backgroundTaskToolsActive || workflowToolsActive) {
1264
+ toolEffects.set("TaskOutput", "read");
1265
+ toolEffects.set("TaskStop", "write");
1266
+ tools.push(firstPartyOffload(createTaskOutputTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore })), // design/129: session dimension for cross-turn poll of session-scoped bg
1267
+ firstPartyOffload(createTaskStopTool({ registry: defaultTaskRegistry, owner: hostTaskId, scope: taskScope, sessionId, workflowStore: deps.workflowRunStore })));
1268
+ // 深挖 G3 — SendMessage: continue a completed background agent with its context intact (the design/122
1269
+ // resume face made MODEL-VISIBLE). Mounted with the same background surface as TaskOutput/TaskStop
1270
+ // (the a* ids it addresses come from that lane) + a runner self (the resume executes child runs).
1271
+ // Mounted even with retain OFF (clay 拍 ①+②): the call then returns the honest not-retained text.
1272
+ // A caller-supplied tool of the same name wins (skip, not throw — this mount is unconditional within
1273
+ // the gate, unlike the opt-in Fork whose collision is a config error).
1274
+ if (runnerSelf && !(spec.tools ?? []).some((t) => t.name === SEND_MESSAGE_TOOL_NAME)) {
1275
+ tools.push(firstPartyOffload(createSendMessageTool({
1276
+ runner: runnerSelf,
1277
+ registry: defaultTaskRegistry,
1278
+ ...(subagentRetain ? { retain: subagentRetain } : {}),
1279
+ owner: hostTaskId,
1280
+ scope: taskScope,
1281
+ ...(sessionId !== undefined ? { sessionId } : {}),
1282
+ ...(internals?.onTaskNotification !== undefined ? { notify: internals.onTaskNotification } : {}),
1283
+ ...(internals?.onSubagentSpawn !== undefined ? { sink: internals.onSubagentSpawn } : {}),
1284
+ })));
1285
+ // 黑板 [558] C — AgentTranscript: read a retained background child's recent tool steps ON DEMAND
1286
+ // (pull face, does not inflate context). Same background surface + self-runner (reads sessions),
1287
+ // same skip-on-collision posture. Complements the stop-time residual the notification carries.
1288
+ if (!(spec.tools ?? []).some((t) => t.name === AGENT_TRANSCRIPT_TOOL_NAME)) {
1289
+ tools.push(firstPartyOffload(createAgentTranscriptTool({
1290
+ runner: runnerSelf,
1291
+ registry: defaultTaskRegistry,
1292
+ owner: hostTaskId,
1293
+ scope: taskScope,
1294
+ ...(sessionId !== undefined ? { sessionId } : {}),
1295
+ })));
1296
+ }
1297
+ }
1298
+ }
1299
+ // design/135 G2 (审计 [485]①): the Monitor tool — watch a background command's stdout as batched
1300
+ // notification events (the自主循环 companion to Bash run_in_background). Same preconditions as the
1301
+ // background half above (real shell + write hands + background-capable env); same security posture as
1302
+ // Bash (arbitrary shell ⇒ effect:"write", folded into the shellGate tighten above). Events ride the
1303
+ // run-local task-notification lane at "later" priority; the m* handle gets TaskOutput/TaskStop for free.
1304
+ if (backgroundTaskToolsActive) {
1305
+ toolEffects.set("Monitor", "write");
1306
+ tools.push(firstPartyOffload(createMonitorTool(executionEnv, {
1307
+ registry: defaultTaskRegistry,
1308
+ owner: hostTaskId,
1309
+ scope: taskScope,
1310
+ ...(sessionId !== undefined ? { sessionId } : {}),
1311
+ ...(internals?.onTaskNotification !== undefined ? { onTaskNotification: internals.onTaskNotification } : {}),
1312
+ ...(handsCwdRef !== undefined ? { cwdRef: handsCwdRef } : {}),
1313
+ })));
1314
+ }
1315
+ // 审计[485]③ G4: EnterWorktree/ExitWorktree — session-level git-worktree isolation (CC parity). Mounted
1316
+ // only with real WRITE hands over a tracked cwd (`handsCwdRef` exists ⇔ real shell + !handsReadOnly —
1317
+ // the same premise family as the Monitor/background mounts): the tools' whole point is switching the
1318
+ // shared mutable cwd, which read-only/stub hands don't have. Git-ness is probed honestly at Enter time
1319
+ // (subagent-worktree-helper posture — no per-task prepare-time git exec); effect:"write" ⇒ plan mode
1320
+ // gates Enter/Exit like every other write tool.
1321
+ if (handsCwdRef !== undefined) {
1322
+ toolEffects.set("EnterWorktree", "write");
1323
+ toolEffects.set("ExitWorktree", "write");
1324
+ // codex 终审 1.255 F2: seed the session ref from the checkpoint (Enter→suspend→resume→Exit works:
1325
+ // the worktree dir survived on disk; Exit re-derives removal without any Enter-leg closure).
1326
+ worktreeSessionRef = { ...(resume?.seed.activeWorktree ? { current: { ...resume.seed.activeWorktree } } : {}) };
1327
+ tools.push(...createWorktreeTools(executionEnv, { repoRoot: taskRootPath, cwdRef: handsCwdRef, session: worktreeSessionRef }).map((t) => firstPartyOffload(t)));
1328
+ }
1329
+ // Inject the reader once, so the model can fetch any offloaded result (design/30). Not itself offloaded.
1330
+ if (offloadStore)
1331
+ tools.push(makeReadToolResultTool(offloadStore));
1332
+ // AskUserQuestion (design/64 §5): mount only when a content-ask seam is wired (deployment opt-in).
1333
+ // The model issues the question; the tool routes it to onQuestion (a real human/UI) and returns the
1334
+ // choice. Headless deployments simply don't wire onQuestion, so the tool isn't offered.
1335
+ const onQuestion = spec.onQuestion ?? deps.onQuestion;
1336
+ if (onQuestion)
1337
+ // design S1d §1.2: pass Runner-held source identity so an aggregating onQuestion can attribute per worker.
1338
+ tools.push(createAskUserQuestionTool(onQuestion, { principal: spec.principal, sourceTaskId: sessionId }));
1339
+ // design/105: scheduler tools (CronCreate/CronDelete/CronList — design/136 §2.2: Sleep retired) — mounted only when the env carries an
1340
+ // injected scheduler daemon backend (`hasScheduler`, checked inside the factory → returns [] otherwise = INERT).
1341
+ // Runner-held identity (principal/scope/sessionId) is closure-captured, NOT a model arg (防自排提权, mirrors
1342
+ // AskUserQuestion/present_plan). Write tools → not in the read-only verifier band (`handsReadOnly`).
1343
+ if (spec.handsReadOnly !== true) {
1344
+ const schedScope = sessionId ?? spec.principal ?? spec.taskId ?? "default";
1345
+ tools.push(...createSchedulerTools(executionEnv, {
1346
+ scope: schedScope,
1347
+ ...(spec.principal !== undefined ? { principal: spec.principal } : {}),
1348
+ ...(sessionId !== undefined ? { sessionId } : {}),
1349
+ requestStopAfterTurn,
1350
+ }));
1351
+ }
1352
+ // design/136 §2.1: the standalone FORK tool was retired — fork lives ONLY in `Agent(subagent_type:
1353
+ // "fork")` (1.254 收编 + the [417]① background lane migrated in this batch). Governance travels as
1354
+ // `ctx.forkAccess` (computed above from the shared `forkGovernanceDenial` predicate, opt-OUT default:
1355
+ // only an explicit `enableFork:false` / `runtimeCaps.allowFork:false` denies, honestly, at call time).
1356
+ // Observability (mirrors allowWorkflows): a fork-CAPABLE run (session + fork-capable durable store,
1357
+ // not itself a forked child) denied purely by the per-principal `runtimeCaps.allowFork=false`
1358
+ // entitlement emits a governance (NOT misconfiguration) operator note, so "capable + no fork" is
1359
+ // never silently confusing. A `denied:"task"` (explicit enableFork:false) is the deployment's own
1360
+ // choice — no note.
1361
+ if (internals?.insideFork !== true &&
1362
+ runnerSelf &&
1363
+ sessionId !== undefined &&
1364
+ hasSessionFork(runnerSelf.sessions) &&
1365
+ agentForkDenial === "principal" // ⇔ runtimeCaps.allowFork === false (shared predicate, design/136 §6 盲点①)
1366
+ ) {
1367
+ deps.onError?.(new Error(`Fork DENIED for principal "${spec.principal ?? ""}" by runtimeCaps.allowFork=false ` +
1368
+ `(per-principal entitlement governance, not a misconfiguration — Agent(subagent_type:"fork") will refuse honestly)`), { phase: "config", sessionId });
1369
+ }
1370
+ // LSP code intelligence (design/64 §13.1): mounted only when a manager is wired (deployment opt-in).
1371
+ // Results are filtered through git check-ignore (CC parity); no server for a language → graceful degrade.
1372
+ const lspManager = spec.lspManager ?? deps.lspManager;
1373
+ if (lspManager) {
1374
+ const lspRoot = taskRootPath;
1375
+ // Pass the per-task env (the agent's sandbox) so the manager can be stateless — no sessionId→env
1376
+ // registry needed (design/64 §13.1 wiring, answering service[59]). Env injection stays off TaskSpec
1377
+ // (design/44 §7 Q4): it flows from the trusted Runner env here, never from untrusted caller input.
1378
+ tools.push(createLspTool(lspManager, { isPathIgnored: gitCheckIgnoreFilter(executionEnv, lspRoot), env: executionEnv }));
1379
+ }
1380
+ // design/121 (CC 198 <new-diagnostics> parity): the diagnostics lane. Preconditions = a manager
1381
+ // whose sessions FEED a registry + write-capable hands on the roster (CC's same gate) + not opted
1382
+ // out. When live: ① every successful edit/write nudges the file's LSP session (didChange push, so
1383
+ // the server re-analyzes and pushes publishDiagnostics — the session's own sync is lazy and only
1384
+ // runs on the next LSP op) and clears the file's delivered-set (a persisting problem resurfaces
1385
+ // against the new text); ② runtask drains the registry at each turn boundary (injection + frame).
1386
+ const lspDiagnostics = spec.lspDiagnostics !== false && lspManager?.diagnostics !== undefined && handsEnabled && spec.handsReadOnly !== true
1387
+ ? lspManager.diagnostics
1388
+ : undefined;
1389
+ const nudgeLspOnEdit = lspDiagnostics
1390
+ ? (rawPath) => {
1391
+ // The tool details carry the RAW arg path — resolve like the fs band does (relative against
1392
+ // the task's live cwd, else the root) so the LSP session/URI sees the same canonical file.
1393
+ // [K-PLATFORM-SWEEP] MAJOR#3: FORM-aware absolute check + family-consistent join — the raw arg
1394
+ // path comes from the ExecutionEnv lane and may be the OTHER family than the host (host
1395
+ // node:path isAbsolute/resolve mangles a cross-family path). Same philosophy as fs/safety.ts.
1396
+ const baseDir = handsCwdRef?.current ?? taskRootPath;
1397
+ const isAbsForm = rawPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(rawPath);
1398
+ const joinSep = baseDir.includes("\\") ? "\\" : "/";
1399
+ // codex 1.237 F2: normalize dot-segments FAMILY-AGNOSTICALLY after the join — a raw `./x` or
1400
+ // `a/../x` arg otherwise mints a URI (`/root/./x`) that diverges from the session's canonical
1401
+ // key (delivered diagnostics never clear; didChange hits a duplicate doc). Pure string walk.
1402
+ const normalizeSegments = (pth) => {
1403
+ const isUnc = pth.startsWith("\\\\");
1404
+ const drive = /^[A-Za-z]:/.exec(pth)?.[0] ?? "";
1405
+ const rest = pth.slice(drive.length + (isUnc ? 2 : 0));
1406
+ const sep = pth.includes("\\") ? "\\" : "/";
1407
+ const abs = rest.startsWith("/") || rest.startsWith("\\");
1408
+ const outSegs = [];
1409
+ for (const seg of rest.split(/[\\/]+/)) {
1410
+ if (!seg || seg === ".")
1411
+ continue;
1412
+ if (seg === "..") {
1413
+ if (outSegs.length > 0 && outSegs[outSegs.length - 1] !== "..")
1414
+ outSegs.pop();
1415
+ else if (!abs)
1416
+ outSegs.push("..");
1417
+ continue;
1418
+ }
1419
+ outSegs.push(seg);
1420
+ }
1421
+ return (isUnc ? sep + sep : "") + drive + (abs ? sep : "") + outSegs.join(sep);
1422
+ };
1423
+ const filePath = normalizeSegments(isAbsForm ? rawPath : baseDir.replace(/[\\/]+$/, "") + joinSep + rawPath);
1424
+ lspDiagnostics.fileEdited(pathToUri(filePath));
1425
+ // Fire-and-forget: sessionFor() resolves the file's language server (spawning at most the
1426
+ // same set the LSP tool would); a language with no server degrades silently.
1427
+ void lspManager
1428
+ .sessionFor(filePath, undefined, executionEnv)
1429
+ .then((session) => session?.notifyFileChanged?.(filePath))
1430
+ .catch(() => undefined);
1431
+ }
1432
+ : undefined;
1433
+ // Long-term memory (L2): inject the scope(s)' memory block + a `remember` tool (opt-in, scoped).
1434
+ // design/84 Seam A: normalize the (backward-compatible) memory union ONCE to a canonical
1435
+ // {scopes, writeScope, enabled}; every consumer below reads ONLY this, never the raw union (zero narrowing).
1436
+ const memorySpec = normalizeMemorySpec(spec.memory);
1437
+ // design/138 S1 — the NEW memory-engine path. `deps.memoryBackend` is THE switch: when configured
1438
+ // (and the task enables memory), the injection-first file-based lifecycle replaces the legacy
1439
+ // memoryStore path WHOLESALE for this task — no remember/recall tools, no legacy <user_memory>
1440
+ // block, no legacy consolidation/dynamic-recall (S2 rewires consolidation onto the entry plane).
1441
+ // Unset ⇒ the legacy path below is byte-identical (this leg adds; a later slice switches defaults).
1442
+ const useMemoryEngine = Boolean(memorySpec && memorySpec.enabled && deps.memoryBackend);
1443
+ let memoryEngineSession;
1444
+ if (useMemoryEngine && memorySpec) {
1445
+ try {
1446
+ const backend = deps.memoryBackend;
1447
+ // Directory resolution (§2.7): a FileBackend pins its own directory (zero-copy — the dir IS
1448
+ // the storage); any other backend gets the derived per-repo projection dir under the config root.
1449
+ const pinned = backend.directoryRoot;
1450
+ const engineRoot = resolveMemoryEngineRoot(deps.memoryEngineDir);
1451
+ const repoRoot = deps.rootPath ?? taskRootPath;
1452
+ // design/142 §1.5 — marker-first identity keying (v2 opt-in only): a resolved project marker
1453
+ // switches the derived mount key from the PATH to the projectId, so rename/move/re-clone/
1454
+ // container remounts stop breaking the memory link. resolveProjectId throws config.-coded on
1455
+ // a nested marker (rethrown below = fail-loud). A missing marker with a recorded hint is the
1456
+ // §1.5 recovery breadcrumb: warn (restorable), fall through to the path key — never mint.
1457
+ let identityKey;
1458
+ if (memorySpec.scopeContract === "v2" && repoRoot) {
1459
+ // 独立轨 F6: a nested-marker refusal is a PROJECT-IDENTITY violation — it must only fail
1460
+ // sessions that actually consume project identity (proj:/userproj: keys). A pure
1461
+ // personal/legacy session in the same checkout degrades to a warning + path key.
1462
+ const consumesProjectIdentity = [...memorySpec.scopes, ...(memorySpec.writeScope !== null ? [memorySpec.writeScope] : [])]
1463
+ .map(parseScopeKey)
1464
+ .some((k) => k.kind === "proj" || k.kind === "userproj");
1465
+ let marker = null;
1466
+ try {
1467
+ marker = resolveProjectId(repoRoot);
1468
+ }
1469
+ catch (err) {
1470
+ if (consumesProjectIdentity)
1471
+ throw err;
1472
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId });
1473
+ }
1474
+ if (marker) {
1475
+ identityKey = marker.projectId;
1476
+ const firstSwitch = lookupProjectIdHint(engineRoot, repoRoot) !== marker.projectId;
1477
+ recordProjectIdHint(engineRoot, repoRoot, marker.projectId);
1478
+ // 独立轨 F4 — key-switch control-plane carry-over: the OLD path-keyed control dir may hold
1479
+ // announcements queued for the next session's disclosure; switching the mount key must not
1480
+ // silently orphan them (backend entries are key-independent; the control plane is not).
1481
+ // One-shot, best-effort: drain old → enqueue new on the FIRST session that sees this marker.
1482
+ if (firstSwitch) {
1483
+ try {
1484
+ const oldCtl = deriveControlPlaneDir(engineRoot, repoRoot);
1485
+ const newCtl = deriveProjectControlDir(engineRoot, marker.projectId);
1486
+ const drained = drainMemoryAnnouncements(oldCtl);
1487
+ for (const ann of drained.queue)
1488
+ enqueueMemoryAnnouncement(newCtl, ann);
1489
+ }
1490
+ catch {
1491
+ /* carry-over is best-effort — announcements are disclosure, never CAS-load-bearing */
1492
+ }
1493
+ }
1494
+ // codex 复审 B2 (142 §1.4 一致性核): the WRITE target's projectId must match the marker —
1495
+ // writing proj:t/B while standing in marker-A's checkout is a mis-addressed write, fail-loud.
1496
+ // READ layers may reference other projects (cross-project reference is legal); only the
1497
+ // write face is identity-checked.
1498
+ if (memorySpec.writeScope !== null) {
1499
+ const w = parseScopeKey(memorySpec.writeScope);
1500
+ if ((w.kind === "proj" || w.kind === "userproj") && w.projectId !== marker.projectId) {
1501
+ const e = new Error(`memory scope contract v2: writeScope ${memorySpec.writeScope} targets projectId ${w.projectId}, ` +
1502
+ `but this checkout's marker (.sema/project) declares ${marker.projectId} — refusing a mis-addressed write.`);
1503
+ e.code = "config.memory_project_identity";
1504
+ throw e;
1505
+ }
1506
+ }
1507
+ }
1508
+ else {
1509
+ const hint = lookupProjectIdHint(engineRoot, repoRoot);
1510
+ if (hint !== undefined) {
1511
+ deps.onError?.(new Error(`memory project marker missing but recoverable: ${repoRoot} previously carried projectId ${hint} ` +
1512
+ `(.sema/project deleted?). Restore the marker to keep the identity-keyed memory mount; this session ` +
1513
+ `falls back to the path-derived key.`), { phase: "config", sessionId });
1514
+ }
1515
+ }
1516
+ }
1517
+ // repoKey derives from the BASE repo root (deps.rootPath) when configured — a worktree-isolated
1518
+ // task must share the same repo-level memory dir as the base checkout (§2.7 worktree-shared).
1519
+ const memoryDir = typeof pinned === "string" && pinned
1520
+ ? pinned
1521
+ : identityKey !== undefined
1522
+ ? deriveProjectMemoryDir(engineRoot, identityKey)
1523
+ : deriveRepoMemoryDir(engineRoot, repoRoot);
1524
+ // design/142 S2b — dual physical roots (S1b 清账): under v2, personal scopes (`user:`/
1525
+ // `userproj:`) route to the PERSONAL plane (a cross-project home mount under the config
1526
+ // root) while proj:/org:/legacy stay on the project plane. Assembly is OUTSIDE the engine
1527
+ // (two instances, each keeping its single-root invariants); exactly one owns the write face.
1528
+ const planes = memorySpec.scopeContract === "v2" ? classifyScopePlanes(memorySpec.scopes, memorySpec.writeScope) : undefined;
1529
+ const dual = planes !== undefined && needsDualRoots(planes);
1530
+ const personalOnly = planes !== undefined && !dual && planes.project.length === 0 && planes.writePlane !== "project" && (planes.personal.length > 0 || planes.writePlane === "personal");
1531
+ // design/142 §7 S1 硬门 (复审 F9): the PROJECT plane must never carry a personal key into an
1532
+ // in-repo memory dir. With S2b routing this is structural (the project plane is personal-free
1533
+ // by construction) — the assert stays as the invariant's fail-loud backstop. config.-coded →
1534
+ // rethrown past the fail-open catch below.
1535
+ if (memorySpec.scopeContract === "v2" && !personalOnly) {
1536
+ assertScopeContractPlacement({
1537
+ parsedScopes: dual
1538
+ ? parsedProjectPlane(planes, memorySpec.writeScope)
1539
+ : [...memorySpec.scopes, ...(memorySpec.writeScope !== null ? [memorySpec.writeScope] : [])].map(parseScopeKey),
1540
+ memoryDir,
1541
+ repoRoot,
1542
+ });
1543
+ }
1544
+ // Personal-plane seat (dual or personal-only): a directory-pinned File backend keeps the
1545
+ // project plane zero-copy, so the personal plane gets its OWN File backend pinned to the
1546
+ // personal mount (the home mount IS the personal authority disk); a DB backend is shared —
1547
+ // the scope column partitions, both instances copy-out into their own roots.
1548
+ const personalMemoryDir = derivePersonalMemoryDir(engineRoot);
1549
+ const personalControlDir = derivePersonalControlDir(engineRoot);
1550
+ const makePersonalEngine = () => new MemoryEngine({
1551
+ backend: typeof pinned === "string" && pinned
1552
+ ? new FileMemoryEngineBackend(personalMemoryDir, { controlDir: personalControlDir })
1553
+ : backend,
1554
+ memoryDir: personalMemoryDir,
1555
+ controlDir: personalControlDir,
1556
+ });
1557
+ // Assemble the session faces (single write gate, merged inject, merged harvest) so the rest
1558
+ // of the runner keeps ONE session shape regardless of how many roots are underneath.
1559
+ let writeEngine;
1560
+ let writeHandle;
1561
+ let injectFn;
1562
+ let harvestBoth;
1563
+ if (dual) {
1564
+ const projectEngine = new MemoryEngine({
1565
+ backend,
1566
+ memoryDir,
1567
+ controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveControlPlaneDir(engineRoot, repoRoot),
1568
+ });
1569
+ const personalEngine = makePersonalEngine();
1570
+ const p = planes;
1571
+ const projectHandle = await projectEngine.materialize(p.project, p.writePlane === "project" ? memorySpec.writeScope : null);
1572
+ const personalHandle = await personalEngine.materialize(p.personal, p.writePlane === "personal" ? memorySpec.writeScope : null);
1573
+ const writeIsPersonal = p.writePlane === "personal";
1574
+ writeEngine = writeIsPersonal ? personalEngine : projectEngine;
1575
+ writeHandle = writeIsPersonal ? personalHandle : projectHandle;
1576
+ injectFn = () => mergeInjections(projectEngine.inject(projectHandle), personalEngine.inject(personalHandle));
1577
+ // codex 复审 H12 — the WRITE plane's harvest is the hard half (the session's actual yield):
1578
+ // run it FIRST and let its failure propagate (harvestSafe surfaces it via onError). The
1579
+ // read-only plane's harvest is tamper-detection only — its failure degrades to a warning
1580
+ // on the merged report instead of silently discarding the write plane's completed half.
1581
+ harvestBoth = async () => {
1582
+ const writeFirst = writeIsPersonal ? [personalEngine, personalHandle] : [projectEngine, projectHandle];
1583
+ const readOther = writeIsPersonal ? [projectEngine, projectHandle] : [personalEngine, personalHandle];
1584
+ const writeReport = await writeFirst[0].harvest(writeFirst[1]);
1585
+ let readReport;
1586
+ let readFailure;
1587
+ try {
1588
+ readReport = await readOther[0].harvest(readOther[1]);
1589
+ }
1590
+ catch (err) {
1591
+ readFailure = err instanceof Error ? err.message : String(err);
1592
+ }
1593
+ const merged = readReport !== undefined ? mergeHarvestReports(writeReport, readReport) : writeReport;
1594
+ if (readFailure !== undefined)
1595
+ merged.warnings.push(`read-plane harvest failed (write-plane yield committed): ${readFailure}`);
1596
+ return merged;
1597
+ };
1598
+ }
1599
+ else if (personalOnly) {
1600
+ const personalEngine = makePersonalEngine();
1601
+ const handle = await personalEngine.materialize(memorySpec.scopes, memorySpec.writeScope);
1602
+ writeEngine = personalEngine;
1603
+ writeHandle = handle;
1604
+ injectFn = () => personalEngine.inject(handle);
1605
+ harvestBoth = () => personalEngine.harvest(handle);
1606
+ }
1607
+ else {
1608
+ const engine = new MemoryEngine({
1609
+ backend,
1610
+ memoryDir,
1611
+ controlDir: identityKey !== undefined ? deriveProjectControlDir(engineRoot, identityKey) : deriveControlPlaneDir(engineRoot, repoRoot),
1612
+ });
1613
+ const handle = await engine.materialize(memorySpec.scopes, memorySpec.writeScope);
1614
+ writeEngine = engine;
1615
+ writeHandle = handle;
1616
+ injectFn = () => engine.inject(handle);
1617
+ harvestBoth = () => engine.harvest(handle);
1618
+ }
1619
+ // design/138 S2-C (C-F2): install the write gate — Write/Edit/NotebookEdit now run their
1620
+ // FINAL text through the engine's shared scan when (and only when) the target lands inside
1621
+ // the memory writable root. gateWrite never throws (its sidecar bookkeeping is fail-open).
1622
+ // Dual roots: the write face lives on exactly ONE plane; the other plane's trees are
1623
+ // chmod-protected + harvest-tamper-checked (the same protection non-write scopes always had).
1624
+ memoryWriteGateRef.current = (w) => writeEngine.gateWrite(writeHandle, w.key, w.content);
1625
+ const harvestSafe = async (phase = "terminal") => {
1626
+ // Never throws: harvest is a boundary side-effect (task end / checkpoint mint) — a memory
1627
+ // failure must not fail or strand the run. Incidents/rejections surface via onError + the report.
1628
+ try {
1629
+ const report = await harvestBoth();
1630
+ if (!report.ok && report.incident) {
1631
+ deps.onError?.(new Error(`memory harvest refused (${report.incident.kind}): ${report.incident.detail}`), { phase: "memory", sessionId });
1632
+ }
1633
+ // S2-B (O-F7/C-F3): the report is returned to the Runner's owner — never dropped on the
1634
+ // floor. Swallow-guarded like onError: a throwing consumer must not fail the boundary.
1635
+ try {
1636
+ deps.onMemoryHarvestReport?.(report, { sessionId, phase });
1637
+ }
1638
+ catch {
1639
+ /* consumer errors never escape a best-effort harvest */
1640
+ }
1641
+ return report;
1642
+ }
1643
+ catch (err) {
1644
+ try {
1645
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "memory", sessionId });
1646
+ }
1647
+ catch {
1648
+ /* a throwing onError must not escape a best-effort harvest */
1649
+ }
1650
+ return undefined;
1651
+ }
1652
+ };
1653
+ memoryEngineSession = { engine: writeEngine, handle: writeHandle, inject: injectFn, harvest: harvestSafe };
1654
+ }
1655
+ catch (err) {
1656
+ // design/142 S1 硬门: a `config.`-coded violation is a DELIBERATE refusal (scope-contract
1657
+ // placement / malformed v2 key) — rethrow, never degrade to "runs memory-less" (F9: the
1658
+ // leak-shaped config must not silently proceed without its memory gate).
1659
+ if (typeof err.code === "string" && (err.code.startsWith("config.memory_scope") || err.code.startsWith("config.memory_project"))) {
1660
+ throw err;
1661
+ }
1662
+ // Fail-open like loadProjectMemory: a broken memory mount must not block the task; the task
1663
+ // simply runs memory-less this session (surfaced via onError).
1664
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "memory", sessionId });
1665
+ }
1666
+ }
1667
+ // design/138 S4 — the legacy MemoryStore path is RETIRED (BREAKING; clay 拍「直接弃用不迁移」).
1668
+ // A task that enables memory WITHOUT a memoryBackend no longer mounts remember/recall tools,
1669
+ // injects no legacy <user_memory> block, and runs NO legacy consolidation/selective/dynamic
1670
+ // recall. Fail-loud: ONE operator-visible deprecation warning per prepare (onError, phase
1671
+ // "config") so a deployment still wiring the retired deps.memoryStore learns to migrate to
1672
+ // RunnerDeps.memoryBackend (the design/138 injection-first memory engine) instead of silently
1673
+ // losing memory. Model-facing: a NEW Remember/Recall call gets the TOMBSTONED_TOOLS migration
1674
+ // text (agent-loop roster-miss branch); a pre-retirement checkpoint whose APPROVED pending
1675
+ // action was remember/recall resumes down the established P-7 `resume.tool_unavailable` reopen
1676
+ // (the checkpoint reopens for a fresh operator decision — the suspended work is never dropped).
1677
+ let memoryBlock;
1678
+ if (memorySpec && memorySpec.enabled && !useMemoryEngine) {
1679
+ const detail = deps.memoryStore
1680
+ ? "RunnerDeps.memoryStore is retired (design/138 S4): the legacy remember/recall tools and <user_memory> injection no longer run. Configure RunnerDeps.memoryBackend (memory engine) instead; this task runs memory-less."
1681
+ : "TaskSpec.memory is enabled but RunnerDeps.memoryBackend is not configured; memory is inactive for this task (the legacy memoryStore fallback was retired in design/138 S4).";
1682
+ deps.onError?.(new Error(detail), { phase: "config", sessionId });
1683
+ }
1684
+ // design/138 S1 — the memory-engine INJECTION replaces the legacy block wholesale on the new path:
1685
+ // ① the CC-verbatim `# Memory` instruction section (system authority — the §0.3 逐字 surface) and
1686
+ // ② the derived MEMORY.md index, truncated to CC parameters and FENCED untrusted (composeMemoryBlock
1687
+ // family — memory CONTENT never gets system-prompt authority). Mounted in the SAME stable-prefix
1688
+ // memory slot as the legacy memoryBlock (finishStable places it at the tail, before the project layer).
1689
+ if (memoryEngineSession) {
1690
+ const injection = memoryEngineSession.inject();
1691
+ if (injection.block.trim())
1692
+ memoryBlock = injection.block;
1693
+ }
1694
+ // === C4 (design/113) proactive project startup — load the deployment's project context (CLAUDE.md ± git
1695
+ // narrative) via the TRUSTED RunnerDeps seam and append it as a READ-ONLY, FENCED virtual "project" memory
1696
+ // layer at the TAIL. DECOUPLED from any memory feature (H5; the legacy `if (memoryEnabled…)` block this
1697
+ // note once referenced was retired in design/138 S4 — this layer always runs). Reuses composeMemoryBlock's
1698
+ // sanitize/fence (H2 — repo-controlled CLAUDE.md is UNTRUSTED). The project layer NEVER enters
1699
+ // memorySpec.scopes/writeScope (S4: the memory ENGINE owns the real scopes). Seam unwired OR null/blank
1700
+ // content ⇒ memoryBlock UNCHANGED = byte-identical (H3). Re-read every prepare incl. durable resume
1701
+ // (H8 — fresher than CC's conversation-frozen snapshot; documented in design/113 §6).
1702
+ if (deps.loadProjectMemory) {
1703
+ let loaded = null;
1704
+ try {
1705
+ loaded = await Promise.resolve(deps.loadProjectMemory({
1706
+ cwd: taskRootPath,
1707
+ handsEnabled,
1708
+ isSubagent: internals?.parentTaskId !== undefined,
1709
+ ...(internals?.agentName ? { agentName: internals.agentName } : {}),
1710
+ }));
1711
+ }
1712
+ catch (err) {
1713
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId }); // best-effort, never block the task
1714
+ }
1715
+ // parity-204: the structured return declares startup-seeded context files — pre-seed the hands'
1716
+ // read state (seedContextFiles above; undefined on a hands-less task = nothing to seed). Best-effort
1717
+ // like the load itself: a failing canonicalization must never block the task.
1718
+ const projectMem = loaded !== null && typeof loaded === "object" ? loaded.content : loaded;
1719
+ const seededFiles = loaded !== null && typeof loaded === "object" ? loaded.seededFiles : undefined;
1720
+ if (seededFiles?.length && seedContextFiles) {
1721
+ try {
1722
+ await seedContextFiles(seededFiles);
1723
+ }
1724
+ catch (err) {
1725
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "config", sessionId });
1726
+ }
1727
+ }
1728
+ if (projectMem != null && projectMem.trim()) {
1729
+ // design/113 §4.5: a TRUSTED framing PRECEDES the FENCED content so the model ORIENTS to the project (the
1730
+ // live test showed content-injection alone left a small model project-aware but PASSIVE on a bare greeting).
1731
+ const projectBlock = `${PROJECT_CONTEXT_FRAMING}\n\n${composeMemoryBlock(projectMem, "project")}`; // sanitized + fenced (H2)
1732
+ memoryBlock = memoryBlock ? `${memoryBlock}\n\n${projectBlock}` : projectBlock; // project at the TAIL (closest to the model); appended (NOT re-composed) so selective recall above is preserved
1733
+ }
1734
+ }
1735
+ // Skills (design/71 黑盒#5 fix): first-party disclosure + invocation. One normalized list feeds
1736
+ // BOTH halves (the `<skills>` system-prompt block below and the `skill` tool here), so the prompt
1737
+ // promises exactly what the tool serves. On the legacy free-form `provider.system` path the block
1738
+ // is the provider's to place; the tool's own description still names every skill, so disclosure
1739
+ // never silently vanishes there.
1740
+ const skillSpecs = normalizeSkills(spec.skills);
1741
+ // design/77 §3 Gate-3: the `skillScope` LIFO stack (declared above, before the tool wrap) is populated
1742
+ // here. The `skill` tool pushes a loaded skill's manifest onto it; the scope policy (composed into the
1743
+ // adjudicate chain below) deny-narrows tool calls to the intersection of active manifests. The stack
1744
+ // stays empty (a no-op policy) for a task with no manifested skills AND no inherited parent scope — so
1745
+ // such a task behaves exactly as before.
1746
+ // 🔴 design/77 §3 / §7: skill→subagent manifest-scope PROPAGATION. When this task is a SUBAGENT spawned
1747
+ // while a skill manifest was active on the PARENT, the parent's active frames arrive via the trusted
1748
+ // `internals.inheritedManifestScope` channel (never TaskSpec — design/44 §7 Q4). Seed them onto this
1749
+ // child's scope stack FIRST (as the base of the LIFO), so the child's tool gate inherits the parent
1750
+ // skill's deny-narrowing. The child's OWN skill loads then push ON TOP → the scope policy intersects
1751
+ // ALL live frames (parent + child), so the child can only narrow FURTHER, never re-grant. A DENY-ALL
1752
+ // `unresolved` frame the subagent tool threaded as a fail-closed fallback applies the same way. Empty/
1753
+ // absent → no inheritance (backward-compatible).
1754
+ const inheritedFrames = internals?.inheritedManifestScope ?? [];
1755
+ for (const frame of inheritedFrames) {
1756
+ skillScope.push(frame);
1757
+ }
1758
+ // Apply manifest scoping when THIS task carries a manifested skill OR inherits a parent manifest frame.
1759
+ const hasSkillManifest = skillSpecs.some((s) => s.manifest !== undefined) || inheritedFrames.length > 0;
1760
+ if (skillSpecs.length > 0) {
1761
+ // Same reservation rule as `tool_search`/`submit_output`: the harness tool Map is last-write-wins,
1762
+ // so a caller/MCP tool of the same name would silently shadow (or be shadowed by) ours.
1763
+ if (tools.some((t) => t.name === SKILL_TOOL_NAME)) {
1764
+ const e = new Error(`Tool name "${SKILL_TOOL_NAME}" is reserved when spec.skills is present.`);
1765
+ e.code = "config.reserved_tool_name";
1766
+ throw e;
1767
+ }
1768
+ tools.push(makeSkillTool(skillSpecs, skillScope));
1769
+ }
1770
+ const skillsBlock = buildSkillsBlock(skillSpecs);
1771
+ // design/138 S4: legacy consolidation retired with the memoryStore path — never active anymore
1772
+ // (a resumed pre-retirement checkpoint's `seed.consolidationNotes` are ignored: the task-end
1773
+ // reconcile pass they fed no longer runs). The prompt flags below stay wired (always-off) so the
1774
+ // constitution seam is unchanged.
1775
+ const provider = spec.promptProvider ?? deps.promptProvider ?? defaultPromptProvider;
1776
+ // design/64 §6.3: tell the harness-context block which runtime features are actually active for this
1777
+ // task so it never claims a mechanism the task lacks. Cheap derivations of spec/deps (the real policy/
1778
+ // hooks are resolved the same way for the tool gate below); evaluating them here has no side effects.
1779
+ const promptPolicyEnabled = Boolean(spec.toolPolicy ?? deps.toolPolicy);
1780
+ const promptHooks = spec.hooks ?? deps.hooks;
1781
+ const promptHooksEnabled = Boolean(promptHooks?.preToolUse || promptHooks?.postToolUse);
1782
+ // design/98 §1.2 (S8a): the orchestration prompt is injected iff self-orchestration is ACTIVE — the SAME
1783
+ // gate that mounted the run_workflow tool above (`selfOrchestrationActive`, §6.3 lockstep). When the task
1784
+ // opted in but the bar is unmet, emit ONE operator-visible warning (fail-closed; never run an LLM script
1785
+ // in the dev vm).
1786
+ const failClosedReason = selfOrchestrationFailClosedReason(spec, deps);
1787
+ if (failClosedReason) {
1788
+ deps.onError?.(new Error(failClosedReason), { phase: "config", sessionId });
1789
+ }
1790
+ // Isolation (1.257.x capability-split follow-up, mirrors [500]② suspendable): drive the prompt's
1791
+ // isolation claim off the DECLARED capability (`isIsolated` = capabilities.isolation), NOT the
1792
+ // structural remote check — an SSH host / ADB device is remote but `capabilities.isolation:false`,
1793
+ // and the old `isRemoteExecutionEnv` gate OVER-CLAIMED an isolated sandbox to the model on exactly
1794
+ // the un-isolated targets where design/53 zero-trust wants the tighter posture (§16.2: never claim
1795
+ // isolation that isn't there). Behavior change: un-isolated remote envs without a policy gate no
1796
+ // longer get the # Execution environment contract block (hooks-only tasks regain the standalone
1797
+ // deny-don't-retry line instead, per the harnessContext gap-fill gate).
1798
+ const featureFlags = {
1799
+ policyEnabled: promptPolicyEnabled,
1800
+ hooksEnabled: promptHooksEnabled,
1801
+ isolationEnabled: isIsolated(executionEnv),
1802
+ // design/98 §1.2 (S8a): inject ORCHESTRATION_GUIDANCE iff self-orchestration is really active (same gate
1803
+ // as the run_workflow tool mount).
1804
+ orchestrationEnabled: selfOrchestrationActive,
1805
+ // design/96 §C (S2): inject GOAL_COMPLETION_GUIDANCE iff this run is a `runGoal` iteration — the trusted
1806
+ // internal flag is set ONLY by runGoal (never a TaskSpec field), so the prompt's "declaring done stops
1807
+ // iteration" promise is always backed by a real loop (§6.3).
1808
+ goalEnabled: internals?.goalMode === true,
1809
+ // design/96 §D (S4, P0): inject ORCHESTRATION_AWARENESS iff the task runs at an ULTRA reasoning tier. The
1810
+ // honest reasoning-tier analogue of CC's ultracode nudge — driven by the CALLER-DECLARED, role-default-
1811
+ // folded `thinking` (resolved above at L514: `spec.thinking ?? resolvedRole.thinking`), NOT a keyword scan
1812
+ // of untrusted input (design/53). Independent of orchestrationEnabled (the run_workflow gate): a task can
1813
+ // be ultra AND self-orchestrating → both blocks compose.
1814
+ awarenessEnabled: thinking !== undefined && ULTRA_REASONING_TIERS.has(thinking),
1815
+ // design/97 CORE-6 (P1b): inject WORKTREE_NOTICE iff this task ACTUALLY runs in an isolated git worktree.
1816
+ // Driven by the TRUSTED internal channel (set by the workflow `{ isolation: "worktree" }` option), never a
1817
+ // TaskSpec field. AND-gated on `ownedEnv` (a factory actually minted the isolated env): a worktree request
1818
+ // with NO worktree-capable `executionEnvFactory` falls back to the static/stub env (no real worktree) — in
1819
+ // that case claiming an isolated worktree would be a §6.3 over-claim, so fail closed (codex review).
1820
+ worktreeIsolated: internals?.isolation === "worktree" && ownedEnv !== undefined,
1821
+ // design/64 §25 (A): same derivation as the runtask turn-boundary handler — the prompt's
1822
+ // context-management line must claim mid-task summarization ONLY when it will actually run (§6.3).
1823
+ withinTaskCompactionEnabled: (spec.compaction?.enabled ?? true) && (spec.compaction?.withinTask ?? true),
1824
+ };
1825
+ // Explicit task prompt wins; else the requested role's preset (e.g. a dev role's CODE_AGENT_PROMPT);
1826
+ // else a core caller's trusted default persona (合车复审修②: the delegation tool's lean
1827
+ // SUBAGENT_PROMPT rides here so a roles.* preset is never shadowed by the built-in default);
1828
+ // else the provider's own default (domain-neutral). Keeps the global default neutral while letting
1829
+ // coding roles opt into a coding prompt.
1830
+ const userSystemPrompt = spec.systemPrompt ?? resolvedRole.systemPrompt ?? internals?.defaultSystemPrompt;
1831
+ // TaskSpec.appendSystemPrompt (caller-trusted): threaded into the stable prompt context, composed as the
1832
+ // LAST stable block (keeps the base + harness/safety; before the volatile memory tail — prefix-cache safe).
1833
+ const userAppendSystemPrompt = spec.appendSystemPrompt;
1834
+ // MCP server instructions (design/64 §17.3): STABLE per-task → woven between the base and the volatile
1835
+ // memory tail (composeSystemPrompt twice) so the cacheable prefix is preserved.
1836
+ const mcpInstructionsBlock = mcp.serverInstructions.length
1837
+ ? `# MCP Server Instructions\n${mcp.serverInstructions.map((s) => `## ${s.server}\n${s.text}`).join("\n\n")}`
1838
+ : undefined;
1839
+ // Environment context (design/64 §20): situational facts in the VARIABLE tail (date changes daily). cwd/
1840
+ // git/platform/OS come from the ExecutionEnv (remote = the container's values) — best-effort, one probe.
1841
+ // design/112 C1/C5: USER facts from the client-supplied clientContext (NOT execution-env probes). Localize
1842
+ // "today" to the user's zone (TOB: the container runs UTC, the user is elsewhere); absent ⇒ UTC (annotated).
1843
+ const userTz = spec.clientContext?.timeZone;
1844
+ // Hardening (cross-feature review): validate the zone ONCE so the date basis and the `(zone)` annotation can
1845
+ // NEVER disagree. An INVALID client zone must take the UTC path (date) AND stay unannotated → the env block
1846
+ // shows `(UTC)`, never `(Asia/Faketown)` over a UTC date (§25 honesty: don't claim a localization that didn't
1847
+ // happen). `formatLocalDate` alone silently falls back to UTC but `timeZone` being merely PRESENT would still
1848
+ // mislabel it; gating on validity keeps both the initial render and the per-turn re-render (below) consistent.
1849
+ const tzValid = userTz !== undefined && isValidTimeZone(userTz);
1850
+ const envFacts = { date: formatLocalDate(new Date(), tzValid ? userTz : undefined), modelId: model.id };
1851
+ if (tzValid)
1852
+ envFacts.timeZone = userTz;
1853
+ if (spec.clientContext?.userEmail)
1854
+ envFacts.userEmail = spec.clientContext.userEmail;
1855
+ // P1a (knowledge cutoff): deployment-declared per model — core hardcodes no model→date map (a stale cutoff
1856
+ // would be a §25 honesty breach). Looked up by the resolved model id; omitted when the deployment didn't declare it.
1857
+ const declaredCutoff = deps.modelKnowledgeCutoffs?.[model.id];
1858
+ if (declaredCutoff)
1859
+ envFacts.knowledgeCutoff = declaredCutoff;
1860
+ // 黑板 [442] A1 — DEPLOYMENT-supplied sandbox facts (TaskSpec.envFacts): rendered by the same
1861
+ // buildEnvironmentContext (sanitize + bound happen at render). Copied field-by-field (whitelist shape —
1862
+ // unknown keys never ride into the block).
1863
+ if (spec.envFacts?.profile)
1864
+ envFacts.sandboxProfile = spec.envFacts.profile;
1865
+ if (spec.envFacts?.capabilities && spec.envFacts.capabilities.length > 0)
1866
+ envFacts.sandboxCapabilities = [...spec.envFacts.capabilities];
1867
+ if (spec.envFacts?.pkgSource)
1868
+ envFacts.sandboxPkgSource = spec.envFacts.pkgSource;
1869
+ // codex 1.240 ④: validate at the JS boundary — an out-of-union egress from an untyped caller must not
1870
+ // fall into the "full" render branch (claiming open egress that isn't).
1871
+ if (spec.envFacts?.egress === "none" || spec.envFacts?.egress === "allowlist" || spec.envFacts?.egress === "full")
1872
+ envFacts.sandboxEgress = spec.envFacts.egress;
1873
+ // codex 1.257.3 review (MED): standard-Runner path for the Scratchpad section (CC parity) — without
1874
+ // this copy `buildScratchpadSection` was reachable only via a manual buildEnvironmentContext call.
1875
+ if (spec.envFacts?.scratchpadDir)
1876
+ envFacts.scratchpadDir = spec.envFacts.scratchpadDir;
1877
+ if (handsEnabled) {
1878
+ envFacts.cwd = taskRootPath;
1879
+ // design/119 (CC --add-dir parity): announce the extra allowed roots in # Environment.
1880
+ if (additionalRootsCanonical.length > 0) {
1881
+ envFacts.additionalDirectories = [...additionalRootsCanonical];
1882
+ }
1883
+ try {
1884
+ // design/99 §E14: one batched probe (one round-trip). Each sub-command MUST emit EXACTLY ONE line so the
1885
+ // position-based parse stays in sync: osName, osVer, isInsideWorkTree, branch, worktreeRoot, dirty|clean.
1886
+ // Branch uses `symbolic-ref` (NOT `rev-parse --abbrev-ref HEAD`): on an UNBORN branch (`git init`, no commit)
1887
+ // abbrev-ref prints "HEAD" to stdout AND exits non-zero, so `|| echo` emits a SECOND line and every later
1888
+ // field shifts. `symbolic-ref --short -q` prints the branch (even unborn) on success / nothing on detached,
1889
+ // so `|| echo "HEAD (detached)"` yields exactly one line and never mislabels an unborn branch as detached.
1890
+ // P1a (shell): a 7th sub-command appended LAST (one line, always) — the basename of the shell ACTUALLY
1891
+ // RUNNING this probe (the env's exec interpreter), so the agent picks shell-correct syntax. Uses
1892
+ // `ps -p $$ -o comm=` (the current shell process), NOT `$SHELL` — `$SHELL` is the login shell, which can
1893
+ // differ from the interpreter the env spawns (e.g. NodeExecutionEnv runs commands under bash even when
1894
+ // `$SHELL` is zsh; codex review). Strips dir + leading `-` (login marker); empty (e.g. no `ps`) ⇒ omitted.
1895
+ // Appended at the END so the existing 6-field position parse is unaffected; read independently below.
1896
+ const probe = await executionEnv.exec('uname -s; uname -r; (git rev-parse --is-inside-work-tree 2>/dev/null || echo false); (git symbolic-ref --short -q HEAD 2>/dev/null || echo "HEAD (detached)"); (git rev-parse --show-toplevel 2>/dev/null || echo); (test -n "$(git status --porcelain 2>/dev/null | head -1)" && echo dirty || echo clean); (s=$(ps -p $$ -o comm= 2>/dev/null); s=${s##*/}; echo "${s#-}"); (test "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" && echo linked || echo main)', { cwd: envFacts.cwd, timeout: 10 });
1897
+ if (probe.ok && probe.value.exitCode === 0) {
1898
+ const lines = probe.value.stdout.split("\n");
1899
+ // Self-checking: the 6 sub-commands each emit one line, plus a trailing newline → ≥ 6 elements. Fewer
1900
+ // means a sub-command was swallowed (de-sync) — omit the shell-derived facts rather than mis-parse.
1901
+ if (lines.length >= 6) {
1902
+ const [osName, osVer, git, branch, worktreeRoot, dirty] = lines;
1903
+ if (osName?.trim())
1904
+ envFacts.platform = osName.trim();
1905
+ if (osVer?.trim())
1906
+ envFacts.osVersion = osVer.trim();
1907
+ envFacts.isGitRepo = (git ?? "").trim() === "true";
1908
+ if (envFacts.isGitRepo) {
1909
+ // §E14: branch/worktree/dirty only when actually a repo (else the empty/`clean` lines are noise).
1910
+ const b = (branch ?? "").trim();
1911
+ if (b)
1912
+ envFacts.gitBranch = b; // probe already emits "HEAD (detached)" for a detached HEAD
1913
+ const root = (worktreeRoot ?? "").trim();
1914
+ if (root)
1915
+ envFacts.gitWorktreeRoot = root;
1916
+ const d = (dirty ?? "").trim();
1917
+ if (d === "dirty" || d === "clean")
1918
+ envFacts.gitDirty = d === "dirty";
1919
+ }
1920
+ // P1a: shell is the 7th line (independent of the >=6 git/OS guard — a swallowed shell line just omits it).
1921
+ const sh = (lines[6] ?? "").trim();
1922
+ if (sh)
1923
+ envFacts.shell = sh;
1924
+ // design/119 (CC 198 parity): 8th line — linked-worktree detection (git-dir ≠ git-common-dir).
1925
+ // CC's # Environment carries an isolated-copy directive for a linked worktree; feed the same
1926
+ // fact so buildEnvironmentContext can emit it even when the caller didn't set isolation:"worktree".
1927
+ if (envFacts.isGitRepo && (lines[7] ?? "").trim() === "linked")
1928
+ envFacts.isLinkedWorktree = true;
1929
+ }
1930
+ }
1931
+ }
1932
+ catch {
1933
+ /* best-effort: a stub/odd env just omits the shell-derived facts */
1934
+ }
1935
+ // H4 (CC 2.1.198 ENV git snapshot, 考据档 B1/B2) — one more batched round-trip through the SAME
1936
+ // ExecutionEnv seam (TOB e2b = the container's repo), only when the §E14 probe confirmed a repo.
1937
+ // Four sections split by a sentinel line: main-branch inference (CC qP: origin/HEAD symref →
1938
+ // [inferred, main, master] each `show-ref --verify --quiet` → renderer falls back "main"),
1939
+ // `git config user.name`, `git --no-optional-locks status --short`, and
1940
+ // `git --no-optional-locks log --oneline -n 5` (CC-exact commands). 1.256 复审 MED-4: the two
1941
+ // REQUIRED sections (status, log) are captured explicitly and fail the WHOLE script with a
1942
+ // distinct exit code (41 = status, 42 = log) — previously the script's exit code was only the
1943
+ // LAST command's, so a failing status with a succeeding log rendered an EMPTY status section as
1944
+ // "(clean)" (a half-rendered snapshot lying about a dirty tree). Either required failure now
1945
+ // skips the whole section + degraded telemetry (CC's Promise.all catch → null is the same
1946
+ // all-or-nothing shape; an unborn repo still skips via exit 42). The main-branch/user sections
1947
+ // stay best-effort degradable. Failure/timeout = silent skip + onError telemetry with reason.
1948
+ if (envFacts.isGitRepo === true) {
1949
+ const SEP = "@@SEMA_ENV_GIT_SPLIT@@";
1950
+ try {
1951
+ const snap = await executionEnv.exec(`(m=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null); m=\${m#origin/}; for s in "$m" main master; do if [ -n "$s" ] && git show-ref --verify --quiet "refs/remotes/origin/$s"; then echo "$s"; break; fi; done) || true; echo "${SEP}"; (git config user.name 2>/dev/null || true); echo "${SEP}"; gs=$(git --no-optional-locks status --short) || exit 41; printf '%s\\n' "$gs"; echo "${SEP}"; gl=$(git --no-optional-locks log --oneline -n 5) || exit 42; printf '%s\\n' "$gl"`, { cwd: envFacts.cwd, timeout: 10 });
1952
+ if (snap.ok && snap.value.exitCode === 0) {
1953
+ const parts = snap.value.stdout.split(`${SEP}\n`);
1954
+ if (parts.length === 4) {
1955
+ envFacts.gitSnapshot = buildGitSnapshot({
1956
+ // Reuse the §E14 probe's branch ("HEAD (detached)" on detached — richer than CC's bare
1957
+ // "HEAD"; recorded deviation, keeps this block consistent with the `Git branch:` line).
1958
+ branch: envFacts.gitBranch ?? "HEAD",
1959
+ mainBranch: parts[0].trim() || "main",
1960
+ ...(parts[1].trim() ? { userName: parts[1].trim() } : {}),
1961
+ status: parts[2],
1962
+ log: parts[3],
1963
+ });
1964
+ }
1965
+ }
1966
+ else {
1967
+ // Silent-fallback telemetry (C1-C11 discipline: a degrade always carries its reason).
1968
+ // MED-4: the explicit required-section exit codes get a named reason so the degrade is diagnosable.
1969
+ const reason = snap.ok
1970
+ ? snap.value.exitCode === 41
1971
+ ? "git status failed (exit 41)"
1972
+ : snap.value.exitCode === 42
1973
+ ? "git log failed (exit 42)"
1974
+ : `git exited ${snap.value.exitCode}`
1975
+ : `exec failed: ${snap.error.message}`;
1976
+ deps.onError?.(new Error(`env git snapshot skipped — ${reason}`), { phase: "degraded", sessionId, classification: "env-git-snapshot" });
1977
+ }
1978
+ }
1979
+ catch (err) {
1980
+ deps.onError?.(new Error(`env git snapshot skipped — ${err instanceof Error ? err.message : String(err)}`), { phase: "degraded", sessionId, classification: "env-git-snapshot" });
1981
+ }
1982
+ }
1983
+ }
1984
+ // Definite assignment: every branch below assigns via finishStable() or the legacy path (TS can't
1985
+ // see through the closure).
1986
+ let systemPrompt;
1987
+ // design/66 date_change: a multi-day task must see the CURRENT date. Everything except the date is
1988
+ // frozen at prepare time (stable base, MCP block, probed env facts, memory block), so re-rendering
1989
+ // with a new date changes ONLY the `Today's date:` line — the rest stays byte-identical (prefix-cache
1990
+ // loses just the variable tail, by §20 design). Set on the stableSystem/default paths; the legacy
1991
+ // free-form `provider.system` owns its own ordering and stays a static string.
1992
+ let renderWithDate;
1993
+ // Prompt-constitution assembly (sema 提示词主权批): the constitution layer (harness context + safety
1994
+ // blocks + mode blocks + memory guidance) is composed HERE, OUTSIDE the provider — same tier as the
1995
+ // skills/MCP/env/memory tails — so a custom role provider physically cannot drop it (the class of bug
1996
+ // where a deployment's `stablePrompt(base)` replacement silently ran business scenarios with no
1997
+ // truth/safety blocks). `replaceAll: true` is the explicit personalization escape hatch. Every path
1998
+ // records a labelled block manifest → the `prompt.assembled` trace event (black-box killer: "which
1999
+ // blocks were in the prompt" becomes a runtime fact, not archaeology).
2000
+ // design/138 S4: memoryEnabled/consolidationEnabled are the LEGACY-path prompt flags (MEMORY_SAFETY/
2001
+ // MEMORY_HYGIENE constitution blocks) — permanently false now; the engine path injects the CC-verbatim
2002
+ // `# Memory` instruction via injection.block instead (same as pre-S4, which also passed false here).
2003
+ const promptCtx = { userSystemPrompt, userAppendSystemPrompt, tools, memoryEnabled: false, consolidationEnabled: false, ...featureFlags };
2004
+ let promptConstitution = "core";
2005
+ let promptBlocks = [];
2006
+ const finishStable = (stableCore) => {
2007
+ const stable = composeSystemPrompt(composeSystemPrompt(stableCore, skillsBlock), mcpInstructionsBlock);
2008
+ if (skillsBlock?.trim())
2009
+ promptBlocks.push({ id: "skills.block", text: skillsBlock });
2010
+ if (mcpInstructionsBlock?.trim())
2011
+ promptBlocks.push({ id: "mcp.instructions", text: mcpInstructionsBlock });
2012
+ renderWithDate = (date) => composeSystemPrompt(composeSystemPrompt(stable, buildEnvironmentContext({ ...envFacts, date }) || undefined), memoryBlock);
2013
+ systemPrompt = renderWithDate(envFacts.date);
2014
+ const envBlock = buildEnvironmentContext({ ...envFacts, date: envFacts.date });
2015
+ if (envBlock?.trim())
2016
+ promptBlocks.push({ id: "env.context", text: envBlock });
2017
+ if (memoryBlock?.trim())
2018
+ promptBlocks.push({ id: "memory.tail", text: memoryBlock });
2019
+ };
2020
+ if (provider === defaultPromptProvider) {
2021
+ // No custom provider (the ?? fallback): default role base + constitution. Checked FIRST —
2022
+ // defaultPromptProvider implements stableSystem whose output contains the constitution anchor,
2023
+ // so falling into the generic branch below would misroute every plain task through the
2024
+ // migration guard (caught by the prompt-constitution test suite on first run).
2025
+ const c = composeConstitution(userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT, promptCtx);
2026
+ promptBlocks = [...c.blocks];
2027
+ finishStable(c.text);
2028
+ }
2029
+ else if (provider.stableSystem) {
2030
+ const roleOut = provider.stableSystem(promptCtx);
2031
+ if (provider.replaceAll) {
2032
+ // Explicit full replacement: the provider owns the whole stable base (constitution deliberately
2033
+ // omitted — the manifest marks it so the omission is observable, never accidental).
2034
+ promptConstitution = "replaced";
2035
+ promptBlocks = [{ id: "provider.replace_all", text: roleOut }];
2036
+ finishStable(roleOut);
2037
+ }
2038
+ else if (roleOut.includes(CYBER_RISK) && roleOut.includes(URL_SAFETY) && roleOut.includes("# Harness")) {
2039
+ // Migration guard: a historic provider that returns an ALREADY-assembled prompt (e.g. it wraps
2040
+ // `defaultPromptProvider.stableSystem`) — appending the constitution again would double every
2041
+ // safety block. Pass through un-doubled, but surface the upgrade path loudly.
2042
+ // Detection = ALL THREE anchors (codex [B]): a role base merely QUOTING one safety line no longer
2043
+ // trips it (that path gets the constitution appended; a duplicated quote is harmless). Adversarial
2044
+ // bound: a provider is CODE (function-valued, in-process only — a wire-level TaskSpec cannot carry
2045
+ // one), so whoever can spoof this already runs deployment code; and the manifest records the mode,
2046
+ // so a pass-through is observable, never silent.
2047
+ promptConstitution = "provider-assembled";
2048
+ promptBlocks = [{ id: "provider.assembled", text: roleOut }];
2049
+ deps.onError?.(new Error("prompt-constitution: this PromptProvider returns an already-assembled prompt (constitution anchor found). Core now appends the constitution structurally — return ONLY the role base from stableSystem (or set replaceAll:true to own the whole base). Passed through un-doubled."), { phase: "prompt-constitution", sessionId });
2050
+ finishStable(roleOut);
2051
+ }
2052
+ else {
2053
+ // Role-layer semantics (the new contract): provider supplies the role base; core appends the
2054
+ // constitution structurally. The easy path is now the correct path.
2055
+ const c = composeConstitution(roleOut, promptCtx);
2056
+ promptBlocks = [...c.blocks];
2057
+ finishStable(c.text);
2058
+ }
2059
+ }
2060
+ else if (provider.system) {
2061
+ // Legacy free-form path: the provider owns ordering. Lint it — memory present but NOT at the tail
2062
+ // means the cacheable prefix shifts every turn (vLLM/Anthropic prefix cache misses). Surface loudly.
2063
+ systemPrompt = provider.system({ ...promptCtx, memoryBlock });
2064
+ promptConstitution = "legacy";
2065
+ promptBlocks = [{ id: "provider.legacy", text: systemPrompt }];
2066
+ const memTrim = memoryBlock?.trim();
2067
+ if (memTrim && systemPrompt.includes(memTrim) && !systemPrompt.trimEnd().endsWith(memTrim)) {
2068
+ deps.onError?.(new Error("prompt-cache: the <user_memory> block is not at the END of the system prompt — the cacheable prefix changes every turn, so vLLM/Anthropic prefix caching will miss. Put memoryBlock LAST (STABLE→VARIABLE), or implement PromptProvider.stableSystem and let core place memory."), { phase: "prompt-cache", sessionId });
2069
+ }
2070
+ }
2071
+ else {
2072
+ // No provider: default role base (task systemPrompt or the neutral default) + constitution.
2073
+ const c = composeConstitution(userSystemPrompt ?? DEFAULT_SYSTEM_PROMPT, promptCtx);
2074
+ promptBlocks = [...c.blocks];
2075
+ finishStable(c.text);
2076
+ }
2077
+ // design/119 S3: model-declared prompt guidance (CC 198 per-model mitigations, declaratively).
2078
+ // Appended at the very end of the assembled prompt via the same renderWithDate wrapper the
2079
+ // date_change re-render uses, so a mid-task date rollover keeps the guidance.
2080
+ if (model.promptGuidance && model.promptGuidance.length > 0) {
2081
+ const guidance = model.promptGuidance.join("\n\n");
2082
+ const baseRender = renderWithDate;
2083
+ if (baseRender) {
2084
+ renderWithDate = (date) => composeSystemPrompt(baseRender(date), guidance);
2085
+ systemPrompt = renderWithDate(envFacts.date);
2086
+ }
2087
+ else {
2088
+ // Legacy PromptProvider.system path has no date re-render — append once (workflow review:
2089
+ // the previous non-null assertion crashed this branch at runtime).
2090
+ systemPrompt = composeSystemPrompt(systemPrompt, guidance) ?? systemPrompt;
2091
+ }
2092
+ promptBlocks.push({ id: "model.guidance", text: guidance });
2093
+ }
2094
+ // 提示词主权批: the labelled composition manifest (id + size + short content hash per block) —
2095
+ // emitted by the run loop as `prompt.assembled` right after `task.start`. Hashes let a deployment
2096
+ // spot "same block id, different content" drift without shipping prompt text into traces. SALTED
2097
+ // with a per-process random value (codex [E]): an unsalted sha256 of a short, guessable block
2098
+ // (env facts, a one-line memory note) is a dictionary-preimage surface for whoever can read traces
2099
+ // but not prompts. Same-process comparability (drift detection) survives; cross-process hash
2100
+ // equality is deliberately not a feature.
2101
+ const promptManifest = {
2102
+ constitution: promptConstitution,
2103
+ blocks: promptBlocks.map((b) => ({ id: b.id, chars: b.text.length, hash: createHash("sha256").update(PROMPT_HASH_SALT).update(b.text).digest("hex").slice(0, 12) })),
2104
+ };
2105
+ // Deferred-tool disclosure (design/36): when any caller/MCP tool is deferred, it ships as a name-only
2106
+ // placeholder and an injected `tool_search` materializes full schemas on demand. Materialization runs
2107
+ // as a side-effect of `tool_search.execute`, which completes BEFORE the harness snapshots tools for the
2108
+ // next turn — so the run loop is untouched and the new schemas land in the next request.
2109
+ const userToolNames = (spec.tools ?? []).map((t) => t.name);
2110
+ const mcpToolNames = mcp.tools.map((t) => t.name);
2111
+ const deferred = classifyDeferred({
2112
+ specs: spec.tools ?? [],
2113
+ mcpToolNames,
2114
+ fullTools: tools.filter((t) => userToolNames.includes(t.name) || mcpToolNames.includes(t.name)),
2115
+ deferMode: deps.deferMode,
2116
+ model,
2117
+ });
2118
+ const activeTools = new Set();
2119
+ // `harnessRef` declared above (tool ctx.model + harness↔tool_search cycle). `fpRef` lets `rematerialize`
2120
+ // reach the fingerprint object built after the harness; both are populated before any tool_search runs.
2121
+ const fpRef = {}; // fp = cache fingerprint
2122
+ let harnessTools = tools;
2123
+ // G1 [482]: created inside the deferred branch below (a task without deferred tools has no delta source).
2124
+ let toolsDeltaRef;
2125
+ if (deferred.size > 0) {
2126
+ // Our injected tool_search must not collide with a caller/MCP tool (the harness Map is last-write-wins).
2127
+ if (deferred.has(TOOL_SEARCH_NAME) || tools.some((t) => t.name === TOOL_SEARCH_NAME)) {
2128
+ const e = new Error(`Tool name "${TOOL_SEARCH_NAME}" is reserved when deferred tools are present.`);
2129
+ e.code = "config.reserved_tool_name";
2130
+ throw e;
2131
+ }
2132
+ const registry = buildDeferredRegistry(deferred, tools);
2133
+ const placeholders = new Map([...registry.values()].map((i) => [i.name, makePlaceholderTool(i)]));
2134
+ // Seed the active set from the log so a RESUMED session keeps its previously-activated tools (else
2135
+ // they'd silently revert to placeholders). `buildContext` returns [] for a fresh session.
2136
+ const { messages } = await session.buildContext();
2137
+ for (const n of extractDiscoveredToolNames(messages, registry)) {
2138
+ if (deferred.has(n))
2139
+ activeTools.add(n);
2140
+ }
2141
+ if (resume) {
2142
+ // design/45 §4.bis: re-seed the activated set from the checkpoint (authoritative), on top of what
2143
+ // the rewound transcript implies, so a resumed task never reverts an activated tool to a placeholder.
2144
+ for (const n of resume.seed.activeTools)
2145
+ if (deferred.has(n))
2146
+ activeTools.add(n);
2147
+ }
2148
+ let toolSearch;
2149
+ const buildToolList = (active) => {
2150
+ const list = tools.map((t) => (deferred.has(t.name) && !active.has(t.name) ? placeholders.get(t.name) : t));
2151
+ list.push(toolSearch);
2152
+ return list;
2153
+ };
2154
+ // G1 通告层 [482] (CC deferred_tools_delta parity): track which activations the model has been
2155
+ // TOLD about at a boundary. Seeded with the prepare-time actives (transcript-derived + resume
2156
+ // checkpoint reseed above) — those were active before this leg's first turn, so a resume never
2157
+ // replays old announcements (TD3). The diff below is append-only and dedup'd by this set.
2158
+ const announcedTools = new Set(activeTools);
2159
+ toolsDeltaRef = { pending: [] };
2160
+ const deltaRef = toolsDeltaRef;
2161
+ const rematerialize = async (active) => {
2162
+ const list = buildToolList(active);
2163
+ await harnessRef.current.setTools(list);
2164
+ if (fpRef.current)
2165
+ fpRef.current.tools = toolsToFingerprintInputs(list);
2166
+ // Diff AFTER the setTools landed (announce only what is truly live on the harness).
2167
+ for (const n of active) {
2168
+ if (!announcedTools.has(n)) {
2169
+ announcedTools.add(n);
2170
+ deltaRef.pending.push(n);
2171
+ }
2172
+ }
2173
+ };
2174
+ toolSearch = makeToolSearchTool({ registry, active: activeTools, rematerialize });
2175
+ harnessTools = buildToolList(activeTools);
2176
+ }
2177
+ // G1 通告层续批 (CC agent_listing_delta) — the announce face: read the roster off the mounted
2178
+ // delegation tool (ToolSpec.agentListing, filled by createSubagentTool; deployment tools normally
2179
+ // leave it unset). Static per-leg; on a durable resume leg the run loop seeds the producer as
2180
+ // already-announced ONLY after confirming a listing reminder in the transcript (MED-3② — TD3
2181
+ // discipline, made delivery-confirmed instead of leg-assumed).
2182
+ const agentListingSpec = (spec.tools ?? []).find((t) => t.agentListing !== undefined && t.agentListing.length > 0);
2183
+ const agentListing = agentListingSpec
2184
+ ? { entries: agentListingSpec.agentListing, toolName: agentListingSpec.name, seedAnnounced: resume !== undefined }
2185
+ : undefined;
2186
+ // design/66 date_change: the vendored harness re-evaluates a systemPrompt FUNCTION at every turn
2187
+ // boundary (createTurnState), so a multi-day task picks up a date flip on the next request. Memoized:
2188
+ // same date ⇒ the SAME string instance is returned (byte-stability is trivially guaranteed). On a
2189
+ // flip, the design/31 fingerprint is refreshed in lockstep so the resulting one-off cache miss is
2190
+ // truthfully attributed to "system-prefix" instead of surfacing as a mystery break (frozen
2191
+ // fingerprint = false-negative direction). Legacy `provider.system` path keeps the static string.
2192
+ let promptDate = envFacts.date;
2193
+ let renderedPrompt = systemPrompt;
2194
+ const liveSystemPrompt = renderWithDate
2195
+ ? () => {
2196
+ // C1: re-derive in the user's zone so the date flips at the USER's midnight, not the container's UTC midnight.
2197
+ // Use the VALIDATED zone (tzValid) — an invalid zone re-renders as UTC, consistent with the `(UTC)` annotation.
2198
+ const today = formatLocalDate(new Date(), tzValid ? userTz : undefined);
2199
+ if (today !== promptDate) {
2200
+ promptDate = today;
2201
+ // Non-null: this closure only exists on the renderWithDate branch of the ternary below;
2202
+ // assignment now happens inside finishStable, which TS's flow analysis can't see through.
2203
+ renderedPrompt = renderWithDate(today);
2204
+ if (fpRef.current)
2205
+ fpRef.current.systemPrompt = renderedPrompt;
2206
+ }
2207
+ return renderedPrompt;
2208
+ }
2209
+ : undefined;
2210
+ const harness = new AgentHarness({
2211
+ // design/119 #2: per-request output cap (spec.limits.maxOutputTokens → provider max_tokens).
2212
+ // Guard: a non-positive cap would ride to the wire as max_tokens=0 → provider 400; ignore it
2213
+ // loudly instead (design/120 review).
2214
+ ...(spec.limits?.maxOutputTokens !== undefined && spec.limits.maxOutputTokens > 0
2215
+ ? { maxOutputTokens: spec.limits.maxOutputTokens }
2216
+ : {}),
2217
+ ...(callCapRef !== undefined && callCapOn
2218
+ ? {
2219
+ maxOutputTokensPerCall: () => {
2220
+ // service [405] P0: a model OBSERVED emitting self-directed reasoning is never shrunk
2221
+ // again — reasoning tokens bill against max_tokens with no visible output, so the
2222
+ // wall-clock cap math decapitates the thought mid-stream. Observation (a thinking
2223
+ // block, or a shrunk call ending `length` with zero output — both set in runtask) is
2224
+ // the ONLY stand-down source: a `Model.reasoning` metadata gate would ALSO switch off
2225
+ // Anthropic budget-thinking's live [1024,2047] computeCallCap contract (镜头A MED #5)
2226
+ // and a wrong `false` was exactly the TB hole (镜头B Q4). Worst case = ONE capped
2227
+ // first call, which the dynamicCut length-recovery turns into a continue, not a death.
2228
+ if (callCapRef.reasoningObserved === true)
2229
+ return undefined;
2230
+ const cap = computeCallCap(callCapRef, Date.now(), (thinking ?? "off") !== "off");
2231
+ if (cap !== undefined)
2232
+ callCapRef.shrinks = (callCapRef.shrinks ?? 0) + 1; // B2: mechanisms.capShrinks
2233
+ return cap;
2234
+ },
2235
+ }
2236
+ : {}),
2237
+ // design/130 P2b: soft per-call wall-clock deadline — the brain cuts a stream that crosses it
2238
+ // (salvaging the prefix) instead of riding into the hard abort. Inert until runtask arms
2239
+ // deadlineMs + cushionMs on the ref (gracefulFinalize gates the cushion).
2240
+ ...(callCapRef !== undefined
2241
+ ? { callDeadlineMsPerCall: () => softExecDeadlineMs(callCapRef) }
2242
+ : {}),
2243
+ // design/131: per-task resilience intent → every provider call (decorators stand aside).
2244
+ ...(spec.resilience !== undefined ? { resilience: spec.resilience } : {}),
2245
+ // service [398] C2: bridge the loop's self-heal recoveries to the trace stream (`loop.recovery`).
2246
+ // Only RECOVERY continues are bridged — the three normal drive reasons (next_turn / steer /
2247
+ // follow_up) are the loop's ordinary heartbeat, not fallback events.
2248
+ loopTrace: (step) => {
2249
+ if (step.kind !== "continue")
2250
+ return;
2251
+ if (step.reason === "next_turn" || step.reason === "steer_injected" || step.reason === "follow_up_injected")
2252
+ return;
2253
+ emitTrace(deps.tracer, () => ({
2254
+ kind: "loop.recovery",
2255
+ version: 1,
2256
+ taskId: spec.taskId ?? sessionId,
2257
+ reason: step.reason,
2258
+ ts: Date.now(),
2259
+ }));
2260
+ },
2261
+ // design/120 P2: in-stream tool execution opt-in. The harness's own run-start snapshot
2262
+ // (no `tool_call` handler registered) is the real gate keeper — on a run where the policy/
2263
+ // approval gate below registers its handler, the flag never reaches the loop.
2264
+ ...(spec.streamingToolExecution === true ? { streamingToolExecution: true } : {}),
2265
+ // Blackboard 2026-07-03 (clay transcript 40e3f65f): the followUp queue's only producer in a
2266
+ // runtask deployment is the task-notification lane ("later" priority — workflow/agent
2267
+ // completions). The default one-at-a-time drain turned 8 buffered completions into 8 chained
2268
+ // turns, each interrupting the final answer. Drain them ALL into one boundary turn instead —
2269
+ // one turn digests every pending notification. (User steering keeps its own queue/mode.)
2270
+ followUpMode: "all",
2271
+ env: executionEnv,
2272
+ session,
2273
+ tools: harnessTools,
2274
+ model,
2275
+ thinkingLevel: thinking ?? "off",
2276
+ systemPrompt: liveSystemPrompt ?? systemPrompt,
2277
+ getApiKeyAndHeaders: spec.getApiKeyAndHeaders,
2278
+ runtime: brainToRuntime(deps.brain),
2279
+ // No `resources`: harness resources (skills/promptTemplates) were a write-only sink here — the
2280
+ // vendored readers were deleted in design/71 P0/P1. Skills are first-party now (see above).
2281
+ });
2282
+ // Now that the harness exists, let `tool_search`'s `rematerialize` reach it (design/36).
2283
+ harnessRef.current = harness;
2284
+ // `abortController` is created early (above, before any env op) for the resume VM-restore. It releases a
2285
+ // pending human-approval gate on abort so the worker isn't hung past its deadline (F4). Here — now that
2286
+ // the harness exists — wire the LIVE external-cancellation listener.
2287
+ // External cancellation: a caller (or a parent task's tool, via ToolExecuteContext.signal) can pass
2288
+ // `spec.signal` to abort this run. Mirror the timeout path: release the policy gate first
2289
+ // (abortController) then stop the loop (harness.abort) — the controller alone does not terminate it.
2290
+ let releaseSignal = () => undefined;
2291
+ if (spec.signal) {
2292
+ if (spec.signal.aborted) {
2293
+ // Already aborted before we started: mark the controller; run() short-circuits before prompt.
2294
+ abortController.abort();
2295
+ }
2296
+ else {
2297
+ const sig = spec.signal;
2298
+ const onExternalAbort = () => {
2299
+ abortController.abort();
2300
+ void harness.abort();
2301
+ };
2302
+ sig.addEventListener("abort", onExternalAbort, { once: true });
2303
+ // Must be removed on task end: a never-fired {once} listener otherwise lingers on a caller's
2304
+ // long-lived/shared signal and pins this run's controller + harness (leak across many tasks).
2305
+ releaseSignal = () => sig.removeEventListener("abort", onExternalAbort);
2306
+ }
2307
+ }
2308
+ // Two-phase tool gate (design/37): a SINGLE `tool_call` handler runs PreToolUse hooks (collect arg
2309
+ // rewrites + context) then the tool policy (adjudicate on the final args). Funneling both phases
2310
+ // through one handler — instead of two `tool_call` handlers raced by the harness's last-write-wins
2311
+ // `emitHook` — makes the load-bearing invariant ("a hook's allow cannot bypass a policy deny/ask")
2312
+ // structural: the policy always runs last, in this call stack. A task's own policy/hooks/onAsk
2313
+ // override the Runner defaults.
2314
+ const policy = spec.toolPolicy ?? deps.toolPolicy;
2315
+ // design/99 §E6: per-session permission rules (deny-narrowing), composed BEFORE the caller policy like the
2316
+ // skill scope below. Read ONLY when a `sessionPolicyStore` is wired AND this task carries its own
2317
+ // `sessionId` — a delegated subagent has no `spec.sessionId`, so it inherits no session rules (symmetric
2318
+ // with the rules being keyed on the owner's session). v1 reads the LATEST rules at prepare time (an operator
2319
+ // change takes effect on resume), unlike the leg-frozen durable budget. Unset store / no sessionId / no
2320
+ // rules ⇒ undefined ⇒ zero behavior change (fully backward-compatible, opt-in).
2321
+ let sessionRulePolicy;
2322
+ if (deps.sessionPolicyStore && spec.sessionId) {
2323
+ const rules = await deps.sessionPolicyStore.getRules(spec.sessionId, spec.principal);
2324
+ if (rules) {
2325
+ sessionRulePolicy = createSessionRulePolicy(rules, { env: executionEnv, rootPath: taskRootPath, toolEffects });
2326
+ }
2327
+ }
2328
+ // design/77 §3 Gate-3: the manifest deny-narrowing policy. Active when ≥1 skill carries a manifest OR this
2329
+ // task INHERITED a parent manifest frame across the subagent boundary (design/77 §7 propagation).
2330
+ const skillScopePolicy = hasSkillManifest
2331
+ ? createActiveSkillScopePolicy({
2332
+ scope: skillScope,
2333
+ env: executionEnv,
2334
+ rootPath: taskRootPath,
2335
+ // MAJOR-2 fail-closed: the same effects map (spec.tools + hand band + folded MCP) so the scope can
2336
+ // DENY an allowlisted mutating tool that `allowPaths` cannot path-confine (e.g. bash).
2337
+ toolEffects,
2338
+ })
2339
+ : undefined;
2340
+ // The DENY-NARROWING layers (session rules, then skill scope — deny/allow-only, no ask, no rewrite). Each
2341
+ // layer's `deny` short-circuits the `deny > ask > allow` fold (tool-policy.ts:196): they can only SUBTRACT
2342
+ // capability, and a caller `allow` can never widen them back. Core OWNS this composition (the caller's
2343
+ // `spec.toolPolicy` cannot remove a narrowing layer). Re-checked on RESUME before an approved pending tool
2344
+ // (runtask.ts) so a rule TIGHTENED during suspend still applies.
2345
+ const narrowingLayers = [sessionRulePolicy, skillScopePolicy].filter((p) => p !== undefined);
2346
+ const denyNarrowingPolicy = narrowingLayers.length === 0 ? undefined : narrowingLayers.length === 1 ? narrowingLayers[0] : combinePolicies(...narrowingLayers);
2347
+ // SECURITY (bug-hunt MAJOR): the caller `policy` may return `allow{updatedInput}` — a REWRITE the LEADING
2348
+ // narrowing already passed (it checked the ORIGINAL args). combinePolicies cascades the rewrite forward, so
2349
+ // RE-APPEND the narrowing layers AFTER the caller to re-check the REWRITTEN args → a rewrite that escapes
2350
+ // allowDirs/skill-scope is DENIED (the live-path twin of the resume-path re-check). The narrowing layers are
2351
+ // deny/allow-only + idempotent, so the trailing re-check never perturbs ask-precedence or rewrites.
2352
+ const policyLayers = policy !== undefined ? [...narrowingLayers, policy, ...narrowingLayers] : narrowingLayers;
2353
+ // feat(parity-206) — CC 2.1.205/206 auto-mode safety rules, ALWAYS-ON and core-owned (a caller's
2354
+ // `spec.toolPolicy` cannot remove them, same doctrine as the narrowing layers): ① session-transcript
2355
+ // tamper deny (keyed on SEMA's real transcript dir, `($AGENT_DATA_DIR ?? ~/.ai-agent)/sessions`) and
2356
+ // ② unverifiable recursive-delete deny (`rm -rf $VAR` with no in-window assignment — «Fail closed»).
2357
+ // Both are DENY-only (deny short-circuits the deny>ask>allow fold → not auto-allowable by any permission
2358
+ // rule or `onAsk:"allow"`, the CC rule's exact clause) and fire only on two ultra-narrow signatures, so
2359
+ // the design/77 §4 stance (no blanket bash auto-tighten; unattended operation stays cheap) is preserved.
2360
+ // LEADING so they adjudicate original args; RE-APPENDED after a caller policy for the same rewrite-escape
2361
+ // reason as the narrowing layers above.
2362
+ const integrityGuardLayers = [createTranscriptIntegrityPolicy(), createUnverifiableDeletePolicy()];
2363
+ const guardedPolicyLayers = policy !== undefined ? [...integrityGuardLayers, ...policyLayers, ...integrityGuardLayers] : [...integrityGuardLayers, ...policyLayers];
2364
+ const effectivePolicy = guardedPolicyLayers.length === 1 ? guardedPolicyLayers[0] : combinePolicies(...guardedPolicyLayers);
2365
+ const hooks = spec.hooks ?? deps.hooks;
2366
+ const onAsk = spec.onAsk ?? deps.onAsk;
2367
+ // ── MAJOR-1 (design/77 §4 / design/70): write-capable hand band + destructive MCP visibility. ─────────────
2368
+ // DESIGN STANCE (chief designer's call): bash's irreversible-action protection is the DEPLOYMENT SANDBOX —
2369
+ // the egress/microVM boundary (design/53 §2.H, design/76 §4.2) — NOT a built-in per-bash suspend. The
2370
+ // per-tool irreversibility axis reliably covers TYPED tools (spec.tools AND the now-folded MCP tools).
2371
+ // Making every bash 'always' would force every shell command to suspend and break unattended operation, so
2372
+ // we DO NOT auto-tighten bash here. A finance/high-stakes profile may opt the hand band into 'always' via
2373
+ // its tool policy (an `ask`/deny on bash) — that knob is PROFILE-side, not built here.
2374
+ //
2375
+ // (b) The effect:write/idempotent hand-band tools (bash/edit_file/write_file, from toolEffects when a hand
2376
+ // env is injected) are CONSIDERED in the gate-registration predicate below: when the deployment HAS an
2377
+ // effect-aware policy/hook, the gate registers and the policy can adjudicate bash. (No-op when a policy/hook
2378
+ // already forces registration — but explicit, so the link is documented and survives a refactor.)
2379
+ const handWriteTools = handsEnabled
2380
+ ? Object.keys(HAND_TOOL_EFFECTS).filter((name) => {
2381
+ const eff = HAND_TOOL_EFFECTS[name];
2382
+ return eff === "write" || eff === "idempotent";
2383
+ })
2384
+ : [];
2385
+ // feat(parity-206): keyed on the CALLER-facing layers (`policyLayers`), NOT `effectivePolicy` — the
2386
+ // always-on integrity guard makes `effectivePolicy` unconditionally defined, and counting it here would
2387
+ // permanently silence the loud UNGATED warning below (the guard fires on two narrow tamper/delete
2388
+ // signatures only; it is not an effect-aware adjudicator for the write band).
2389
+ const hasEffectAwareGate = Boolean(policyLayers.length > 0 || hooks?.preToolUse);
2390
+ // (c) 🔴 LOUD fail-safe: a write-capable hand band OR a destructive MCP tool is present, but NO effect-aware
2391
+ // gate covers it (no policy, no PreToolUse hook) AND it is not otherwise marked egress/irreversible → its
2392
+ // irreversible actions are UNADJUDICATED. Emit a one-time loud warning so the misconfiguration is OBSERVABLE
2393
+ // instead of a silent auto-allow. (The MCP fold above already marks `destructiveHint` tools irreversible, so
2394
+ // a destructive MCP tool only reaches here when its server advertised NO destructiveHint annotation.)
2395
+ const destructiveMcpUngated = mcp.tools.some((t) => !irreversibleTools.has(t.name) && !egressTools.has(t.name) && (toolEffects.get(t.name) ?? "write") !== "read");
2396
+ // design/53 GAP3 (defense-in-depth): a FIRST-PARTY effect:"write" tool that FORGOT to declare egress:true /
2397
+ // irreversibility (e.g. a future external-write tool like open_gitea_issue) is also unadjudicated under no
2398
+ // policy/hook. Scan spec.tools the same way (egress/irreversible-marked tools are already covered by the
2399
+ // egressTools.size / irreversibleTools.size gate-registration above, so exclude them here) so the omission
2400
+ // is at least OBSERVABLE, not silent. Effect comes from toolEffects (filled on the spec.tools pass).
2401
+ const firstPartyWriteUngated = (spec.tools ?? [])
2402
+ .map((t) => t.name)
2403
+ .filter((name) => !irreversibleTools.has(name) && !egressTools.has(name) && toolEffects.get(name) === "write");
2404
+ if (!hasEffectAwareGate && (handWriteTools.length > 0 || destructiveMcpUngated || firstPartyWriteUngated.length > 0)) {
2405
+ // Compose the "what" label ADDITIVELY: when MULTIPLE surfaces are ungated the warning must name each, not
2406
+ // just the hand band (a destructive MCP / first-party write tool would otherwise be silently elided).
2407
+ const what = [
2408
+ handWriteTools.length > 0 ? `hand tools [${handWriteTools.join(", ")}]` : undefined,
2409
+ destructiveMcpUngated ? "MCP write tools" : undefined,
2410
+ firstPartyWriteUngated.length > 0 ? `write tools [${firstPartyWriteUngated.join(", ")}]` : undefined,
2411
+ ]
2412
+ .filter(Boolean)
2413
+ .join(" + ");
2414
+ // 飞轮 [520]③ (hookAgent noise): a deployment whose hook-runner spins a child run per hook firing
2415
+ // re-triggered this on EVERY firing — same deps, same shape, an error-level line each time. The warning
2416
+ // is deployment-configuration observability, so it dedupes per (deps, shape): the FIRST task with a
2417
+ // given ungated shape still fires loud (nothing goes silent — C-telemetry discipline), repeats of the
2418
+ // exact same shape on the same deployment are spam and are dropped. A DIFFERENT shape (new tool set)
2419
+ // fires again. Proper fix for the hook-runner remains spec-side (inherit a baseline policy).
2420
+ if (!ungatedWarnedShapes.get(deps)?.has(what)) {
2421
+ let shapes = ungatedWarnedShapes.get(deps);
2422
+ if (!shapes) {
2423
+ shapes = new Set();
2424
+ ungatedWarnedShapes.set(deps, shapes);
2425
+ }
2426
+ shapes.add(what);
2427
+ deps.onError?.(new Error(`write-capable ${what} are present but UNGATED — wire an effect-aware tool policy (or a PreToolUse hook). ` +
2428
+ `bash/MCP irreversible actions are otherwise unadjudicated; the deployment sandbox/egress boundary is the only protection (design/77 §4, design/53 §2.H). ` +
2429
+ `(This warning fires once per deployment per tool-set shape.)`), { phase: "config", sessionId });
2430
+ }
2431
+ }
2432
+ // PreToolUse `additionalContext`, stashed per tool-call id in phase 1 and drained onto the result in
2433
+ // the PostToolUse handler below (the `tool_call` return shape has no context channel of its own).
2434
+ const preToolContexts = new Map();
2435
+ // design/134 R3: tool-call ids the gate BLOCKED (policy/hook deny, plan-mode deny) or SUSPENDED.
2436
+ // Their error results are not tool EXECUTION failures — CC scopes them to the PermissionDenied
2437
+ // event domain — so the tool_result handler below fires NEITHER postToolUse NOR postToolUseFailure
2438
+ // for them, and the runner's postToolBatch collector excludes them from the turn batch. Entries
2439
+ // are one-shot (deleted on consumption), same discipline as preToolContexts.
2440
+ const blockedToolCalls = new Set();
2441
+ // design/134 复审 (codex HIGH): populate ONLY when a consumer exists. A blocked call's "immediate"
2442
+ // result skips the loop's finalize, so the tool_result handler never drains its entry — the real
2443
+ // consumers are (a) that handler's defensive delete (any per-tool post/pre hook wired) and (b) the
2444
+ // runner's batch-collector delete (postToolBatch wired). With neither, adds would only accumulate.
2445
+ const blockedTracked = Boolean(hooks?.postToolUse || hooks?.preToolUse || hooks?.postToolUseFailure || hooks?.postToolBatch);
2446
+ // design/70: egress tools force the gate even with no policy/hooks wired — otherwise an
2447
+ // unconfigured deployment would auto-allow an external write (the exact thing the marker forbids).
2448
+ // design/74 Slice 3c: ALSO enter this block when resource-suspend is eligible — the suspend helpers
2449
+ // (serializeCheckpointState / commitSuspendSaga / suspendForResource) live here and must be assigned even
2450
+ // for a task with no policy/hooks/egress (resourceSuspend is independent of durableApproval). The gate
2451
+ // *handler* itself stays guarded below, so a pure resource-suspend task keeps the default tool path.
2452
+ const resourceSuspendEligible = spec.resourceSuspend !== undefined &&
2453
+ (spec.checkpointStore ?? deps.checkpointStore) !== undefined &&
2454
+ !(offloadStore !== undefined && isVolatileOffloadStore(offloadStore)) &&
2455
+ // Structural remote check is CORRECT here (≠ the [500]② suspendVM-target capability split below): ANY
2456
+ // remote per-task env is eligible — a suspendable one snapshots via suspendVM, a non-suspendable one
2457
+ // (SSH/ADB host) takes the durable-park-only degrade (workspace persists on the target). Only a
2458
+ // NON-remote per-task env is ineligible (destroyed on suspend → corrupt resume).
2459
+ (ownedEnv === undefined || isRemoteExecutionEnv(ownedEnv));
2460
+ if (spec.resourceSuspend !== undefined && !resourceSuspendEligible) {
2461
+ // design/74 (council #5): opted into resource-slice suspend but NOT eligible — say WHY, else a resource
2462
+ // limit silently hard-fails (failed/limit.max_turns) where the operator expects a resumable `suspended`.
2463
+ const why = (spec.checkpointStore ?? deps.checkpointStore) === undefined
2464
+ ? "no CheckpointStore is wired"
2465
+ : offloadStore !== undefined && isVolatileOffloadStore(offloadStore)
2466
+ ? "tool-result offload uses the in-memory store (a resume needs durable results)"
2467
+ : "the per-task execution env is not a RemoteExecutionEnv (it would be destroyed on suspend)";
2468
+ deps.onError?.(new Error(`resourceSuspend is set but INACTIVE: ${why}; resource limits will hard-fail, not suspend`), {
2469
+ phase: "config",
2470
+ sessionId,
2471
+ });
2472
+ }
2473
+ // `effectivePolicy` folds the Gate-3 manifest scope (design/77 §3) over the caller policy; `irreversibleTools`
2474
+ // gates registration too (design/77 §4) so an irreversible tool in a no-policy deployment still registers.
2475
+ // MAJOR-1 (b): the write-capable hand band (`handWriteTools`) is adjudicated via this same predicate — when a
2476
+ // deployment wires an effect-aware policy/hook (`hasEffectAwareGate`), `effectivePolicy || hooks?.preToolUse`
2477
+ // is already true here, so the gate registers and the policy can adjudicate bash/edit_file/write_file. We do
2478
+ // NOT add hand tools as an independent activation term (that would force the gate — and a default-allow no-op
2479
+ // — on a no-policy deployment, breaking the unattended default path); the LOUD warning above makes a
2480
+ // no-policy + write-hands deployment OBSERVABLE instead.
2481
+ // feat(parity-206): `effectivePolicy` is now ALWAYS defined (the always-on integrity guard), so this
2482
+ // predicate — and the handler-registration twin below — always registers the gate. Deliberate: the CC
2483
+ // 2.1.205/206 auto-mode rules must hold precisely in the no-policy unattended deployment ("auto mode");
2484
+ // for every call outside the two guard signatures the adjudication is a cheap string-scan allow.
2485
+ if (effectivePolicy || hooks?.preToolUse || egressTools.size > 0 || irreversibleTools.size > 0 || resourceSuspendEligible || spec.enablePlanMode === true) {
2486
+ // Bound the policy check by the task's abort signal: even a policy that ignores `signal` (or an
2487
+ // operator who never answers) cannot hang the loop past timeout/cancel — on abort it resolves to a
2488
+ // deny so the harness can proceed to its own abort handling. `effectivePolicy` includes the Gate-3
2489
+ // manifest scope (design/77 §3) when a manifested skill is present, so the narrowing rides this same
2490
+ // abort-bound adjudicate path.
2491
+ const adjudicate = effectivePolicy
2492
+ ? (req) => raceAbort(
2493
+ // design/80 D-E-core (Part A2): inject the read-only durable budget snapshot onto EVERY request
2494
+ // the gate adjudicates (per-leg-immutable; the same wrapper D-2's safety axis flows through). A
2495
+ // stateless escalation policy reads `req.budget` to self-limit across resume legs; it cannot
2496
+ // widen it (frozen here, no write-back). Same object every call — read-only by contract.
2497
+ Promise.resolve(effectivePolicy.check({ ...req, budget: budgetSnapshot }, abortController.signal)), abortController.signal, () => ({
2498
+ action: "deny",
2499
+ reason: "policy check aborted (task timed out or cancelled)",
2500
+ }))
2501
+ : undefined;
2502
+ const resolveAskBound = async (decision, req) => {
2503
+ // design/91: time the SYNCHRONOUS human-review wait (the in-process `onAsk` await). `t0`/`t1` come from
2504
+ // the injectable `now` (NOT bare Date.now) so a test clock pins `waitMs`. Recorded for EVERY resolveAsk
2505
+ // — including the headless auto-deny / `"allow"` fast paths, which return ~0ms (a real human wait shows
2506
+ // a real delay). The wait is bucketed under `kind:"human"` (a synchronous policy ask has no durable
2507
+ // gate kind; it is the in-process analog of the `human` durable gate). **Budget-EXCLUDED** observable.
2508
+ const t0 = now();
2509
+ const resolved = await resolveAsk({
2510
+ toolName: req.toolName,
2511
+ toolCallId: req.toolCallId,
2512
+ args: req.args,
2513
+ message: decision.message ?? decision.reason ?? `approval required for "${req.toolName}"`,
2514
+ // design S1d §1.2: Runner-filled source identity so an AGGREGATING onAsk (one approver fanning
2515
+ // over N delegated workers) can attribute each ask per worker (sourceTaskId) / user (principal).
2516
+ // Non-aggregating approvers ignore these (additive, no-op).
2517
+ principal: spec.principal,
2518
+ sourceTaskId: sessionId,
2519
+ }, onAsk, abortController.signal);
2520
+ const waitMs = Math.max(0, now() - t0); // clamp: a non-monotonic injected clock must never refund time
2521
+ humanReviewRef.count += 1;
2522
+ humanReviewRef.totalWaitMs += waitMs;
2523
+ // design/99 MF-24 (Service AI [297]): record the gated tool + a SHORT, SECRET-SCRUBBED input summary for
2524
+ // the permission-denial ledger ("denied: Bash(rm …)"). The input is in scope on this synchronous path.
2525
+ const toolArg = primaryActivityArg(req.args);
2526
+ humanReviewRef.gates.push({
2527
+ kind: "human",
2528
+ waitMs,
2529
+ // `resolveAsk` only ever returns a terminal allow/deny (never `ask`) — record which the operator chose.
2530
+ decision: resolved.action === "allow" ? "allow" : "deny",
2531
+ toolName: req.toolName,
2532
+ ...(toolArg !== undefined ? { toolArg } : {}),
2533
+ });
2534
+ return resolved;
2535
+ };
2536
+ // design/134 R5 (CC PermissionDenied parity, service [418]③): the pre-wrapped deny observer — the
2537
+ // SAME three-way swallow posture as every 1.252 lifecycle callback (hook throw → onError(phase:
2538
+ // "hook") → a throwing onError sink is itself swallowed). Built ONCE here and consumed at exactly
2539
+ // TWO deny short-circuits: the plan-mode write-deny below and runToolGate's single deny exit (which
2540
+ // funnels policy/hook-ask/safety/shellGate denies). A durable suspend and a PreToolUse hook deny
2541
+ // never reach either site (R5 exclusions, enforced structurally in runToolGate).
2542
+ const hooksWithPermissionDenied = hooks?.permissionDenied ? hooks : undefined;
2543
+ const notifyPermissionDenied = hooksWithPermissionDenied
2544
+ ? async (payload) => {
2545
+ try {
2546
+ // Called THROUGH the hooks object so a class-instance Hooks keeps its `this` binding.
2547
+ await hooksWithPermissionDenied.permissionDenied(payload);
2548
+ }
2549
+ catch (err) {
2550
+ try {
2551
+ deps.onError?.(err instanceof Error ? err : new Error(String(err)), { phase: "hook", sessionId });
2552
+ }
2553
+ catch {
2554
+ /* throwing onError sink — swallowed */
2555
+ }
2556
+ }
2557
+ }
2558
+ : undefined;
2559
+ // design/45 durable suspend-on-approval (F4) + design/49 v1.5 remote suspend. Active only when BOTH a
2560
+ // CheckpointStore is wired and the task opted into `durableApproval` — else `undefined`, so the gate keeps
2561
+ // the synchronous onAsk path (opt-in, default-off). On a policy `ask` it persists a checkpoint with enough
2562
+ // per-task state to resume on any replica (§4.bis whitelist + the hand's readFileState + the remote
2563
+ // workspaceHandle), then aborts the run so it assembles as `status:"suspended"` (M2 blocked-pattern —
2564
+ // capture + abort + suspendRef, no vendored change).
2565
+ //
2566
+ // The closure is ordered as a commit protocol (design/49 §1/§4①, code-ready council BUG#1): a remote
2567
+ // `suspendVM()` (E2B pause) is the FIRST, atomic side effect; `checkpointStore.put()` is the commit point,
2568
+ // after which `suspendRef` is set with NO intervening await and the run NEVER falls back to onAsk (a durable
2569
+ // checkpoint plus an onAsk both resolving the same approval would double-resolve it). A PRE-commit failure
2570
+ // (including a `{ok:false}` suspendVM) returns `undefined` → safe onAsk fallback (VM untouched). The one
2571
+ // post-pause/pre-commit window — `put` fails after the VM is already paused — is handled explicitly by
2572
+ // unpausing (resumeVM) before any onAsk, or aborting if it can't be unpaused (§4①.split).
2573
+ // `pin` is best-effort and runs only AFTER the commit (its failure must not undo a durable suspend).
2574
+ const checkpointStore = spec.checkpointStore ?? deps.checkpointStore;
2575
+ // design/99 §K (center [318] forceDurableGate): a per-principal entitlement can FORCE this run onto the
2576
+ // durable-approval path — so a plain policy `ask` suspends to the wire (per-tool CC-faithful gate) even
2577
+ // when the caller did not opt into `durableApproval`. center's fleet-wide "every interactive run gates"
2578
+ // mandate. We synthesize the same `{scope}` the caller would supply (principal-isolated, no TTL → the
2579
+ // operator decides), letting the existing suspendAsk path below take over unchanged. Caller-supplied
2580
+ // `durableApproval` always wins (it may carry a tighter scope/ttl).
2581
+ // scope = `spec.principal || DEFAULT` (NOT `??`, DELIBERATELY — same council-MINOR pattern as the scope
2582
+ // read at ~L2223): an EMPTY-string principal must fall to the default ISOLATION scope, never be kept as a
2583
+ // shared `""` bucket that would collapse every empty-principal task into one cross-tenant scope. This
2584
+ // synthesized scope is exactly what L2223 would compute for a `forceDurableGate` ask, so the two agree.
2585
+ const durableApproval = spec.durableApproval ??
2586
+ (runtimeCaps?.forceDurableGate ? { scope: spec.principal || DEFAULT_IRREVERSIBLE_SCOPE } : undefined);
2587
+ // design/74 Slice 3a: the §4.bis correctness-state whitelist, extracted so BOTH the human-gate suspend
2588
+ // (below) and the design/74 resource-slice suspend (the turn-boundary trigger) serialize the SAME live
2589
+ // state. Typed `SerializedCheckpointState` (P3): a new CheckpointState field is a COMPILE error here
2590
+ // until handled — never a silent resume drop. `workspaceHandle` is the suspend-specific snapshot identity
2591
+ // (or undefined for a process-local suspend).
2592
+ const serializeCheckpointState = (workspaceHandle) => ({
2593
+ activeTools: [...activeTools],
2594
+ outputRef: { value: outputRef.value, set: outputRef.set },
2595
+ nestedStats: { ...nestedStats },
2596
+ consolidationNotes: undefined, // design/138 S4: legacy consolidation retired — nothing to carry
2597
+ readFileState: readFileStateForCheckpoint ? [...readFileStateForCheckpoint.entries()] : undefined,
2598
+ // design/78 Slice-1 (MAJOR-3 wiring): the SAFE-tier repair bundle is held in-memory by `runRepairLoop`
2599
+ // OUTSIDE the Runner (a thin composition over runTask). Two sources, in order:
2600
+ // 1. the LIVE bundle threaded by `runRepairLoop` for an in-flight repair attempt
2601
+ // (`internals.repairBundle`) — so a checkpoint minted MID-attempt (attemptCount>0) carries the
2602
+ // live Reflexion state + attemptCount, not `undefined` (the unwired-producer bug);
2603
+ // 2. else the RESUME seed (`resume.seed.repairBundle`) — the pass-through for a re-suspend of a run
2604
+ // that was itself resumed WITH a bundle (multi-suspend chain preservation, like consolidationNotes).
2605
+ // A worker that was never repair-driven serializes `undefined`. The bundle is `structuredClone`d (no
2606
+ // fn/Date) so the persisted copy is independent of the loop's mutable closure object.
2607
+ repairBundle: internals?.repairBundle !== undefined
2608
+ ? structuredClone(internals.repairBundle)
2609
+ : resume?.seed.repairBundle !== undefined
2610
+ ? structuredClone(resume.seed.repairBundle)
2611
+ : undefined,
2612
+ workspaceHandle,
2613
+ // codex 终审 1.255 F2: the live LOGICAL cwd + active EnterWorktree session at suspend — without
2614
+ // these a resume reset the cwd to the task root and orphaned the worktree session (Exit no-op,
2615
+ // double-Enter accepted). `undefined` when no tracked cwd / no active session (both optional).
2616
+ handsCwd: handsCwdRef?.current,
2617
+ activeWorktree: worktreeSessionRef?.current ? { ...worktreeSessionRef.current } : undefined,
2618
+ // SR-7 (CC F6c :698391-698398 + resume leg :707384-707399): snapshot this run's still-live
2619
+ // background tasks (SAME owner triple as listBackgroundTasks / the TaskOutput mounts) so the
2620
+ // resume leg can diff them against its own registry and announce the orphans in ONE CC-shaped
2621
+ // notice. Serialized at suspend COMMIT time (the saga runs mid-task, BEFORE the run-exit
2622
+ // teardown settles/evicts these rows) — the pre-kill truth lands on the checkpoint.
2623
+ runningBackgroundTasks: (() => {
2624
+ const live = defaultTaskRegistry
2625
+ .list({ owner: hostTaskId, scope: taskScope, sessionId })
2626
+ .filter((t) => t.status === "pending" || t.status === "running")
2627
+ .map((t) => ({ id: t.task_id, ...(t.description !== undefined ? { description: t.description } : {}) }));
2628
+ return live.length > 0 ? live : undefined;
2629
+ })(),
2630
+ // design/80 D-A: a `pendingSteer` is CONSUMED by the resume that injects it (runtask.ts, right after the
2631
+ // resume-continuation prompt) — it is NOT carried onto the next checkpoint. Re-seeding it would re-inject
2632
+ // the SAME guidance on every subsequent resume (a duplicate delivery). So a re-suspend mints
2633
+ // `pendingSteer: undefined`; a supervisor steers the NEW checkpoint with a fresh `setPendingSteer`. (This
2634
+ // is deliberately UNLIKE the `consolidationNotes`/`repairBundle` pass-through — those are cumulative
2635
+ // correctness state; a steer is a one-shot message already delivered to the model.)
2636
+ pendingSteer: undefined,
2637
+ });
2638
+ // design/74 Slice 3b: the put-commit + put-failure split (design/49 §4①), extracted so BOTH the
2639
+ // human-gate suspend (below) and the design/74 resource-slice suspend reuse the SAME commit semantics.
2640
+ // `put` is the point of no return; on put-failure a paused remote VM is unpaused (or the run aborted if
2641
+ // it can't be) so a paused VM is NEVER left without a committed checkpoint. Returns true iff committed —
2642
+ // the caller then sets suspendRef + stops the run ITS way (abort for the human gate, requestStopAfterTurn
2643
+ // for resource). `checkpointStore` is captured; callers gate on it being defined (the guard is defensive).
2644
+ const commitSuspendSaga = async (token, cp, remoteEnv, remoteHandle) => {
2645
+ if (!checkpointStore)
2646
+ return false;
2647
+ // design/138 §2.2 — harvest at the CHECKPOINT MINT point: memory written this slice must be
2648
+ // durable in the backend BEFORE the run suspends (the resume may land on another replica /
2649
+ // after the workspace is gone). Swallow-guarded — a memory failure never blocks the suspend.
2650
+ if (memoryEngineSession)
2651
+ await memoryEngineSession.harvest("checkpoint");
2652
+ try {
2653
+ await checkpointStore.put(token, cp);
2654
+ return true;
2655
+ }
2656
+ catch (putErr) {
2657
+ deps.onError?.(putErr, { phase: "config", sessionId });
2658
+ if (remoteEnv !== undefined && remoteHandle?.snapshotId !== undefined) {
2659
+ const back = await remoteEnv.resumeVM(remoteHandle.snapshotId, { abortSignal: abortController.signal });
2660
+ if (back.ok) {
2661
+ const init = await remoteEnv.postResumeInit();
2662
+ if (init.ok)
2663
+ return false; // VM restored to running → safe fallback (caller: NOT suspended)
2664
+ deps.onError?.(init.error, { phase: "config", sessionId });
2665
+ }
2666
+ else {
2667
+ deps.onError?.(back.error, { phase: "config", sessionId });
2668
+ }
2669
+ // Could not restore the paused VM → fatal: abort the run, never continue on a paused VM.
2670
+ abortController.abort();
2671
+ void harness.abort();
2672
+ return false;
2673
+ }
2674
+ // No remote VM was paused (process-local suspend) → put failure is safe to fall back.
2675
+ return false;
2676
+ }
2677
+ };
2678
+ // design/76 §2.5 — THE COMMIT-SIDE DISCRIMINANT (v4 MAJOR-A, the load-bearing dry-run fix). Called
2679
+ // AFTER `commitSuspendSaga` returns true (the checkpoint is durably committed) to publish which holder
2680
+ // the run loop reads for the terminal status. Branch on the COMMITTED `gate.kind`: a REVIEW-PAUSE gate
2681
+ // writes `reviewRef` (→ assemble-result slot 8.6 → `status:"needs_review"` / `review.pending`); every
2682
+ // approval/resource gate (human / irreversible_ask / resource_limit / task_done) writes `suspendRef` as
2683
+ // before (→ slot 8.5 → `status:"suspended"`). NEVER both — a review pause that also set `suspendRef`
2684
+ // would assemble as `"suspended"` and make slot 8.6 dead code. The review-pause family is BOTH the
2685
+ // dry-run `{kind:"needs_review"}` gate (design/76 §2.5) AND the design/80 D-B `{kind:"plan_review"}`
2686
+ // gate — both are human-review pauses that share the `needs_review` terminal (a `plan_review` resumes
2687
+ // with a `plan_review` outcome, a `needs_review` with `dry_run_review`; the distinct gate.kind keeps the
2688
+ // resume discriminant unambiguous). (The review checkpoints themselves are minted by a PROFILE, out of
2689
+ // core scope; core only owns this routing seam + the gate/status/resume discriminants.)
2690
+ const publishCommittedSuspend = (token, gate, scope) => {
2691
+ const ref = gate.kind === "needs_review" || gate.kind === "plan_review" ? reviewRef : suspendRef;
2692
+ ref.token = token;
2693
+ ref.gate = gate;
2694
+ // design/80 D-2 (r3 BLOCKER): stash the COMMITTED scope so the reap/cancel path reads it verbatim
2695
+ // instead of re-deriving from `spec` (which, after the cross-scope re-scope above, would mismatch the
2696
+ // row's scope → expire()'s WHERE never fires → stranded paused VM + orphan checkpoint).
2697
+ ref.scope = scope;
2698
+ };
2699
+ // design/72 §2.2 (B) / council design/74 #4: the shared suspend-loop-cap guard for BOTH the human gate
2700
+ // (`suspendAsk`) and the resource suspend (`suspendForResource`) — extracted so the cap logic can't drift
2701
+ // between them. Returns true when the cap is hit (the caller then bails with its own no-suspend value).
2702
+ // SAFETY STATE FIRST, diagnostic SECOND (council 2026-06-13 BUG): a deployment-owned `onError` that throws
2703
+ // must NOT skip the safety mutations and leave the run looping — commit hit + abort, THEN report.
2704
+ // design/74 (search [136] HIGH-1): generalized over (count, cap) so the restart-loop path and the
2705
+ // resource-slice path use SEPARATE counters/caps (suspendCount/maxSuspends vs ledger.sliceCount/maxSlices)
2706
+ // while sharing the safety-state-first commit (hit + abort, THEN best-effort diagnostic).
2707
+ const suspendLoopCapHit = (count, cap, detail) => {
2708
+ if (count + 1 <= cap)
2709
+ return false;
2710
+ suspendLoopRef.hit = true;
2711
+ abortController.abort();
2712
+ void harness.abort();
2713
+ // design/74 (codex Vector 5): a deployment-owned `onError` that THROWS must not unwind past the safety
2714
+ // state already committed above (which would reject `suspendForResource` instead of returning, breaking
2715
+ // the turn_boundary hook). The diagnostic is best-effort; the loop-fail state stands regardless.
2716
+ try {
2717
+ deps.onError?.(new Error(`suspend loop: task already suspended ${count} time(s) (max ${cap}); refusing to suspend again${detail}`), { phase: "config", sessionId });
2718
+ }
2719
+ catch {
2720
+ /* ignore a throwing onError — safety state is already set */
2721
+ }
2722
+ return true;
2723
+ };
2724
+ // design/74 Slice 3c: opt-in resource-slice suspend. A resource limit (turns/budget/walltime) hit at a
2725
+ // CLEAN turn boundary (no pending tool) mints a `resource_limit` checkpoint + pauses the workspace, then
2726
+ // stops the loop CLEANLY (requestStopAfterTurn — NOT abort, so NO orphan-[INTERRUPTED] reconcile), so the
2727
+ // run is RESUMABLE instead of failed. Reuses `serializeCheckpointState` (3a) + `commitSuspendSaga` (3b).
2728
+ // Exposed (≠ undefined) ONLY when ELIGIBLE: opted in, a durable store, durable tool-results, and a remote
2729
+ // per-task env — or a static caller-owned env (process-local, no workspace pause). A non-remote PER-TASK
2730
+ // env would be destroyed on suspend → corrupt resume, so it is refused (= human gate).
2731
+ //
2732
+ // service [500]② capability-split: pick the `suspendVM` target by CAPABILITY (`isSuspendable`), NOT the
2733
+ // structural `isRemoteExecutionEnv` — a non-suspendable remote env (SSH host / ADB:
2734
+ // `capabilities.suspendable:false`) has the method structurally but it returns `"unsupported"`, which used
2735
+ // to fail the whole suspend (plan_review park never landed → approval flow broke). Such an env instead
2736
+ // takes the DURABLE-PARK-ONLY degrade shared by all three suspend paths (resource / plan_review / ask):
2737
+ // no `suspendVM`, the checkpoint is minted as usual, and the workspaceHandle is captured WITHOUT a
2738
+ // `snapshotId` — resume then skips `resumeVM` (the host/device workspace persists on the target itself;
2739
+ // the factory reconnects to it). Both consts are task-constant (ownedEnv never changes after prepare).
2740
+ //
2741
+ // ⚠️ INVARIANT this selection rests on (opus review 1.257.2): `suspendable:false` remote ⇒ the
2742
+ // workspace is EXTERNALLY DURABLE (see the capabilities.suspendable doc in remote-env.ts). A
2743
+ // non-suspendable-but-EPHEMERAL adapter must not exist under this contract — it would take this
2744
+ // branch and silently resume onto a fresh workspace. If such a backend class appears, add an
2745
+ // explicit workspace-durability capability and gate `parkOnlyRemoteEnv` on it (fall back to the
2746
+ // human gate instead of parking).
2747
+ const suspendableEnv = ownedEnv !== undefined && isSuspendable(ownedEnv) ? ownedEnv : undefined;
2748
+ const parkOnlyRemoteEnv = suspendableEnv === undefined && ownedEnv !== undefined && isRemoteExecutionEnv(ownedEnv) ? ownedEnv : undefined;
2749
+ // Park-only identity capture: STRIP any snapshotId the env's handle may carry (`WorkspaceHandle.snapshotId`
2750
+ // = "this env was produced by resuming a snapshot", i.e. lineage — the park itself minted NO snapshot).
2751
+ // Persisting a stale lineage id would make the resume path call `resumeVM` on the non-suspendable env
2752
+ // ("unsupported" → failed resume); the ABSENT id is exactly what routes resume onto the skip-restore branch.
2753
+ const parkOnlyHandle = (env) => {
2754
+ const { snapshotId: _lineage, ...identity } = env.workspaceHandle();
2755
+ // `restoreMode` makes the park EXPLICIT in the durable record: resume distinguishes "parked on
2756
+ // purpose" from "snapshot handle that LOST its snapshotId" (corruption → fail-closed there).
2757
+ return { ...identity, restoreMode: "park_only" };
2758
+ };
2759
+ // Assign the task-scoped `suspendForResource` (forward-declared above). `resourceSuspendEligible` (computed
2760
+ // before this block, for the block guard) is the SAME eligibility predicate — reuse it so the two can't
2761
+ // drift; `suspendableEnv`/`parkOnlyRemoteEnv` re-derive the capability split the closure needs.
2762
+ suspendForResource = resourceSuspendEligible
2763
+ ? async (reason, sliceSpend) => {
2764
+ const rs = spec.resourceSuspend;
2765
+ if (!checkpointStore || !rs)
2766
+ return false;
2767
+ // A cancelling run must not mint an irreversible checkpoint (design/51 §5, same as the human gate).
2768
+ if (abortController.signal.aborted)
2769
+ return false;
2770
+ // design/72 §2.2 (B): refuse a re-suspend past the cap — fail the run instead of looping forever.
2771
+ // design/74 (search [136] HIGH-1): bound on the RESOURCE-slice counter (ledger.sliceCount) vs the
2772
+ // independent `maxSlices` — NOT the restart-loop suspendCount/maxSuspends. A long resource run is
2773
+ // legitimate; only a $0-progress slice loop trips this backstop (the $ ledger is the real bound).
2774
+ if (suspendLoopCapHit(priorLedger?.sliceCount ?? 0, maxSlices, " (resource slice limit)"))
2775
+ return false;
2776
+ const leafId = await session.getLeafId();
2777
+ if (!leafId)
2778
+ return false; // no committed leaf → no consistent resume point
2779
+ // LOW-10 (search [136]): re-check abort AFTER the async getLeafId — a cancel that landed DURING that
2780
+ // await must not proceed to mint an irreversible checkpoint + pause the VM (the human-gate path does
2781
+ // the same second check before its first durable side effect). Cheap insurance against a TOCTOU race.
2782
+ if (abortController.signal.aborted)
2783
+ return false;
2784
+ // suspendVM = the FIRST durable side effect (atomic seam: {ok:false} ⇒ VM untouched ⇒ safe to skip).
2785
+ let remoteHandle;
2786
+ if (suspendableEnv !== undefined) {
2787
+ // design/103 §3.7: kill background processes BEFORE suspendVM — a suspendable TOB env would otherwise
2788
+ // snapshot them and resume revive untracked zombies (breaking 不跨 suspend). Detached jobs are OUTSIDE
2789
+ // suspendVM's in-flight contract, so the runner MUST dispose explicitly here, not rely on the adapter.
2790
+ if (hasBackgroundShell(suspendableEnv))
2791
+ await suspendableEnv.disposeBackgroundShells();
2792
+ const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
2793
+ if (!snap.ok) {
2794
+ deps.onError?.(new Error(`resource suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
2795
+ return false;
2796
+ }
2797
+ remoteHandle = { ...suspendableEnv.workspaceHandle(), snapshotId: snap.value };
2798
+ }
2799
+ else if (parkOnlyRemoteEnv !== undefined) {
2800
+ // service [500]② durable-park-only: a non-suspendable remote env (SSH/ADB host) is NOT paused —
2801
+ // its workspace persists on the target itself. Capture identity WITHOUT a snapshotId so the
2802
+ // resume skips resumeVM and simply reconnects via the factory. Background shells are still
2803
+ // disposed (the run is stopping; a detached job must not outlive the leg untracked).
2804
+ if (hasBackgroundShell(parkOnlyRemoteEnv))
2805
+ await parkOnlyRemoteEnv.disposeBackgroundShells();
2806
+ remoteHandle = parkOnlyHandle(parkOnlyRemoteEnv);
2807
+ }
2808
+ const token = mintCheckpointToken();
2809
+ const gate = { kind: "resource_limit", reason };
2810
+ const cp = {
2811
+ token,
2812
+ version: RESOURCE_CHECKPOINT_VERSION, // v2 — an old worker rejects it pre-CAS
2813
+ scope: rs.scope,
2814
+ sessionId,
2815
+ leafId,
2816
+ gate,
2817
+ pendingAction: { kind: "resource_limit", reason },
2818
+ state: serializeCheckpointState(remoteHandle),
2819
+ status: "pending",
2820
+ createdAt: Date.now(),
2821
+ // design/74 (search [136] HIGH-2): stamp a `deadline` (createdAt + ttl, default 30 days) so the
2822
+ // durable reaper — which filters on `deadline` — can GC an ORPHANED (never-resumed) resource
2823
+ // suspend. Without it the `pending` row + in-memory pin leak forever. A legit auto-resume fires
2824
+ // long before this; the deadline only bounds abandonment.
2825
+ deadline: Date.now() + (rs.ttlMs ?? DEFAULT_RESOURCE_TTL_MS),
2826
+ // design/74 (search [136] HIGH-1): the resource slice does NOT advance the restart-loop
2827
+ // `suspendCount` — its place in the chain is the ledger's `sliceCount`. Carrying suspendCount
2828
+ // UNCHANGED keeps the restart-loop safety (maxSuspends) counting only restart suspends, and stops
2829
+ // resource slices from tripping that (low) cap. (The cap for slices is `maxSlices`, checked above.)
2830
+ suspendCount: priorSuspendCount,
2831
+ // design/91: a resource_limit suspend is NOT a human-review wait (no `suspendedAt`), but it must
2832
+ // CARRY FORWARD any human-review burden already accumulated this chain (a prior approval leg) so it
2833
+ // isn't lost across a resource leg. Snapshot the accumulator (omitted when empty). The resume reads
2834
+ // it back into the accumulator; no new latency is added for a resource resume (no human waited).
2835
+ humanReview: humanReviewRef.count > 0
2836
+ ? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
2837
+ : undefined,
2838
+ // design/74 Slice 4: debit THIS slice's spend onto the cross-slice ledger (prior + this slice; the
2839
+ // first slice seeds the human total from spec.resourceSuspend.totalBudgetUsd). The next resume
2840
+ // reads it to size its own budget = min(maxCostUsd, total − spent) — so a resume can't refresh it.
2841
+ resourceLedger: debitLedger(priorLedger, sliceSpend, resourceTotal),
2842
+ };
2843
+ if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
2844
+ return false;
2845
+ // ── COMMITTED. Set suspendRef + pin, then stop the loop CLEANLY (Slice 2: requestStopAfterTurn, NOT
2846
+ // abort): a resource suspend is at a clean turn boundary with no orphan tool calls, so the loop
2847
+ // exits via `agent_end` and the session is left resumable (no interrupted-reconcile corruption).
2848
+ // Via the commit-side discriminant (design/76 §2.5): a `resource_limit` gate routes to `suspendRef`.
2849
+ publishCommittedSuspend(token, gate, rs.scope); // design/80 D-2: stash the COMMITTED scope (== cp.scope)
2850
+ try {
2851
+ await sessions.pin?.(sessionId);
2852
+ }
2853
+ catch (pinErr) {
2854
+ deps.onError?.(pinErr, { phase: "config", sessionId });
2855
+ }
2856
+ harness.requestStopAfterTurn();
2857
+ return true;
2858
+ }
2859
+ : undefined;
2860
+ // design/80 D-B: mint a `plan_review` checkpoint at a CLEAN turn boundary when a tool requested review via
2861
+ // `ctx.requestReview()`. Mirrors the human/resource suspend commit saga (suspendVM → put → publish), but the
2862
+ // gate is a REVIEW pause: it routes to `reviewRef` (→ `status:"needs_review"`), binds NO tool call
2863
+ // (`pendingAction:{kind:"plan_review"}`), and ABORTS the loop (like the human gate — the run stops here and
2864
+ // assembles as needs_review; resume continues with a `plan_review` outcome). Present only with a
2865
+ // checkpointStore (else `ctx.requestReview` is a silent no-op). The plan text is in the tool's transcript
2866
+ // content, NOT on the gate (design/76 §9).
2867
+ suspendForReview = checkpointStore
2868
+ ? async (reason) => {
2869
+ if (!checkpointStore)
2870
+ return false;
2871
+ // A cancelling run must not mint a checkpoint (design/51 §5, same as the human/resource gates).
2872
+ if (abortController.signal.aborted)
2873
+ return false;
2874
+ // Refuse a re-suspend past the restart-loop cap (a model that re-requests review every resume) —
2875
+ // fail the run instead of looping (design/72 §2.2 B), same backstop as the human gate.
2876
+ if (suspendLoopCapHit(priorSuspendCount, maxSuspends, " for a plan_review (likely a resume/restart loop)."))
2877
+ return false;
2878
+ const leafId = await session.getLeafId();
2879
+ if (!leafId) {
2880
+ // 飞轮 [506]④ diagnosability: this was the ONLY silent drop on the plan_review park path (every
2881
+ // other failure logs via onError). A dropped HITL plan review must never vanish without signal.
2882
+ // codex 1.257.3 (LOW): diagnostic sink swallow-guarded — a throwing deployment onError would
2883
+ // otherwise reject this async branch instead of returning false (run continues).
2884
+ try {
2885
+ deps.onError?.(new Error("plan_review park skipped: no committed session leaf (the plan-review request was dropped; the run continues without pausing)"), { phase: "config", sessionId });
2886
+ }
2887
+ catch {
2888
+ /* diagnostic only — never fault the park path */
2889
+ }
2890
+ return false; // no committed leaf → no consistent resume point
2891
+ }
2892
+ if (abortController.signal.aborted)
2893
+ return false; // TOCTOU re-check after the async getLeafId
2894
+ // suspendVM = the FIRST durable side effect (atomic seam: {ok:false} ⇒ VM untouched ⇒ safe to skip).
2895
+ // service [500]② capability-split: `suspendableEnv` (NOT the structural isRemoteExecutionEnv) picks
2896
+ // the suspendVM target; a non-suspendable remote env takes the park-only branch below instead of
2897
+ // calling suspendVM (which returned "unsupported" → the plan_review park failed → approval flow broke).
2898
+ let remoteHandle;
2899
+ if (suspendableEnv !== undefined) {
2900
+ // design/103 §3.7: dispose background processes before suspendVM (see resource path) — no snapshot zombies.
2901
+ if (hasBackgroundShell(suspendableEnv))
2902
+ await suspendableEnv.disposeBackgroundShells();
2903
+ const snap = await suspendableEnv.suspendVM({ abortSignal: abortController.signal });
2904
+ if (!snap.ok) {
2905
+ deps.onError?.(new Error(`plan_review suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error }), { phase: "config", sessionId });
2906
+ return false;
2907
+ }
2908
+ remoteHandle = { ...suspendableEnv.workspaceHandle(), snapshotId: snap.value };
2909
+ }
2910
+ else if (parkOnlyRemoteEnv !== undefined) {
2911
+ // durable-park-only (see resource path): no pause; identity without snapshotId; resume reconnects.
2912
+ if (hasBackgroundShell(parkOnlyRemoteEnv))
2913
+ await parkOnlyRemoteEnv.disposeBackgroundShells();
2914
+ remoteHandle = parkOnlyHandle(parkOnlyRemoteEnv);
2915
+ }
2916
+ const token = mintCheckpointToken();
2917
+ // Review pause is principal-isolated (operator `/decide` arrives in the task's scope), same `|| DEFAULT`
2918
+ // empty-string rule as the human gate (L2253). A caller `durableApproval` scope/ttl, if any, wins.
2919
+ const scope = spec.durableApproval?.scope ?? (spec.principal || DEFAULT_IRREVERSIBLE_SCOPE);
2920
+ const gate = { kind: "plan_review", reason: reason ?? `plan review requested` };
2921
+ const cp = {
2922
+ token,
2923
+ version: BINDING_CHECKPOINT_VERSION, // ≤ MAX_SUPPORTED ⇒ current workers accept; plan_review resume already supported
2924
+ scope,
2925
+ sessionId,
2926
+ leafId,
2927
+ gate,
2928
+ pendingAction: { kind: "plan_review" },
2929
+ state: serializeCheckpointState(remoteHandle),
2930
+ status: "pending",
2931
+ createdAt: Date.now(),
2932
+ // A plan_review is a HUMAN-DECISION wait (like the approval gate, NOT a resource slice): stamp
2933
+ // `suspendedAt` so resume derives this leg's human-review latency = `resumeNow − suspendedAt`
2934
+ // (design/91), and ADVANCE `suspendCount` so the maxSuspends restart-loop cap actually bounds a
2935
+ // present_plan→reject→present_plan loop (a non-advancing count would never trip the cap above).
2936
+ suspendedAt: now(),
2937
+ deadline: Date.now() + (spec.durableApproval?.ttlMs ?? DEFAULT_RESOURCE_TTL_MS), // bound an abandoned review
2938
+ suspendCount: priorSuspendCount + 1,
2939
+ humanReview: humanReviewRef.count > 0
2940
+ ? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
2941
+ : undefined,
2942
+ resourceLedger: priorLedger, // carry forward unchanged (no slice debit for a review pause)
2943
+ // Source attribution (parity with the approval gate) so a service supervisor inbox can attribute a
2944
+ // plan_review pause to the issuing task/principal (worker-unforgeable: sessionId, not a tool arg).
2945
+ sourceTaskId: sessionId,
2946
+ ...(spec.principal ? { principal: spec.principal } : {}),
2947
+ };
2948
+ if (!(await commitSuspendSaga(token, cp, suspendableEnv, remoteHandle)))
2949
+ return false;
2950
+ // ── COMMITTED. Route plan_review → reviewRef (design/76 §2.5 discriminant), pin, then ABORT the loop
2951
+ // (like the human gate — the run stops here and assembles as needs_review; design/45 capture+abort).
2952
+ publishCommittedSuspend(token, gate, scope);
2953
+ try {
2954
+ await sessions.pin?.(sessionId);
2955
+ }
2956
+ catch (pinErr) {
2957
+ deps.onError?.(pinErr, { phase: "config", sessionId });
2958
+ }
2959
+ abortController.abort();
2960
+ void harness.abort();
2961
+ return true;
2962
+ }
2963
+ : undefined;
2964
+ // design/77 §4.4 (load-bearing build-time fix): arm the durable suspend for an IRREVERSIBLE tool even
2965
+ // when the deployment did NOT opt into `durableApproval`. Otherwise the Gate-4 tighten (allow→ask) in an
2966
+ // unattended deployment degrades to a headless auto-DENY (a model-facing deny = reward-hack retry
2967
+ // surface) — the exact surface this gate exists to remove. The closure below is PARAMETERIZED: with
2968
+ // `durableApproval` it mints the normal `{kind:"human"}` checkpoint under its scope/ttl; without it
2969
+ // (irreversible-only case) it mints `{kind:"irreversible_ask"}` under the DEFAULT_IRREVERSIBLE_SCOPE and
2970
+ // no TTL. Reading `durableApproval.scope`/`.ttlMs` unconditionally here would throw TypeError →
2971
+ // `undefined` → the headless deny it must avoid (that is the inert-gate bug §4.4 calls out).
2972
+ const suspendAsk =
2973
+ // design/80 D-2 (egress predicate): egress-marked tools must arm the durable suspend too — a THIRD OR
2974
+ // inside the `checkpointStore &&` conjunction (not a sibling), so a no-store deployment still falls to
2975
+ // the store-gated headless path and never crash/hangs. Without this, an egress tighten with no
2976
+ // `durableApproval` falls to a headless auto-DENY (design/77 §4.4 reward-hack retry surface).
2977
+ checkpointStore && (durableApproval || irreversibleTools.size > 0 || egressTools.size > 0)
2978
+ ? async (req, postHookArgs, safety) => {
2979
+ // ── PRE-COMMIT (read-only prep + the remote pause). Any failure here is SAFE: the checkpoint
2980
+ // is not yet persisted and (per the suspendVM atomicity contract) the remote VM is untouched,
2981
+ // so we fall back to the synchronous onAsk gate rather than silently dropping the approval.
2982
+ let token;
2983
+ let gate;
2984
+ let cp;
2985
+ // The remote workspace handle: with a snapshotId when a remote per-task env was actually
2986
+ // `suspendVM`-paused below, WITHOUT one on the [500]② park-only degrade (non-suspendable remote
2987
+ // env; identity only) — drives both the checkpoint field and the put-failure unpause path
2988
+ // (design/49 §4①.split — the unpause is keyed on `snapshotId`, so a park never "unpauses").
2989
+ let remoteHandle;
2990
+ const remoteEnv = suspendableEnv;
2991
+ try {
2992
+ // §4.bis / Minor #1: durable suspend requires durable tool-result storage. The default
2993
+ // in-memory offload store would deref to null after a resume on a fresh instance — refuse
2994
+ // to suspend (fail-fast) rather than resume into corrupt state. Offload disabled (no store)
2995
+ // is fine: results stay inline in the session and are durable with it.
2996
+ // design/80 D-2 (council MAJOR): fail-fast ONLY if the in-memory store ACTUALLY holds offloaded
2997
+ // results a cross-replica resume would lose. An EMPTY in-memory store has nothing to lose, so a
2998
+ // durable suspend is safe — this is what makes the D-2 egress/irreversible_ask suspend WORK on
2999
+ // the common "wire only a checkpointStore" deployment (default offload, but small args that
3000
+ // never offloaded) instead of silently throwing here → caught below → headless auto-DENY (the
3001
+ // very reward-hack surface D-2's egress predicate removes). A store with entries still fails fast.
3002
+ if (offloadStore && isVolatileOffloadStore(offloadStore) && !offloadStore.isEmpty()) {
3003
+ throw new Error("durable suspend requires a durable toolResultStore (the default InMemoryToolResultStore " +
3004
+ "holds offloaded results that are lost across a cross-replica resume); inject " +
3005
+ "RunnerDeps.toolResultStore or disable offload.");
3006
+ }
3007
+ // design/49 v1.5: a per-task `executionEnvFactory` env that is REMOTE survives the suspend via
3008
+ // `suspendVM()` (E2B pause) instead of being destroyed — resume rebuilds it + `resumeVM()`. A
3009
+ // per-task env that is NOT remote (theoretical — the factory only mints remote) cannot be
3010
+ // paused, so it would still be destroyed on finish() → a fresh empty env on resume → corrupt
3011
+ // readFileState. Refuse that one (the v1 fail-fast, now narrowed to non-remote per-task envs).
3012
+ // A static, caller-owned `deps.executionEnv` (ownedEnv === undefined) survives in-process and
3013
+ // suspends process-locally (no workspaceHandle), exactly as in v1. A NON-SUSPENDABLE remote
3014
+ // per-task env ([500]② park-only: `parkOnlyRemoteEnv`) is fine too — its workspace persists on
3015
+ // the target itself, so no pause is needed; only a non-remote per-task env is refused.
3016
+ if (ownedEnv !== undefined && remoteEnv === undefined && parkOnlyRemoteEnv === undefined) {
3017
+ throw new Error("durable suspend is not supported with a non-remote per-task executionEnvFactory env: the " +
3018
+ "minted env is destroyed on suspend, so a resumed file/shell tool would act on a fresh " +
3019
+ "(empty) env. Use a RemoteExecutionEnv factory (it is paused, not destroyed) or a static, " +
3020
+ "caller-owned RunnerDeps.executionEnv.");
3021
+ }
3022
+ const leafId = await session.getLeafId();
3023
+ if (!leafId) {
3024
+ // A suspend always happens after the suspending assistant message is persisted, so the
3025
+ // branch has a leaf. Guard defensively: no leaf → no consistent resume point → don't suspend.
3026
+ throw new Error("cannot suspend: session has no committed leaf to resume from");
3027
+ }
3028
+ const { messages } = await session.buildContext();
3029
+ const { batchToolCallIds, completedCallIds } = batchContextAt(messages, req.toolCallId);
3030
+ // design/51 §5: a run being CANCELLED must not mint an irreversible durable checkpoint (the
3031
+ // budget→cancel race: an orchestrator detects over-budget and aborts the shared signal just as
3032
+ // a worker is about to suspend). Check the abort HERE — after the read-only prep, immediately
3033
+ // before the first side effect (suspendVM on the remote path; `put` on the process-local
3034
+ // path, which has NO suspendVM) — so neither path commits for a cancelling run. Aborted ⇒
3035
+ // return undefined = safe onAsk fallback (the run is aborting anyway → the abort-bounded onAsk
3036
+ // gate resolves to deny → the loop ends cancelled, no durable checkpoint).
3037
+ //
3038
+ // This is a CORRECTNESS guard, not just an optimization: on the process-local suspend path
3039
+ // there is no suspendVM, so `put` is the first side effect and this check is the ONLY thing
3040
+ // gating the commit on cancel; on the remote path it is also defense-in-depth, since
3041
+ // suspendVM's abortSignal is best-effort (a backend may finish the pause before honoring it).
3042
+ // The residual micro-race (passes here, THEN abort fires, THEN put commits) is covered by
3043
+ // TaskStream.destroy() reaping the resulting suspension (design/51 §4) — the two are
3044
+ // complementary = atomic cancel.
3045
+ if (abortController.signal.aborted) {
3046
+ return undefined;
3047
+ }
3048
+ // design/72 §2.2 (B): refuse a re-suspend past the cap BEFORE any side effect (no suspendVM,
3049
+ // no put) — fail the run instead of looping. Checked here, after the read-only prep and the
3050
+ // abort check, so a cancelling run still takes the abort path above. The shared helper commits
3051
+ // `suspendLoopRef` + abort (safety-state-first) so assembleResult reports `failed`/`suspend.loop`.
3052
+ if (suspendLoopCapHit(priorSuspendCount, maxSuspends, ` for tool "${req.toolName}" — likely a resume/restart loop.`))
3053
+ return undefined;
3054
+ // suspendVM = the FIRST durable side effect, and it is ATOMIC (seam contract): {ok:false} ⇒ the
3055
+ // VM is left running/untouched ⇒ safe to fall back to onAsk. Done BEFORE `put` so a paused VM
3056
+ // is always paired with a committed checkpoint (or, on the put-failure split path, unpaused).
3057
+ // abortSignal: a hung pause (network partition) cannot pin the worker (council BUG#2).
3058
+ if (remoteEnv !== undefined) {
3059
+ // design/103 §3.7: dispose background processes before suspendVM (see resource path) — no snapshot zombies.
3060
+ if (hasBackgroundShell(remoteEnv))
3061
+ await remoteEnv.disposeBackgroundShells();
3062
+ const snap = await remoteEnv.suspendVM({ abortSignal: abortController.signal });
3063
+ if (!snap.ok) {
3064
+ throw new Error(`suspendVM failed (${snap.error.code}): ${snap.error.message}`, { cause: snap.error });
3065
+ }
3066
+ // Capture identity AFTER the pause and stamp the returned snapshotId ourselves (don't rely on
3067
+ // the env to mutate workspaceHandle() in place) — this is what resume's resumeVM() reads.
3068
+ remoteHandle = { ...remoteEnv.workspaceHandle(), snapshotId: snap.value };
3069
+ }
3070
+ else if (parkOnlyRemoteEnv !== undefined) {
3071
+ // durable-park-only (see resource path): no pause; identity without snapshotId; resume reconnects.
3072
+ if (hasBackgroundShell(parkOnlyRemoteEnv))
3073
+ await parkOnlyRemoteEnv.disposeBackgroundShells();
3074
+ remoteHandle = parkOnlyHandle(parkOnlyRemoteEnv);
3075
+ }
3076
+ token = mintCheckpointToken();
3077
+ // design/77 §4.4/§4.5: pick the gate kind + scope + ttl by whether `durableApproval` is wired.
3078
+ // WITH it → the normal `{kind:"human"}` durable-approval suspend (unchanged). WITHOUT it (the
3079
+ // irreversible-only case, where `durableApproval.scope`/`.ttlMs` would throw TypeError) → mint
3080
+ // the DISJOINT `{kind:"irreversible_ask"}` gate under the default scope, no TTL. Both are
3081
+ // pre-action approval suspends (suspendRef → `status:"suspended"`), resumed identically with a
3082
+ // `policy_ask` outcome (the new third gateMatch arm in runtask.ts).
3083
+ // design/80 D-2 (mint-site safety axis): a SAFETY tighten (`safety !== undefined` — the egress/
3084
+ // irreversibility gate forced this ask) mints the DISJOINT `irreversible_ask` kind with its
3085
+ // persisted `safetyAxis` — EVEN WHEN `durableApproval` is wired (which supplies scope/ttl, NOT
3086
+ // the kind). This is the load-bearing fix: a network budget resolver keys on `kind` to NEVER
3087
+ // auto-approve a safety ask. A plain (non-safety) ask keeps the normal choice: `human` under
3088
+ // `durableApproval`, else `irreversible_ask` (the §4.4 unattended-irreversible-only case).
3089
+ // design/80 D-E: a DETERMINISTIC, REDACTED risk summary attached AT MINT so a supervisor inbox
3090
+ // sorts/triages by TRUE severity. INERT — it is display/triage metadata ONLY: nothing below
3091
+ // reads it to gate/budget/suppress (structural ask still strictly wins). `shellGated` is true only
3092
+ // for a bash call coarsely gated by `shellGate` (grades as the `shell` axis, severity 3, not 4).
3093
+ // Pure (no clock/random) — same call ⇒ identical descriptor. `postHookArgs` are the SHOWN args.
3094
+ const riskDescriptor = buildRiskDescriptor({
3095
+ toolName: req.toolName,
3096
+ args: postHookArgs,
3097
+ safety,
3098
+ shellGated: (req.toolName === "Bash" && shellGatedBash) || (req.toolName === "Monitor" && shellGatedMonitor),
3099
+ });
3100
+ gate =
3101
+ safety !== undefined
3102
+ ? {
3103
+ kind: "irreversible_ask",
3104
+ reason: `human approval required before safety-tightened tool "${req.toolName}"`,
3105
+ toolName: req.toolName,
3106
+ safetyAxis: safety,
3107
+ riskDescriptor,
3108
+ }
3109
+ : durableApproval
3110
+ ? {
3111
+ kind: "human",
3112
+ reason: `human approval required for tool "${req.toolName}"`,
3113
+ toolName: req.toolName,
3114
+ riskDescriptor,
3115
+ }
3116
+ : {
3117
+ kind: "irreversible_ask",
3118
+ reason: `human approval required before irreversible tool "${req.toolName}"`,
3119
+ toolName: req.toolName,
3120
+ riskDescriptor,
3121
+ };
3122
+ // design/80 D-2 (cross-scope mint): an UNATTENDED safety/irreversible_ask (no `durableApproval`)
3123
+ // must be resumable by the operator whose `/decide` arrives in the task's principal scope — so
3124
+ // re-scope to `spec.principal` (the authenticated end-user, design/62) when no durableApproval
3125
+ // scope is set, else the resolve scope-WHERE never matches (or a fixed DEFAULT cross-tenant
3126
+ // leaks). The reap path reads the COMMITTED `cp.scope` (below), never re-derives — so this
3127
+ // re-scope can't strand the paused VM (r3 BLOCKER).
3128
+ // `|| DEFAULT` (not `??`): an EMPTY-string principal must NOT become a shared scope bucket
3129
+ // (council MINOR) — `?? spec.principal` would keep `""` and collapse every empty-principal task
3130
+ // into one cross-tenant scope. Falsy "" falls through to the default isolation scope.
3131
+ const scope = durableApproval?.scope ?? (spec.principal || DEFAULT_IRREVERSIBLE_SCOPE);
3132
+ const ttlMs = durableApproval?.ttlMs;
3133
+ // §4.bis whitelist of correctness state (snapshot of the live values at suspend), via the shared
3134
+ // serializer (Slice 3a) so the resource-slice suspend captures the SAME state. `remoteHandle`
3135
+ // is undefined for a process-local suspend (design/49 v1.5).
3136
+ const checkpointState = serializeCheckpointState(remoteHandle);
3137
+ cp = {
3138
+ token,
3139
+ // design/80 D-1 (version-skew fix): binding-bearing human/irreversible_ask checkpoints stamp
3140
+ // v3 so a pre-D-1 worker (MAX_SUPPORTED=2, no binding enforcement) REJECTS them pre-CAS
3141
+ // instead of resuming them with the decision-action binding silently voided (council BLOCKER).
3142
+ version: BINDING_CHECKPOINT_VERSION,
3143
+ scope,
3144
+ sessionId,
3145
+ leafId,
3146
+ gate,
3147
+ pendingAction: {
3148
+ kind: "tool_approval",
3149
+ toolCallId: req.toolCallId,
3150
+ toolName: req.toolName,
3151
+ args: postHookArgs, // design/37 post-hook (rewritten) args — resume executes these
3152
+ // design/80 D-1 §2 (slice 1a.2): server-minted opaque boundInputHash over the SAME
3153
+ // post-hook args — computed ONCE here, persisted on the row, echoed by the operator and
3154
+ // verified by opaque equality on resume (never re-serialized resume-side).
3155
+ boundInputHash: boundInputHashOf(postHookArgs),
3156
+ batchToolCallIds,
3157
+ completedCallIds,
3158
+ },
3159
+ state: checkpointState,
3160
+ // design/80 D-2 (BLOCKER): an UNATTENDED suspend (no durableApproval = no operator to ever
3161
+ // resolve it) MUST carry an abandonment deadline so the reaper can GC it; else the egress/
3162
+ // irreversible_ask suspend pins the session + strands the paused VM forever. A durableApproval
3163
+ // suspend keeps the operator's ttlMs (undefined = unbounded; D-D terminalAt is the backstop).
3164
+ deadline: ttlMs !== undefined
3165
+ ? Date.now() + ttlMs
3166
+ : durableApproval !== undefined
3167
+ ? undefined
3168
+ : Date.now() + DEFAULT_UNATTENDED_APPROVAL_TTL_MS,
3169
+ status: "pending",
3170
+ createdAt: Date.now(),
3171
+ // design/91: the wall-clock instant this approval gate suspended, from the injectable `now`.
3172
+ // Resume derives this leg's human-review latency as `resumeNow − suspendedAt`. (createdAt/
3173
+ // deadline stay on Date.now — they are reaper/SLA fields, not the human-time observable.)
3174
+ suspendedAt: now(),
3175
+ // design/91: snapshot the PRIOR-leg human-review burden onto the row so the resume that wakes
3176
+ // this checkpoint seeds the accumulator + adds THIS leg's latency = the whole chain's burden.
3177
+ // Snapshot the gate array (independent of the mutable ref). Omitted when nothing accumulated yet.
3178
+ humanReview: humanReviewRef.count > 0
3179
+ ? { count: humanReviewRef.count, totalWaitMs: humanReviewRef.totalWaitMs, gates: [...humanReviewRef.gates] }
3180
+ : undefined,
3181
+ suspendCount: priorSuspendCount + 1, // design/72 §2.2 (B): this suspend's place in the chain
3182
+ // design/80 D-E-core (A3): attach the durable resource ledger to this APPROVAL suspend so a
3183
+ // STATELESS policy can read cumulative spend across the resume chain (a fresh in-memory counter
3184
+ // resets every leg). Debit THIS leg's live cumulative spend onto the prior ledger (reuse the
3185
+ // resource-slice `debitLedger`: prior-wins frozen total, monotonic spend, never refunded) — the
3186
+ // event-driven gate reads the live spend via `liveSpendRef` (the resource-slice path passes it
3187
+ // explicitly instead). On the FIRST suspend the first slice seeds the human total from
3188
+ // `resourceTotal` (undefined when no resource budget is configured → an unbounded read).
3189
+ // **READ-ONLY for stats**: this ledger rides the approval suspend purely for the `req.budget`
3190
+ // read — the final-stats aggregate fold (runtask) is GATED to the resource-slice gate kind, so
3191
+ // an approval resume is NEVER double-counted (design/80 A3, the single subtle correctness point).
3192
+ // `debitLedger` is pure + total (never throws) so it cannot leak a paused VM in this try block.
3193
+ resourceLedger: debitLedger(priorLedger, liveSpendRef.get?.() ?? { costMicroUsd: 0, tokens: 0, turns: 0 }, resourceTotal, { countSlice: false }),
3194
+ // design S1d (source-tag persistence, service [198]/[199]): stamp the SAME source identity the
3195
+ // synchronous ask carries (resolveAskBound above: principal = spec.principal, sourceTaskId =
3196
+ // sessionId) onto the durable approval checkpoint — the suspend path never invokes onAsk, so a
3197
+ // supervisor inbox would otherwise have no per-worker/per-user attribution for a PAUSED ask.
3198
+ // ECHO-ONLY triage metadata, NEVER a gate input (no gate/CAS/winner/resume-validation reads it).
3199
+ // `sourceTaskId = sessionId` is worker-unforgeable for a Runner-created delegated subagent (the
3200
+ // child never sets its own sessionId). Only the APPROVAL (human/irreversible_ask) suspend stamps
3201
+ // these — the resource_limit suspend has no ask source (mirrors the winnerFromOutcome/policy_ask
3202
+ // binding scope). Omitted via `||`/conditional spread when absent (empty principal → omit).
3203
+ sourceTaskId: sessionId,
3204
+ ...(spec.principal ? { principal: spec.principal } : {}),
3205
+ };
3206
+ }
3207
+ catch (err) {
3208
+ // Reaching here means the VM is still running: suspendVM was either never called or returned
3209
+ // {ok:false} (atomic seam contract: VM untouched). The only post-pause statements above are the
3210
+ // SYNCHRONOUS `workspaceHandle()` (documented as data the env already holds, remote-env.ts),
3211
+ // `boundInputHashOf(postHookArgs)` (canonical-json.ts — TOTAL by construction, never throws, so a
3212
+ // pathological arg can't leak the paused VM here; design/80 D-1 §2 / round-2 MAJOR), and pure
3213
+ // object construction — by contract they don't throw, so a paused VM never falls through to here.
3214
+ // (A contract-violating throw from a post-pause backend would be the same class as the
3215
+ // put-failure split path below; it is out of scope, not silently mishandled.) → safe onAsk.
3216
+ deps.onError?.(err, { phase: "config", sessionId });
3217
+ return undefined;
3218
+ }
3219
+ // ── COMMIT (Slice 3b: shared put + put-failure split, see `commitSuspendSaga`). ──────────────
3220
+ if (!(await commitSuspendSaga(token, cp, remoteEnv, remoteHandle))) {
3221
+ return undefined; // put failed → rolled back / aborted → safe onAsk fallback (NOT suspended)
3222
+ }
3223
+ // ── COMMITTED (point of no return). No await/throw before setting suspendRef; NEVER onAsk now. ──
3224
+ // Via the commit-side discriminant (design/76 §2.5): a human/irreversible_ask gate routes to
3225
+ // `suspendRef` (the dry-run `needs_review` gate — minted by a profile — would route to `reviewRef`).
3226
+ publishCommittedSuspend(token, gate, cp.scope); // design/80 D-2: stash the COMMITTED scope (after the cross-scope re-scope)
3227
+ // pin is best-effort: a failure only risks idle eviction of an IN-MEMORY session (durable store:
3228
+ // pin is a no-op) — it must NOT undo a committed suspend, so swallow + log rather than throw.
3229
+ try {
3230
+ await sessions.pin?.(sessionId); // protect the session from idle eviction until resume (§5)
3231
+ }
3232
+ catch (pinErr) {
3233
+ deps.onError?.(pinErr, { phase: "config", sessionId });
3234
+ }
3235
+ // Abort the run (capture is durable now): release the policy gate then stop the harness —
3236
+ // the loop returns an aborted message and assembleResult sees suspendRef → "suspended".
3237
+ abortController.abort();
3238
+ void harness.abort();
3239
+ return { token, gate };
3240
+ }
3241
+ : undefined;
3242
+ // design/108: sync `toolEffects` from the FINAL tool set so the plan-mode write-deny sees every tool's real
3243
+ // effect. First-party tools pushed AFTER the toolEffects build (~590) — present_plan, enter_plan_mode,
3244
+ // AskUserQuestion, Skill, recall, repo_map, … — carry their `effect` on the AgentTool (defineTool preserves
3245
+ // it; raw factories already set it). Without this they read as unknown→"write" and plan mode over-rejects
3246
+ // read tools. Only fill MISSING entries (never override an explicit earlier set, e.g. handsReadOnly's Bash:read).
3247
+ for (const t of tools) {
3248
+ const eff = t.effect;
3249
+ if (eff !== undefined && !toolEffects.has(t.name))
3250
+ toolEffects.set(t.name, eff);
3251
+ }
3252
+ // design/74 Slice 3c: register the tool-gate handler ONLY for real gate work (a policy / PreToolUse
3253
+ // hook / egress tool / irreversible tool / design/77 §3 manifest scope). A task that entered this block
3254
+ // solely for resource-suspend has no gate work — runToolGate would no-op — so skipping registration keeps
3255
+ // its tool path provably the default. `effectivePolicy` covers the caller policy AND the Gate-3 scope;
3256
+ // design/77 §4: `irreversibleTools.size > 0` MUST gate registration too, else an `always`/`maybe` tool in
3257
+ // a no-policy/no-hook/no-egress unattended deployment would never be tightened → silently auto-allow.
3258
+ // design/108: `spec.enablePlanMode` MUST also register the handler — a plan-mode task with no policy/hook/egress
3259
+ // /irreversible would otherwise skip the tool_call handler entirely → the plan-mode write-deny below never runs.
3260
+ if (effectivePolicy || hooks?.preToolUse || egressTools.size > 0 || irreversibleTools.size > 0 || spec.enablePlanMode === true) {
3261
+ harness.on("tool_call", async (e) => {
3262
+ // design/134 复审 (codex HIGH): drop any stale blocked-marker for this id FIRST — a provider
3263
+ // reusing a toolCallId across calls must not have the new call's post hooks suppressed by an
3264
+ // old call's block record (the set is otherwise only drained on tool_result/batch collect).
3265
+ blockedToolCalls.delete(e.toolCallId);
3266
+ // design/108: plan-mode write-deny — a FIDELITY check kept SEPARATE from the safety gate below (plan mode
3267
+ // is a read-only behavior mode the model self-imposes, NOT a security boundary; the real gates run on read
3268
+ // tools as usual). Short-circuit BEFORE runToolGate so a write-effect call in plan mode is rejected without
3269
+ // running policy/onAsk/probe/durable-mint. Unknown effect ⇒ "write" (fail-closed: gated in plan mode).
3270
+ //
3271
+ // NOT a security bypass (review): this short-circuit can only ADD a denial (for write-effect tools while
3272
+ // the model is in plan mode) — it never ALLOWS anything runToolGate would have denied. Net effect on a
3273
+ // write tool in plan mode = DENY, which is ≥ as restrictive as any policy/PreToolUse verdict. The only
3274
+ // thing skipped is the gate's SIDE EFFECTS (PreToolUse audit/arg-rewrite, reversibility probe) for a call
3275
+ // that DID NOT EXECUTE; the plan-block itself is recorded as the tool's error result. Deployments that
3276
+ // need every blocked write attempt to traverse their PreToolUse hook should not rely on plan mode for that.
3277
+ if (planModeRef.active && (toolEffects.get(e.toolName) ?? "write") !== "read") {
3278
+ if (blockedTracked)
3279
+ blockedToolCalls.add(e.toolCallId); // design/134 R3: a plan-mode deny is not an execution failure
3280
+ const planDenyReason = `Plan mode is active — "${e.toolName}" is a write/mutating tool and is read-only-blocked. ` +
3281
+ `Research with read-only tools, then call ${PRESENT_PLAN_TOOL_NAME} with your plan to get it approved before acting.`;
3282
+ // design/134 R5: the plan-mode write-deny is a PermissionDenied-domain outcome (R3 already scopes
3283
+ // it OUT of the post-tool callbacks) — the second of the two deny short-circuits the observer covers.
3284
+ if (notifyPermissionDenied) {
3285
+ // 1.256 复审 MED-1: clone the live args before the observe-only payload — a hook mutating
3286
+ // `payload.input` must never pollute the harness event's input object.
3287
+ await notifyPermissionDenied({ toolName: e.toolName, input: cloneObserverInput(e.input), toolCallId: e.toolCallId, reason: planDenyReason, source: "planMode" });
3288
+ }
3289
+ return {
3290
+ block: true,
3291
+ reason: formatHookFeedback(planDenyReason),
3292
+ preToolContext: [],
3293
+ };
3294
+ }
3295
+ const result = await runToolGate({
3296
+ event: e,
3297
+ preToolUse: hooks?.preToolUse,
3298
+ adjudicate,
3299
+ resolveAsk: resolveAskBound,
3300
+ suspendAsk,
3301
+ egress: egressTools.has(e.toolName), // design/70: the gate tightens allow→ask for these
3302
+ // design/77 §4: per-call irreversibility tier + probe (read from the spec at prepare-time,
3303
+ // captured here — not a tool argument the model can monkey-patch). The probe is time-bounded by
3304
+ // the task abort signal (its real deadline) and fails closed to `ask`.
3305
+ irreversibility: irreversibilityTier.get(e.toolName),
3306
+ reversibilityProbe: reversibilityProbes.get(e.toolName),
3307
+ abortSignal: abortController.signal,
3308
+ // design/134 R5: the deny observer (pre-wrapped, never throws) + the coarse-shellGate marker so
3309
+ // a tighten-deny on a shell-gated Bash/Monitor attributes to "shellGate" (not "safety").
3310
+ ...(notifyPermissionDenied ? { permissionDenied: notifyPermissionDenied } : {}),
3311
+ shellGated: (e.toolName === "Bash" && shellGatedBash) || (e.toolName === "Monitor" && shellGatedMonitor),
3312
+ });
3313
+ // Stash PreToolUse context only for calls that will EXECUTE: a blocked call skips the loop's
3314
+ // finalize step, so its `tool_result` hook never fires to drain the map — stashing it there would
3315
+ // both leak the entry and never deliver it (the block reason already carries the model-facing
3316
+ // text). A suspended call is likewise aborted, so don't stash it either.
3317
+ if (!result.block && !result.suspend && result.preToolContext.length > 0) {
3318
+ preToolContexts.set(e.toolCallId, result.preToolContext);
3319
+ }
3320
+ // design/134 R3: remember blocked/suspended ids so a tool_result carrying their error text
3321
+ // (if any path ever emits one) triggers neither post callback.
3322
+ if (blockedTracked && (result.block || result.suspend)) {
3323
+ blockedToolCalls.add(e.toolCallId);
3324
+ }
3325
+ // A suspend has already aborted the run inside `suspendAsk`; let the harness's own abort handling
3326
+ // produce the aborted result for this call (no block/updatedInput to return here).
3327
+ return result.block
3328
+ ? { block: true, reason: result.reason }
3329
+ : result.updatedInput !== undefined
3330
+ ? { updatedInput: result.updatedInput }
3331
+ : undefined;
3332
+ });
3333
+ }
3334
+ }
3335
+ // PostToolUse (design/37): rewrite the tool result content and/or append `<system-reminder>` context
3336
+ // (both the hook's own `additionalContext` and any PreToolUse context stashed for this call).
3337
+ // design/134 §2 (BREAKING): success/failure results are MUTUALLY EXCLUSIVE — an `isError` result
3338
+ // fires postToolUseFailure ONLY (additionalContext-only, no rewrite), never postToolUse.
3339
+ if (hooks?.postToolUse || hooks?.preToolUse || hooks?.postToolUseFailure) {
3340
+ harness.on("tool_result", async (e) => {
3341
+ // design/134 R3: a blocked/suspended call's result is a PermissionDenied-domain outcome, not a
3342
+ // tool execution — fire NEITHER post callback. One-shot: consume the entry so it can't leak.
3343
+ // (preToolContexts never stashes blocked calls, so there is nothing to drain here either.)
3344
+ if (blockedToolCalls.delete(e.toolCallId)) {
3345
+ return undefined;
3346
+ }
3347
+ let content = e.content;
3348
+ let changed = false;
3349
+ if (e.isError) {
3350
+ if (hooks?.postToolUseFailure) {
3351
+ // design/134 复审 (codex MED): isolate the failure payload from the live result the loop
3352
+ // is about to commit — an element-level clone of `content` (a hook mutating a block's
3353
+ // `text` must not rewrite the transcript) and a best-effort deep clone of `details`
3354
+ // (non-cloneable graphs — functions/handles — fall back to the live reference; the
3355
+ // HookToolFailure.details contract documents that read-only expectation).
3356
+ let clonedDetails = e.details;
3357
+ try {
3358
+ clonedDetails = structuredClone(e.details);
3359
+ }
3360
+ catch {
3361
+ /* best-effort: pass the live reference for non-structured-cloneable details */
3362
+ }
3363
+ const patch = await hooks.postToolUseFailure(e.toolName, e.input, {
3364
+ error: e.content
3365
+ .filter((c) => c.type === "text")
3366
+ .map((c) => c.text)
3367
+ .join("\n"),
3368
+ // Wide any-abort semantics (⊇ CC is_interrupt, which is user-interrupt only): the task
3369
+ // abort signal covers timeout/budget/cancel/suspend too — documented on HookToolFailure.
3370
+ isInterrupt: abortController.signal.aborted || spec.signal?.aborted === true,
3371
+ // No durationMs: the harness tool_result event carries no execution-time field today,
3372
+ // and the design forbids adding a timer just for it.
3373
+ content: e.content.map((c) => ({ ...c })),
3374
+ details: clonedDetails,
3375
+ }, { toolCallId: e.toolCallId, toolName: e.toolName });
3376
+ if (patch?.additionalContext) {
3377
+ content = [...content, { type: "text", text: formatHookFeedback(patch.additionalContext) }];
3378
+ changed = true;
3379
+ }
3380
+ }
3381
+ }
3382
+ else if (hooks?.postToolUse) {
3383
+ const patch = await hooks.postToolUse(e.toolName, e.input, { content: e.content, details: e.details, isError: e.isError }, { toolCallId: e.toolCallId, toolName: e.toolName });
3384
+ if (patch?.updatedOutput) {
3385
+ content = patch.updatedOutput;
3386
+ changed = true;
3387
+ }
3388
+ if (patch?.additionalContext) {
3389
+ content = [...content, { type: "text", text: formatHookFeedback(patch.additionalContext) }];
3390
+ changed = true;
3391
+ }
3392
+ }
3393
+ const pre = preToolContexts.get(e.toolCallId);
3394
+ if (pre) {
3395
+ preToolContexts.delete(e.toolCallId); // one-shot: drain so it can't leak onto a later call
3396
+ content = [...content, ...pre.map((t) => ({ type: "text", text: formatHookFeedback(t) }))];
3397
+ changed = true;
3398
+ }
3399
+ return changed ? { content } : undefined;
3400
+ });
3401
+ }
3402
+ // In-task context management, applied before each provider request (request-only, non-destructive):
3403
+ // 0. per-message aggregate tool-result budget — cap a single turn's batch if N parallel results SUM
3404
+ // past 200K chars (design/64 §17.2; largest-first offload). Deterministic ⇒ cache-safe, resume-free.
3405
+ // 1. context-editing — clear old tool-result *content* (lightest; keeps turn structure)
3406
+ // 2. guard trim — if still over the hard budget, drop oldest messages at safe boundaries
3407
+ // Between-task compaction (LLM summary) handles the normal long-conversation case.
3408
+ const editAt = editBudget(model);
3409
+ const guardAt = guardBudget(model);
3410
+ // design/123 D2: the task model's structural coefficient — the request-layer defenses below and
3411
+ // the prompt-overhead term must share ONE coordinate with the compaction trigger estimate.
3412
+ const charsPerToken = model.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
3413
+ harness.on("context", async ({ messages }) => {
3414
+ // audit A-1: heal pre-1.92 sessions that persisted a synthetic empty failure assistant inside an
3415
+ // interrupted batch (API-invalid on strict providers; append-only sessions can't be rewritten).
3416
+ const healed = dropEmptyFailureAssistants(messages);
3417
+ const capped = await capAggregateToolResults(healed, {
3418
+ store: offloadStore,
3419
+ sessionId,
3420
+ // service [398] C8: every capped result (offloaded or degraded to a self-contained preview)
3421
+ // gets a metadata-only trace frame; storeFallback flags the lost-read-back face.
3422
+ onCapped: (info) => emitTrace(deps.tracer, () => ({
3423
+ kind: "tool_result.capped",
3424
+ version: 1,
3425
+ taskId: spec.taskId ?? sessionId,
3426
+ ...(info.tool !== undefined ? { tool: info.tool } : {}),
3427
+ sizeChars: info.sizeChars,
3428
+ storeFallback: info.storeFallback,
3429
+ ts: Date.now(),
3430
+ })),
3431
+ });
3432
+ // batch-C/§17.2: bound aggregate inline-media (image) bytes — the multimodal sibling of the text budget
3433
+ // above (image reads accumulate across turns; per-image cap doesn't bound the SUM). Request-only.
3434
+ const mediaCapped = capAggregateMediaBytes(capped, {
3435
+ limitBytes: deps.mediaByteCapBytes ?? AGGREGATE_MEDIA_BUDGET_BYTES,
3436
+ onStripped: deps.onMediaStripped,
3437
+ });
3438
+ const edited = clearStaleToolResults(mediaCapped, {
3439
+ budgetTokens: editAt,
3440
+ // design/123 D3: hand the defense the ANCHORED estimate of the processed messages (billed
3441
+ // truth — the same coordinate the compaction trigger uses) plus the model's coefficient, so
3442
+ // its gate and internal bookkeeping stop self-certifying via the chars/4 structural sum.
3443
+ anchoredTotalTokens: estimateContextTokens(mediaCapped, charsPerToken).tokens,
3444
+ charsPerToken,
3445
+ // roadmap #6② (CC contentReplacementState parity): offload-then-clear — the cleared marker
3446
+ // carries a ReadToolResult ref so the model can page a cleared result back. Deterministic
3447
+ // ref (sessionId+toolCallId) + write-once put ⇒ the per-request re-clear stays byte-stable
3448
+ // (prefix-cache safe). Only wired when offload is enabled (same store as the size cap above).
3449
+ ...(offloadStore
3450
+ ? {
3451
+ offload: {
3452
+ persist: (toolCallId, fullText) => {
3453
+ const ref = `tr_${sessionId}_${toolCallId}`;
3454
+ void offloadStore.put(ref, fullText); // write-once; a repeat put is a no-op
3455
+ return ref;
3456
+ },
3457
+ },
3458
+ }
3459
+ : {}),
3460
+ });
3461
+ // design/123 D3: same anchored coordinate for the guard trim, re-estimated on the EDITED array
3462
+ // (pre-anchor clears don't lower the anchored estimate — deliberately; see trimToBudget doc).
3463
+ const trimmed = trimToBudget(edited, guardAt, estimateContextTokens(edited, charsPerToken).tokens, charsPerToken);
3464
+ // design/123 D4: a REAL message drop by the trim (shorter output — content-only clears keep the
3465
+ // length) means the request layer was forced to discard history → arm the trim-pressure flag so
3466
+ // the next turn boundary force-compacts (see Prepared.trimPressureRef).
3467
+ if (trimmed.length < edited.length) {
3468
+ trimPressureRef.droppedMessages = true;
3469
+ // service [398] C9: the model didn't see these messages THIS turn (request-only drop) —
3470
+ // previously only the D4 pressure flag knew, and only indirectly via the next compaction.
3471
+ emitTrace(deps.tracer, () => ({
3472
+ kind: "context.trim",
3473
+ version: 1,
3474
+ taskId: spec.taskId ?? sessionId,
3475
+ dropped: edited.length - trimmed.length,
3476
+ ts: Date.now(),
3477
+ }));
3478
+ }
3479
+ // design/123 D3 belt (invariant, live-caught 16k 400s): NO pipeline combination may ship an
3480
+ // orphan toolResult to the provider ("role 'tool' must respond to tool_calls" is a strict-
3481
+ // gateway hard 400). Producers are individually turn-aligned; this is the last line. An actual
3482
+ // drop indicates a producer bug — surface it via onError with attribution detail.
3483
+ const swept = dropOrphanToolResults(trimmed);
3484
+ if (swept.dropped.length > 0) {
3485
+ try {
3486
+ deps.onError?.(new Error(`context belt: dropped ${swept.dropped.length} orphan toolResult(s) whose tool-call assistant was not in the request view — ` +
3487
+ swept.dropped
3488
+ .map((d) => `toolCallId=${d.toolCallId} at index ${d.index} (prev=${d.prevRole}, next=${d.nextRole})`)
3489
+ .join("; ")), { phase: "hook", sessionId });
3490
+ }
3491
+ catch {
3492
+ /* a throwing onError must never break the request build */
3493
+ }
3494
+ }
3495
+ return { messages: swept.messages };
3496
+ });
3497
+ // Two-phase cache-break detection (design/31): default on. Fingerprints the prefix (system + the tools
3498
+ // ACTUALLY sent — placeholders included) here; `tool_search` refreshes `cacheFingerprint.tools` in place
3499
+ // when it materializes a deferred tool (design/36 blocker #2), so the detector never goes blind to a
3500
+ // mid-task tool-schema change. The run loop feeds per-turn cacheRead + the served modelKey.
3501
+ const cacheBreakDetector = deps.cacheBreakDetection === false ? undefined : new CacheBreakDetector();
3502
+ const cacheFingerprint = cacheBreakDetector
3503
+ ? { systemPrompt, tools: toolsToFingerprintInputs(harnessTools) }
3504
+ : undefined;
3505
+ fpRef.current = cacheFingerprint;
3506
+ // Fixed prompt overhead for the compaction trigger's anchor-less regime (design/64 §26.7): the
3507
+ // chars/4 fallback sees messages only; system prompt + tool schemas are billed on every request.
3508
+ // design/123 D2: divided by the model's charsPerToken (not a hard /4) — this term is ADDED to the
3509
+ // anchor-less trigger estimate, so it must share that estimate's structural coordinate.
3510
+ const promptOverheadTokens = Math.ceil((systemPrompt.length +
3511
+ harnessTools.reduce((a, t) => a + t.name.length + (t.description?.length ?? 0) + JSON.stringify(t.parameters ?? {}).length, 0)) / charsPerToken);
3512
+ // Working-file attachments reader (LONGRUN-2): same env as the hands, so attachments are read
3513
+ // from the workspace the task actually modified. Best-effort by contract — errors become null.
3514
+ // 编码 parity 批 (codex 镜头1 MED): SAME decode coordinate as the Read tool — a BOM'd UTF-16 file
3515
+ // the model read as clean text must not re-enter the compaction summary as utf8 mojibake (the
3516
+ // summary tells the model to TRUST attachment content); a malformed body degrades to null.
3517
+ const readTaskFile = handsEnabled
3518
+ ? async (path) => {
3519
+ try {
3520
+ const r = await executionEnv.readBinaryFile(path);
3521
+ if (!r.ok)
3522
+ return null;
3523
+ const d = decodeTextBytes(r.value);
3524
+ return d.malformed ? null : d.text;
3525
+ }
3526
+ catch {
3527
+ return null;
3528
+ }
3529
+ }
3530
+ : undefined;
3531
+ // CC post-compact restore parity (blackboard 2026-07-03): most-recently-READ files, newest first.
3532
+ // Closure over the live readFileState — evaluated AT COMPACTION TIME, so it reflects reads up to
3533
+ // that boundary. Entries without lastReadAt (seeded from an older design/45 checkpoint) sort last.
3534
+ const recentlyReadFiles = handsEnabled && readFileStateForCheckpoint
3535
+ ? () => [...readFileStateForCheckpoint.entries()]
3536
+ .sort((a, b) => (b[1].lastReadAt ?? 0) - (a[1].lastReadAt ?? 0))
3537
+ .map(([path]) => path)
3538
+ : undefined;
3539
+ // design/133 F5 (CC getChangedFiles 同构): boundary-time stat over the most-recently-read files.
3540
+ // Lives HERE (not the run loop) because readFileState + the hands' env are prepare-task closures;
3541
+ // the run loop only decides WHEN (opt-in + boundary gate). Judged against `lastReadAt` + EPS —
3542
+ // no new ReadEntry field, hot read/write paths do zero extra stats, and the agent's own
3543
+ // write-backs are immune because every fs write site already refreshes lastReadAt. Entries
3544
+ // without lastReadAt (old-checkpoint seeds) can't be compared ⇒ skipped, never false-fired.
3545
+ const detectExternalChanges = handsEnabled && readFileStateForCheckpoint
3546
+ ? async (maxFiles) => {
3547
+ const candidates = [...readFileStateForCheckpoint.entries()]
3548
+ .filter(([, e]) => e.lastReadAt !== undefined)
3549
+ .sort((a, b) => (b[1].lastReadAt ?? 0) - (a[1].lastReadAt ?? 0))
3550
+ .slice(0, Math.max(0, maxFiles));
3551
+ const changed = [];
3552
+ const evicted = [];
3553
+ for (const [path, entry] of candidates) {
3554
+ try {
3555
+ const info = await executionEnv.fileInfo(path, abortController.signal);
3556
+ if (!info.ok) {
3557
+ // CC evict-only-on-ENOENT (attachments.ts:2146-2155 / PR #18525): an atomic-save race,
3558
+ // EACCES churn or a network-fs hiccup must NOT evict, or the next Edit fails "not read"
3559
+ // while the file still exists. Only a true not_found deletes the entry.
3560
+ if (info.error.code === "not_found") {
3561
+ readFileStateForCheckpoint.delete(path);
3562
+ evicted.push(path);
3563
+ }
3564
+ continue;
3565
+ }
3566
+ if (info.value.mtimeMs > (entry.lastReadAt ?? 0) + CHANGED_FILES_MTIME_EPS_MS) {
3567
+ changed.push({ path, mtimeMs: info.value.mtimeMs });
3568
+ }
3569
+ }
3570
+ catch {
3571
+ /* best-effort scan: a throwing env must never fault the turn boundary */
3572
+ }
3573
+ }
3574
+ return { changed, evicted };
3575
+ }
3576
+ : undefined;
3577
+ // G1 通告层 [482]: post-compact background-task snapshot — the SAME identity triple the
3578
+ // TaskOutput/TaskStop tool mounts use (hostTaskId/taskScope/sessionId), so the announce surface
3579
+ // can never see a task the model's own tools couldn't reach. Bounded projection, pending/running
3580
+ // only (a terminal task needs no post-compact continuity note — its completion notification
3581
+ // already rode the task_notification lane).
3582
+ const listBackgroundTasks = () => defaultTaskRegistry
3583
+ .list({ owner: hostTaskId, scope: taskScope, sessionId })
3584
+ .filter((t) => t.status === "pending" || t.status === "running")
3585
+ .map((t) => ({ id: t.task_id, ...(t.description !== undefined ? { description: t.description } : {}), status: t.status }));
3586
+ return { harness, session, sessionId, taskRootPath, model, thinking, compModel, mcp, blockedRef, outputRef, abortController, conflictRef, blockedToolCalls, nestedStats, cwdRef: handsCwdRef, denyNarrowingPolicy, releaseSignal, cacheBreakDetector, cacheFingerprint, promptManifest, activeTools, ownedEnv, suspendRef, reviewRef, reviewRequestRef, suspendLoopRef, suspendForResource, ...(callCapRef ? { callCapRef } : {}), suspendForReview, resourceLedger: priorLedger, liveSpendRef, humanReviewRef, now, tools, toolEffects, promptOverheadTokens, readTaskFile, recentlyReadFiles, compactionReuseRef, trimPressureRef, ...(memoryEngineSession ? { memoryEngineSession } : {}), ...(subagentRetain ? { subagentRetain } : {}), ...(lspDiagnostics && nudgeLspOnEdit ? { lspDiagnostics: { registry: lspDiagnostics, nudge: nudgeLspOnEdit } } : {}), planModeRef, ...(detectExternalChanges ? { detectExternalChanges } : {}), ...(toolsDeltaRef ? { toolsDeltaRef } : {}), ...(agentListing ? { agentListing } : {}), listBackgroundTasks };
3587
+ }
3588
+ catch (prepareErr) {
3589
+ // P-8: dispose disposables in REVERSE acquisition order, each best-effort + independent so one failure neither
3590
+ // masks prepareErr nor blocks the others. mcp (acquired after ownedEnv) first; the `if (mcp)` guard skips a
3591
+ // pre-materialize throw (mcp still undefined). NEVER on the success path — there runtask owns prepared.mcp.dispose().
3592
+ if (mcp) {
3593
+ try {
3594
+ await mcp.dispose();
3595
+ }
3596
+ catch {
3597
+ /* best-effort; closing MCP clients must not mask the prepare error */
3598
+ }
3599
+ }
3600
+ if (ownedEnv && hasDestroy(ownedEnv)) {
3601
+ try {
3602
+ await ownedEnv.destroy();
3603
+ }
3604
+ catch {
3605
+ /* best-effort teardown; the service reaper is the backstop */
3606
+ }
3607
+ }
3608
+ await forgetOnThrow(); // 1.93.0 throw-cleanup: the caller can never release what it never received
3609
+ throw prepareErr;
3610
+ }
3611
+ }
3612
+ //# sourceMappingURL=prepare-task.js.map