@pentoshi/clai 3.9.2 → 3.9.4

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 (407) hide show
  1. package/dist/agent/compaction-attempt.d.ts +22 -0
  2. package/dist/agent/compaction-attempt.js +69 -0
  3. package/dist/agent/compaction-attempt.js.map +1 -0
  4. package/dist/agent/compaction-summary.d.ts +27 -0
  5. package/dist/agent/compaction-summary.js +62 -0
  6. package/dist/agent/compaction-summary.js.map +1 -1
  7. package/dist/agent/context-breakdown.d.ts +2 -0
  8. package/dist/agent/context-breakdown.js +37 -0
  9. package/dist/agent/context-breakdown.js.map +1 -1
  10. package/dist/agent/context-manager.d.ts +7 -1
  11. package/dist/agent/context-manager.js +64 -15
  12. package/dist/agent/context-manager.js.map +1 -1
  13. package/dist/agent/continue-orient.d.ts +8 -0
  14. package/dist/agent/continue-orient.js +26 -3
  15. package/dist/agent/continue-orient.js.map +1 -1
  16. package/dist/agent/durable-envelope.d.ts +39 -0
  17. package/dist/agent/durable-envelope.js +195 -0
  18. package/dist/agent/durable-envelope.js.map +1 -0
  19. package/dist/agent/events.d.ts +21 -0
  20. package/dist/agent/loop-guard.d.ts +13 -5
  21. package/dist/agent/loop-guard.js +114 -1
  22. package/dist/agent/loop-guard.js.map +1 -1
  23. package/dist/agent/outcomes.d.ts +29 -0
  24. package/dist/agent/outcomes.js +118 -3
  25. package/dist/agent/outcomes.js.map +1 -1
  26. package/dist/agent/plan-decision.js +8 -3
  27. package/dist/agent/plan-decision.js.map +1 -1
  28. package/dist/agent/plan-tool.d.ts +5 -0
  29. package/dist/agent/plan-tool.js +166 -6
  30. package/dist/agent/plan-tool.js.map +1 -1
  31. package/dist/agent/reliability-policy.d.ts +11 -6
  32. package/dist/agent/reliability-policy.js +19 -13
  33. package/dist/agent/reliability-policy.js.map +1 -1
  34. package/dist/agent/request-budget.d.ts +24 -0
  35. package/dist/agent/request-budget.js +59 -0
  36. package/dist/agent/request-budget.js.map +1 -0
  37. package/dist/agent/responder-context.js +2 -2
  38. package/dist/agent/responder-context.js.map +1 -1
  39. package/dist/agent/responder-parent.d.ts +30 -0
  40. package/dist/agent/responder-parent.js +82 -0
  41. package/dist/agent/responder-parent.js.map +1 -0
  42. package/dist/agent/runner.d.ts +15 -2
  43. package/dist/agent/runner.js +766 -213
  44. package/dist/agent/runner.js.map +1 -1
  45. package/dist/agent/scope-context.d.ts +5 -0
  46. package/dist/agent/scope-context.js +7 -1
  47. package/dist/agent/scope-context.js.map +1 -1
  48. package/dist/agent/session-policy.js +1 -0
  49. package/dist/agent/session-policy.js.map +1 -1
  50. package/dist/agent/stop-summary.js +13 -7
  51. package/dist/agent/stop-summary.js.map +1 -1
  52. package/dist/agent/task-analyzer.js +7 -3
  53. package/dist/agent/task-analyzer.js.map +1 -1
  54. package/dist/agent/task-evidence.js +2 -0
  55. package/dist/agent/task-evidence.js.map +1 -1
  56. package/dist/agent/task-sync.d.ts +11 -0
  57. package/dist/agent/task-sync.js +31 -0
  58. package/dist/agent/task-sync.js.map +1 -1
  59. package/dist/agent/tool-call-parser.d.ts +6 -13
  60. package/dist/agent/tool-call-parser.js +186 -82
  61. package/dist/agent/tool-call-parser.js.map +1 -1
  62. package/dist/agent/tool-history.d.ts +8 -2
  63. package/dist/agent/tool-history.js +8 -1
  64. package/dist/agent/tool-history.js.map +1 -1
  65. package/dist/agent/turn-outcome.d.ts +4 -1
  66. package/dist/agent/turn-outcome.js +16 -2
  67. package/dist/agent/turn-outcome.js.map +1 -1
  68. package/dist/app/adapters/agent-event-adapter.js +27 -0
  69. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  70. package/dist/app/adapters/current-agent-adapter.js +1 -0
  71. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  72. package/dist/app/adapters/current-interactive-sessions-adapter.d.ts +4 -0
  73. package/dist/app/adapters/current-interactive-sessions-adapter.js +11 -0
  74. package/dist/app/adapters/current-interactive-sessions-adapter.js.map +1 -0
  75. package/dist/app/adapters/current-jobs-adapter.js +4 -3
  76. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  77. package/dist/app/adapters/current-store-adapter.js +4 -3
  78. package/dist/app/adapters/current-store-adapter.js.map +1 -1
  79. package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
  80. package/dist/app/adapters/current-updates-adapter.js +34 -5
  81. package/dist/app/adapters/current-updates-adapter.js.map +1 -1
  82. package/dist/app/adapters/quiet-meta-tools.js +2 -1
  83. package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
  84. package/dist/app/controllers/cancel-all-result.d.ts +8 -0
  85. package/dist/app/controllers/cancel-all-result.js +32 -0
  86. package/dist/app/controllers/cancel-all-result.js.map +1 -0
  87. package/dist/app/controllers/job-controller.d.ts +4 -3
  88. package/dist/app/controllers/job-controller.js +9 -6
  89. package/dist/app/controllers/job-controller.js.map +1 -1
  90. package/dist/app/controllers/plan-controller.js +14 -0
  91. package/dist/app/controllers/plan-controller.js.map +1 -1
  92. package/dist/app/controllers/responder-settlement.js +2 -2
  93. package/dist/app/controllers/responder-settlement.js.map +1 -1
  94. package/dist/app/controllers/session-compact-helper.d.ts +23 -1
  95. package/dist/app/controllers/session-compact-helper.js +117 -7
  96. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  97. package/dist/app/controllers/session-context-usage.d.ts +24 -0
  98. package/dist/app/controllers/session-context-usage.js +89 -0
  99. package/dist/app/controllers/session-context-usage.js.map +1 -0
  100. package/dist/app/controllers/session-controller.d.ts +25 -17
  101. package/dist/app/controllers/session-controller.js +142 -128
  102. package/dist/app/controllers/session-controller.js.map +1 -1
  103. package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
  104. package/dist/app/controllers/session-prompt-queue.js +20 -1
  105. package/dist/app/controllers/session-prompt-queue.js.map +1 -1
  106. package/dist/app/controllers/session-responder.js +16 -6
  107. package/dist/app/controllers/session-responder.js.map +1 -1
  108. package/dist/app/controllers/session-turn-request.d.ts +18 -0
  109. package/dist/app/controllers/session-turn-request.js +31 -0
  110. package/dist/app/controllers/session-turn-request.js.map +1 -0
  111. package/dist/app/controllers/turn-continuation.d.ts +14 -0
  112. package/dist/app/controllers/turn-continuation.js +46 -0
  113. package/dist/app/controllers/turn-continuation.js.map +1 -0
  114. package/dist/app/events/app-event.d.ts +21 -0
  115. package/dist/app/events/app-event.js +1 -0
  116. package/dist/app/events/app-event.js.map +1 -1
  117. package/dist/app/ports/agent-port.d.ts +7 -0
  118. package/dist/app/ports/interactive-sessions-port.d.ts +21 -0
  119. package/dist/app/ports/interactive-sessions-port.js +9 -0
  120. package/dist/app/ports/interactive-sessions-port.js.map +1 -0
  121. package/dist/app/ports/jobs-port.d.ts +4 -3
  122. package/dist/app/ports/persistence-port.d.ts +11 -0
  123. package/dist/app/ports/updates-port.d.ts +8 -0
  124. package/dist/attachments/service.js +13 -2
  125. package/dist/attachments/service.js.map +1 -1
  126. package/dist/commands/providers.js +13 -10
  127. package/dist/commands/providers.js.map +1 -1
  128. package/dist/commands/update.d.ts +4 -0
  129. package/dist/commands/update.js +14 -0
  130. package/dist/commands/update.js.map +1 -1
  131. package/dist/index.js +21 -8
  132. package/dist/index.js.map +1 -1
  133. package/dist/interactive-session/artifact-writer.d.ts +54 -0
  134. package/dist/interactive-session/artifact-writer.js +161 -0
  135. package/dist/interactive-session/artifact-writer.js.map +1 -0
  136. package/dist/interactive-session/cleanup.d.ts +36 -0
  137. package/dist/interactive-session/cleanup.js +157 -0
  138. package/dist/interactive-session/cleanup.js.map +1 -0
  139. package/dist/interactive-session/config.d.ts +118 -0
  140. package/dist/interactive-session/config.js +141 -0
  141. package/dist/interactive-session/config.js.map +1 -0
  142. package/dist/interactive-session/input-policy.d.ts +41 -0
  143. package/dist/interactive-session/input-policy.js +163 -0
  144. package/dist/interactive-session/input-policy.js.map +1 -0
  145. package/dist/interactive-session/manager.d.ts +107 -0
  146. package/dist/interactive-session/manager.js +927 -0
  147. package/dist/interactive-session/manager.js.map +1 -0
  148. package/dist/interactive-session/output-store.d.ts +81 -0
  149. package/dist/interactive-session/output-store.js +239 -0
  150. package/dist/interactive-session/output-store.js.map +1 -0
  151. package/dist/interactive-session/output-view.d.ts +20 -0
  152. package/dist/interactive-session/output-view.js +142 -0
  153. package/dist/interactive-session/output-view.js.map +1 -0
  154. package/dist/interactive-session/recovery-journal.d.ts +55 -0
  155. package/dist/interactive-session/recovery-journal.js +115 -0
  156. package/dist/interactive-session/recovery-journal.js.map +1 -0
  157. package/dist/interactive-session/registry.d.ts +59 -0
  158. package/dist/interactive-session/registry.js +170 -0
  159. package/dist/interactive-session/registry.js.map +1 -0
  160. package/dist/interactive-session/runtime.d.ts +107 -0
  161. package/dist/interactive-session/runtime.js +191 -0
  162. package/dist/interactive-session/runtime.js.map +1 -0
  163. package/dist/interactive-session/session-runtime.d.ts +50 -0
  164. package/dist/interactive-session/session-runtime.js +63 -0
  165. package/dist/interactive-session/session-runtime.js.map +1 -0
  166. package/dist/interactive-session/streaming-redactor.d.ts +53 -0
  167. package/dist/interactive-session/streaming-redactor.js +263 -0
  168. package/dist/interactive-session/streaming-redactor.js.map +1 -0
  169. package/dist/interactive-session/telemetry.d.ts +30 -0
  170. package/dist/interactive-session/telemetry.js +47 -0
  171. package/dist/interactive-session/telemetry.js.map +1 -0
  172. package/dist/interactive-session/transport-factory.d.ts +8 -0
  173. package/dist/interactive-session/transport-factory.js +16 -0
  174. package/dist/interactive-session/transport-factory.js.map +1 -0
  175. package/dist/interactive-session/transport-node-pty.d.ts +49 -0
  176. package/dist/interactive-session/transport-node-pty.js +219 -0
  177. package/dist/interactive-session/transport-node-pty.js.map +1 -0
  178. package/dist/interactive-session/transport-pipe.d.ts +9 -0
  179. package/dist/interactive-session/transport-pipe.js +215 -0
  180. package/dist/interactive-session/transport-pipe.js.map +1 -0
  181. package/dist/interactive-session/transport.d.ts +104 -0
  182. package/dist/interactive-session/transport.js +89 -0
  183. package/dist/interactive-session/transport.js.map +1 -0
  184. package/dist/interactive-session/types.d.ts +254 -0
  185. package/dist/interactive-session/types.js +191 -0
  186. package/dist/interactive-session/types.js.map +1 -0
  187. package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
  188. package/dist/llm/adapters/anthropic-tools.js +36 -5
  189. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  190. package/dist/llm/adapters/gemini-tools.d.ts +3 -1
  191. package/dist/llm/adapters/gemini-tools.js +5 -2
  192. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  193. package/dist/llm/adapters/ollama-tools.js +8 -3
  194. package/dist/llm/adapters/ollama-tools.js.map +1 -1
  195. package/dist/llm/adapters/openai-tools.js +8 -1
  196. package/dist/llm/adapters/openai-tools.js.map +1 -1
  197. package/dist/llm/agentrouter.js +2 -0
  198. package/dist/llm/agentrouter.js.map +1 -1
  199. package/dist/llm/anthropic.d.ts +8 -0
  200. package/dist/llm/anthropic.js +68 -9
  201. package/dist/llm/anthropic.js.map +1 -1
  202. package/dist/llm/aws-mantle.js +60 -12
  203. package/dist/llm/aws-mantle.js.map +1 -1
  204. package/dist/llm/bynara.js +2 -0
  205. package/dist/llm/bynara.js.map +1 -1
  206. package/dist/llm/capabilities.d.ts +7 -0
  207. package/dist/llm/capabilities.js +22 -0
  208. package/dist/llm/capabilities.js.map +1 -1
  209. package/dist/llm/gemini.d.ts +3 -0
  210. package/dist/llm/gemini.js +71 -11
  211. package/dist/llm/gemini.js.map +1 -1
  212. package/dist/llm/groq.d.ts +20 -1
  213. package/dist/llm/groq.js +32 -4
  214. package/dist/llm/groq.js.map +1 -1
  215. package/dist/llm/http.d.ts +29 -0
  216. package/dist/llm/http.js +122 -29
  217. package/dist/llm/http.js.map +1 -1
  218. package/dist/llm/kimchi.js +2 -0
  219. package/dist/llm/kimchi.js.map +1 -1
  220. package/dist/llm/nvidia.js +2 -0
  221. package/dist/llm/nvidia.js.map +1 -1
  222. package/dist/llm/ollama.d.ts +5 -0
  223. package/dist/llm/ollama.js +53 -6
  224. package/dist/llm/ollama.js.map +1 -1
  225. package/dist/llm/openai.js +2 -0
  226. package/dist/llm/openai.js.map +1 -1
  227. package/dist/llm/openrouter.js +2 -0
  228. package/dist/llm/openrouter.js.map +1 -1
  229. package/dist/llm/qwen-cloud.js +2 -0
  230. package/dist/llm/qwen-cloud.js.map +1 -1
  231. package/dist/llm/router.d.ts +16 -0
  232. package/dist/llm/router.js +79 -23
  233. package/dist/llm/router.js.map +1 -1
  234. package/dist/llm/sampling.d.ts +32 -0
  235. package/dist/llm/sampling.js +48 -0
  236. package/dist/llm/sampling.js.map +1 -0
  237. package/dist/llm/stream-progress.d.ts +4 -0
  238. package/dist/llm/stream-progress.js +43 -0
  239. package/dist/llm/stream-progress.js.map +1 -0
  240. package/dist/llm/system-messages.d.ts +29 -0
  241. package/dist/llm/system-messages.js +51 -0
  242. package/dist/llm/system-messages.js.map +1 -0
  243. package/dist/llm/token-usage.d.ts +4 -1
  244. package/dist/llm/token-usage.js +56 -4
  245. package/dist/llm/token-usage.js.map +1 -1
  246. package/dist/llm/tool-protocol.js +30 -0
  247. package/dist/llm/tool-protocol.js.map +1 -1
  248. package/dist/os/process-identity.d.ts +46 -0
  249. package/dist/os/process-identity.js +147 -0
  250. package/dist/os/process-identity.js.map +1 -0
  251. package/dist/os/process-tree.d.ts +15 -0
  252. package/dist/os/process-tree.js +77 -0
  253. package/dist/os/process-tree.js.map +1 -0
  254. package/dist/prompts/embedded.js +1 -1
  255. package/dist/prompts/embedded.js.map +1 -1
  256. package/dist/prompts/system.agent.md +13 -12
  257. package/dist/repl/prompt-line.d.ts +5 -0
  258. package/dist/repl/prompt-line.js +7 -1
  259. package/dist/repl/prompt-line.js.map +1 -1
  260. package/dist/repl.js +71 -41
  261. package/dist/repl.js.map +1 -1
  262. package/dist/safety/classifier.js +98 -44
  263. package/dist/safety/classifier.js.map +1 -1
  264. package/dist/safety/engagement-policy.d.ts +8 -0
  265. package/dist/safety/engagement-policy.js +52 -7
  266. package/dist/safety/engagement-policy.js.map +1 -1
  267. package/dist/safety/patterns.d.ts +44 -0
  268. package/dist/safety/patterns.js +168 -37
  269. package/dist/safety/patterns.js.map +1 -1
  270. package/dist/store/config.d.ts +9 -1
  271. package/dist/store/config.js +51 -1
  272. package/dist/store/config.js.map +1 -1
  273. package/dist/store/history-index.d.ts +12 -0
  274. package/dist/store/history-index.js +47 -18
  275. package/dist/store/history-index.js.map +1 -1
  276. package/dist/store/history.d.ts +5 -2
  277. package/dist/store/history.js +153 -62
  278. package/dist/store/history.js.map +1 -1
  279. package/dist/store/plan.d.ts +81 -5
  280. package/dist/store/plan.js +406 -46
  281. package/dist/store/plan.js.map +1 -1
  282. package/dist/store/redaction-cache.d.ts +12 -0
  283. package/dist/store/redaction-cache.js +51 -0
  284. package/dist/store/redaction-cache.js.map +1 -0
  285. package/dist/store/responder-settlement.d.ts +5 -1
  286. package/dist/store/responder-settlement.js +42 -30
  287. package/dist/store/responder-settlement.js.map +1 -1
  288. package/dist/store/scope.js +36 -27
  289. package/dist/store/scope.js.map +1 -1
  290. package/dist/store/task-transitions.d.ts +19 -0
  291. package/dist/store/task-transitions.js +46 -0
  292. package/dist/store/task-transitions.js.map +1 -0
  293. package/dist/tools/capabilities.js +33 -18
  294. package/dist/tools/capabilities.js.map +1 -1
  295. package/dist/tools/command-intent.d.ts +24 -0
  296. package/dist/tools/command-intent.js +158 -4
  297. package/dist/tools/command-intent.js.map +1 -1
  298. package/dist/tools/definitions.d.ts +5 -1
  299. package/dist/tools/definitions.js +257 -21
  300. package/dist/tools/definitions.js.map +1 -1
  301. package/dist/tools/fs.d.ts +16 -0
  302. package/dist/tools/fs.js +122 -46
  303. package/dist/tools/fs.js.map +1 -1
  304. package/dist/tools/image.js +1 -1
  305. package/dist/tools/image.js.map +1 -1
  306. package/dist/tools/interactive-session-tools.d.ts +11 -0
  307. package/dist/tools/interactive-session-tools.js +261 -0
  308. package/dist/tools/interactive-session-tools.js.map +1 -0
  309. package/dist/tools/jobs.d.ts +78 -4
  310. package/dist/tools/jobs.js +486 -127
  311. package/dist/tools/jobs.js.map +1 -1
  312. package/dist/tools/net-ping-sweep.d.ts +3 -1
  313. package/dist/tools/net-ping-sweep.js +64 -26
  314. package/dist/tools/net-ping-sweep.js.map +1 -1
  315. package/dist/tools/nmap-runner.js +5 -2
  316. package/dist/tools/nmap-runner.js.map +1 -1
  317. package/dist/tools/pdf.js +50 -7
  318. package/dist/tools/pdf.js.map +1 -1
  319. package/dist/tools/registry.js +158 -96
  320. package/dist/tools/registry.js.map +1 -1
  321. package/dist/tools/shell.d.ts +24 -2
  322. package/dist/tools/shell.js +113 -28
  323. package/dist/tools/shell.js.map +1 -1
  324. package/dist/tools/tool-types.d.ts +2 -0
  325. package/dist/tools/validate.d.ts +8 -5
  326. package/dist/tools/validate.js +177 -7
  327. package/dist/tools/validate.js.map +1 -1
  328. package/dist/tools/wordlists.js +69 -17
  329. package/dist/tools/wordlists.js.map +1 -1
  330. package/dist/tui/state.d.ts +4 -0
  331. package/dist/tui/state.js +88 -2
  332. package/dist/tui/state.js.map +1 -1
  333. package/dist/tui-v2/actions/keymap.js +3 -8
  334. package/dist/tui-v2/actions/keymap.js.map +1 -1
  335. package/dist/tui-v2/app/App.js +52 -4
  336. package/dist/tui-v2/app/App.js.map +1 -1
  337. package/dist/tui-v2/app/commands/config-commands.js +5 -2
  338. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  339. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  340. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  341. package/dist/tui-v2/app/plan-lifecycle.d.ts +1 -1
  342. package/dist/tui-v2/app/plan-lifecycle.js +2 -1
  343. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  344. package/dist/tui-v2/bootstrap/composition-root.d.ts +3 -0
  345. package/dist/tui-v2/bootstrap/composition-root.js +3 -0
  346. package/dist/tui-v2/bootstrap/composition-root.js.map +1 -1
  347. package/dist/tui-v2/bootstrap/start-tui-v2.js +10 -0
  348. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  349. package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
  350. package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
  351. package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
  352. package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
  353. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  354. package/dist/tui-v2/components/plan/plan-view.js +9 -6
  355. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  356. package/dist/tui-v2/components/status/status-line.d.ts +15 -0
  357. package/dist/tui-v2/components/status/status-line.js +33 -25
  358. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  359. package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
  360. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  361. package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
  362. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  363. package/dist/tui-v2/components/transcript/intro-card.js +3 -0
  364. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  365. package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
  366. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  367. package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
  368. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  369. package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
  370. package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
  371. package/dist/tui-v2/composer/composer-editor.js +12 -6
  372. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  373. package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
  374. package/dist/tui-v2/rendering/incremental-strip.js +89 -0
  375. package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
  376. package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
  377. package/dist/tui-v2/rendering/plan-view.js +71 -2
  378. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  379. package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
  380. package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
  381. package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
  382. package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
  383. package/dist/tui-v2/rendering/thinking-tail.js +26 -0
  384. package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
  385. package/dist/tui-v2/state/transcript-hydrate.js +6 -2
  386. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  387. package/dist/tui-v2/state/transcript-reducer.js +146 -25
  388. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  389. package/dist/tui-v2/state/transcript-types.d.ts +6 -0
  390. package/dist/tui-v2/state/transcript-types.js +17 -0
  391. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  392. package/dist/types.d.ts +34 -0
  393. package/dist/types.js.map +1 -1
  394. package/dist/ui/intro-header.d.ts +1 -0
  395. package/dist/ui/intro-header.js +10 -13
  396. package/dist/ui/intro-header.js.map +1 -1
  397. package/dist/ui/markdown.js +9 -2
  398. package/dist/ui/markdown.js.map +1 -1
  399. package/dist/ui/mentions.d.ts +5 -0
  400. package/dist/ui/mentions.js +66 -7
  401. package/dist/ui/mentions.js.map +1 -1
  402. package/dist/ui/thinking.d.ts +5 -1
  403. package/dist/ui/thinking.js +3 -2
  404. package/dist/ui/thinking.js.map +1 -1
  405. package/dist/version.generated.d.ts +2 -2
  406. package/dist/version.generated.js +2 -2
  407. package/package.json +2 -1
@@ -1,18 +1,19 @@
1
1
  import chalk from "chalk";
2
2
  import { join } from "node:path";
3
- import { streamWithProvider, completeWithProvider } from "../llm/router.js";
3
+ import { completeWithProvider, streamWithProvider } from "../llm/router.js";
4
+ import { streamAlreadyEmitted } from "../llm/stream-progress.js";
4
5
  import { classifyStreamFailure, planStreamRecovery, recordRecoveryAttempt, createStreamRecoveryState, resetStreamRecoveryState, } from "./stream-recovery.js";
5
6
  import { resolveToolDialect } from "../llm/capabilities.js";
6
7
  import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
7
8
  import { sanitizeAssistantText } from "../ui/ansi-box.js";
8
9
  import { randomUUID } from "node:crypto";
9
10
  import { jobManager, } from "../tools/jobs.js";
10
- import { responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
11
+ import { isResponderResultLedgerMessage, responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
11
12
  import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
12
13
  import { getConfig } from "../store/config.js";
13
14
  import { beginSessionWorkspace, getActiveSessionWorkspace, } from "../store/session-workspace.js";
14
15
  import { groqInputTokenBudget } from "../llm/groq.js";
15
- import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
16
+ import { classifyToolCall, isPentestToolCall, scopeTargetForToolCall, } from "../safety/classifier.js";
16
17
  /**
17
18
  * Scope/engagement classification runs on every tool call and parses
18
19
  * model-supplied arguments (URLs, hosts, commands). A malformed argument must
@@ -27,20 +28,20 @@ function safeScopeTargetForToolCall(call) {
27
28
  return undefined;
28
29
  }
29
30
  }
30
- function safeEngagementActionForToolCall(call) {
31
+ function safeEngagementActionsForToolCall(call) {
31
32
  try {
32
- return engagementActionForToolCall(call);
33
+ return engagementActionsForToolCall(call);
33
34
  }
34
35
  catch {
35
- return undefined;
36
+ return [];
36
37
  }
37
38
  }
38
39
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
39
- import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
40
+ import { getToolDefinitions, getCompactToolDefinitions, RUNNER_META_TOOL_NAMES, } from "../tools/definitions.js";
40
41
  import { appendAssistantWithTools, ensureUniqueToolCallIds, appendToolResult, assertValidToolProtocol, fillMissingToolResults, repairToolProtocol, } from "./tool-history.js";
41
42
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
42
43
  import { compactMessagesWithSummary, shouldApplyAutoCompact, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
43
- import { buildContextBreakdown, contextBreakdownAuditPayload, } from "./context-breakdown.js";
44
+ import { buildContextBreakdown, contextBreakdownAuditPayload, describeDominantContextBlock, toolSchemaHash, } from "./context-breakdown.js";
44
45
  import { autoCompactTriggerTokens, dedupeToolContextOutput, freeTierGuardNotices, getReliabilityPolicy, resolveStepMaxTokens, } from "./reliability-policy.js";
45
46
  import { auditLog } from "../store/logs.js";
46
47
  import { loadProjectContext } from "../store/project.js";
@@ -53,24 +54,29 @@ import { safeCwd } from "../os/cwd.js";
53
54
  import { analyzeTask, formatTaskAnalysisHint, isNarrowExplicitNmapOperation, } from "./task-analyzer.js";
54
55
  import { computeMaxIterations, computeStepBudget } from "./step-budget.js";
55
56
  import { isScratchOnlyWrite } from "./scratch-write.js";
57
+ import { buildDurableEnvelope, WorkLedger, } from "./durable-envelope.js";
56
58
  import { COMPACTION_SYSTEM_PROMPT, } from "./compaction-summary.js";
57
59
  import { maybeAppendPlanModeReminder, PLAN_REMINDER_TOAST, } from "./plan-mode-reminders.js";
58
60
  import { LoopGuard } from "./loop-guard.js";
59
- import { loadPlan, savePlan, markTask, appendPlanTask, readyPlanTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
60
- import { fsWrite, isOutsideWorkingDirectory, resolveFsToolPath, } from "../tools/fs.js";
61
+ import { CompactionAttemptLedger, compactionAttemptKey, } from "./compaction-attempt.js";
62
+ import { resolveRequestBudget } from "./request-budget.js";
63
+ import { loadPlan, mutatePlan, markTask, appendPlanTask, readyPlanTasks, foregroundRemaining, responderOpenTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
64
+ import { stat } from "node:fs/promises";
65
+ import { isOutsideWorkingDirectory, resolveFsToolPath, } from "../tools/fs.js";
61
66
  import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, salvageTruncatedWriteFromNative, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, looksLikePlanNarration, looksLikeErrorDiagnosisWithFixIntent, localHttpProbeIsFailure, localHttpProbeIsSuccess, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, narrowNmapOperationDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
62
67
  import { createSessionPolicy, isPreApprovalAllowedTool, isPlanModeAllowedShellCommand, isPlanModeAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
63
68
  import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
64
69
  import { codingSessionFromContext, isProtocolPlaceholderOutput, progressPauseMode, } from "./progress-pause-policy.js";
65
- import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
66
- import { readTaskUpdateArgs, distinctAdvancingTaskIds, isSimultaneousTaskAdvance, batchUpdateSignature, buildMultiUpdateReminder, multiUpdateToast, } from "./task-sync.js";
67
- import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, classifyTaskTitle, hasLocalRuntimeProof, hasRemoteWorkProof, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
70
+ import { renderPlanForTerminal, planContextMessage, upsertPlanContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
71
+ import { readDeclaredParentTaskId, resolveResponderParent, isExplicitResponderDelegation, delegationTaskTitle, } from "./responder-parent.js";
72
+ import { readTaskUpdateArgs, distinctAdvancingTaskIds, isSimultaneousTaskAdvance, batchUpdateSignature, buildMultiUpdateReminder, buildMultiOpenRejection, multiUpdateToast, multiOpenToast, openingTaskIds, } from "./task-sync.js";
73
+ import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
68
74
  import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
69
- import { buildContinueOrientation } from "./continue-orient.js";
75
+ import { buildContinueOrientation, looksLikeContinueOrResumePrompt, } from "./continue-orient.js";
70
76
  import { detectPackageManager } from "./workspace-orient.js";
71
77
  import { budgetRemaining, consumeBudget, createRecoveryBudgets, freestyleClaimsAppReady, looksLikeShallowPentestReport, recoveryForErrorDiagnosis, recoveryForFailedProbe, recoveryForFreshness, recoveryForMissingFeature, recoveryForMissingPlan, recoveryForNarration, recoveryForPrematureComplete, recoveryForRuntimeVerify, recoveryForShallowPentest, } from "./must-continue.js";
72
- import { scopeContextMessage } from "./scope-context.js";
73
- import { EngagementPolicyEngine, actionFromUrl, engagementActionForToolCall, evaluateEngagementAction, } from "../safety/engagement-policy.js";
78
+ import { outOfScopeToolMessage, scopeContextMessage } from "./scope-context.js";
79
+ import { EngagementPolicyEngine, actionFromUrl, engagementActionsForToolCall, evaluateEngagementAction, } from "../safety/engagement-policy.js";
74
80
  import { patchPlanMeta } from "../store/plan.js";
75
81
  import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
76
82
  import { buildWorkspaceOrientation, discoverImmediateProjectRoots, guessProjectFolderName, isBareParentDirectory, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, resolveScaffoldTargetPath, } from "./workspace-orient.js";
@@ -80,28 +86,51 @@ import { composeAgentSystemPrompt } from "./prompt-composer.js";
80
86
  import { createGovernorState, governProgress, } from "./evidence-governor.js";
81
87
  import { createTurnState, transitionTurn, } from "./turn-state.js";
82
88
  import { deriveOutcomeStatus, inferOutcomeKind, openOutcomeState, recordAnswerEvidence, recordFailedHypothesis, recordToolEvidence, saveOutcomeState, validateCriterionEvidence, } from "./outcomes.js";
83
- import { createTurnOutcome, renderTurnOutcome } from "./turn-outcome.js";
89
+ import { createTurnOutcome, normalizeTurnOutcomeInput, renderTurnOutcome, } from "./turn-outcome.js";
84
90
  import { beginEngagementAction, finishEngagementAction, recordEngagementCheckpoint, reconcileEngagementJob, openEngagement, saveEngagement, } from "../store/engagement.js";
85
91
  export * from "./tool-call-parser.js";
86
92
  export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
87
93
  export function styleToolChatter(call, text) {
88
94
  return shouldDimToolChatter(call) ? chalk.dim(text) : text;
89
95
  }
90
- export function shouldYieldForResponderBeforeReport(plan, runningJobs, notifications, currentNotificationId) {
96
+ /**
97
+ * A foreground task waits for a responder child only when the plan
98
+ * Declares that dependency. Report titles carry no scheduling meaning: any
99
+ * Dependency-ready foreground work is executed while children keep running, and
100
+ * Their results arrive as addenda.
101
+ *
102
+ * A declared dependency blocks only while the child is genuinely live (running
103
+ * Job or an undelivered/unanalyzed receipt), so an orphaned child can never
104
+ * Stall the turn forever.
105
+ */
106
+ export function shouldYieldForDeclaredResponderDependency(plan, runningJobs, notifications, currentNotificationId) {
91
107
  if (!plan)
92
108
  return false;
93
109
  const unfinished = plan.tasks.filter((task) => !task.responderOwned &&
94
110
  (task.state === "pending" || task.state === "in_progress"));
95
- if (unfinished.length === 0 ||
96
- !unfinished.every((task) => classifyTaskTitle(task.title, { planKind: plan.kind }) === "report")) {
111
+ if (unfinished.length === 0)
97
112
  return false;
98
- }
99
- return (runningJobs.some((job) => job.responder) ||
100
- notifications.some((notification) => (!currentNotificationId || notification.id !== currentNotificationId) &&
101
- notification.responder &&
113
+ const childById = new Map(plan.tasks.filter((task) => task.responderOwned).map((task) => [task.id, task]));
114
+ const isLive = (child) => {
115
+ if (child.state === "done" || child.state === "skipped" || child.state === "failed") {
116
+ return false;
117
+ }
118
+ const running = runningJobs.some((job) => job.responder &&
119
+ (job.taskId === child.id || (!!child.jobId && job.id === child.jobId)));
120
+ if (running)
121
+ return true;
122
+ return notifications.some((notification) => notification.responder &&
102
123
  !notification.archivedAt &&
103
124
  !notification.readAt &&
104
- !notification.analyzedAt));
125
+ !notification.analyzedAt &&
126
+ (!currentNotificationId || notification.id !== currentNotificationId) &&
127
+ (notification.taskId === child.id ||
128
+ (!!child.jobId && notification.jobId === child.jobId)));
129
+ };
130
+ return unfinished.every((task) => (task.dependencies ?? []).some((dependency) => {
131
+ const child = childById.get(dependency);
132
+ return !!child && isLive(child);
133
+ }));
105
134
  }
106
135
  /**
107
136
  * Cancellable backoff. Resolves after `ms`, or rejects immediately if the
@@ -246,33 +275,61 @@ export async function runAgentTurn(prompt, options = {}) {
246
275
  };
247
276
  /** Strip a known prefix from a string, returning the remainder unchanged. */
248
277
  const insertedText = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length) : value;
249
- /**
250
- * Surface the compacted-context summary to both the TUI (via an event)
251
- * and, when running with a direct stdout writer, as a rendered box.
252
- * Token-count stats are always emitted for logs.
253
- */
254
- const writeCompacted = (summary, beforeTokens, afterTokens) => {
255
- emit({ type: "compacted", summary, beforeTokens, afterTokens });
278
+ const writeCompactionStarted = (id, beforeTokens) => {
279
+ emit({ type: "compaction-start", id, beforeTokens });
280
+ if (writesDirectly) {
281
+ process.stdout.write(chalk.dim(" ✦ Compacted Context · streaming Markdown\n\n"));
282
+ }
283
+ };
284
+ const writeCompactionDelta = (id, text) => {
285
+ if (!text)
286
+ return;
287
+ emit({ type: "compaction-delta", id, text });
288
+ if (writesDirectly)
289
+ process.stdout.write(text);
290
+ };
291
+ const writeCompactionCompleted = (id, summary, beforeTokens, afterTokens) => {
292
+ emit({
293
+ type: "compaction-completed",
294
+ id,
295
+ summary,
296
+ beforeTokens,
297
+ afterTokens,
298
+ });
299
+ if (writesDirectly) {
300
+ const footer = chalk.dim(`\n ~${beforeTokens.toLocaleString()} → ~${afterTokens.toLocaleString()} tokens`);
301
+ process.stdout.write(`${footer}\n`);
302
+ }
303
+ };
304
+ const writeCompactionFailed = (id, message, retainedTokens) => {
305
+ emit({ type: "compaction-failed", id, message, retainedTokens });
256
306
  if (writesDirectly) {
257
- const header = chalk.dim(" \u2726 Compacted Context");
258
- const footer = chalk.dim(` ~${beforeTokens.toLocaleString()} \u2192 ~${afterTokens.toLocaleString()} tokens`);
259
- const body = summary ? renderMarkdown(summary) : "(empty summary)";
260
- process.stdout.write(`${header}\n\n${body}\n${footer}\n`);
307
+ process.stdout.write(chalk.yellow(`\n Compaction failed: ${message} (~${retainedTokens.toLocaleString()} tokens retained)\n`));
261
308
  }
262
309
  };
263
310
  // Points at the live message array so finishTurn can hand the full
264
311
  // conversation back to the caller. Assigned once `messages` is built below;
265
312
  // all later mutations are in-place so this reference stays current.
266
313
  let liveMessages = [];
314
+ let suppressOutcomeDiagnostics = false;
315
+ const unreadResponderNotificationIds = new Set();
316
+ const releaseUnreadResponderClaims = () => {
317
+ for (const notificationId of unreadResponderNotificationIds) {
318
+ jobManager.releaseResponderNotificationClaim(notificationId);
319
+ }
320
+ };
267
321
  const finishTurn = (answer, steps, status = "succeeded", remainingCriteria = [], reason) => {
268
- const outcome = createTurnOutcome({
322
+ releaseUnreadResponderClaims();
323
+ const outcome = createTurnOutcome(normalizeTurnOutcomeInput({
269
324
  status,
270
325
  answer,
271
326
  steps,
272
327
  remainingCriteria,
273
328
  reason,
329
+ }));
330
+ const rendered = renderTurnOutcome(outcome, {
331
+ diagnostics: !suppressOutcomeDiagnostics,
274
332
  });
275
- const rendered = renderTurnOutcome(outcome);
276
333
  writeAssistantMessage(rendered);
277
334
  if (options.onMessages) {
278
335
  try {
@@ -323,6 +380,10 @@ export async function runAgentTurn(prompt, options = {}) {
323
380
  // freshness retries — a false "act don't narrate" path burned tokens on
324
381
  // web.search recovery loops after a simple "hi".
325
382
  const idleOrSocialPrompt = looksLikeIdleOrSocialPrompt(prompt);
383
+ suppressOutcomeDiagnostics =
384
+ informationalQuery ||
385
+ idleOrSocialPrompt ||
386
+ looksLikeContinueOrResumePrompt(prompt);
326
387
  const freshWebSearchRequired = !buildLikeTurn &&
327
388
  !pentestLikeTurn &&
328
389
  !idleOrSocialPrompt &&
@@ -348,7 +409,7 @@ export async function runAgentTurn(prompt, options = {}) {
348
409
  const base = compact
349
410
  ? getCompactToolDefinitions()
350
411
  : getToolDefinitions();
351
- const allow = new Set([...toolNames, ...PLAN_TOOL_NAMES]);
412
+ const allow = new Set([...toolNames, ...RUNNER_META_TOOL_NAMES]);
352
413
  return base.filter((d) => allow.has(d.name));
353
414
  };
354
415
  let lastAnswer = "";
@@ -379,6 +440,7 @@ export async function runAgentTurn(prompt, options = {}) {
379
440
  // behind a stale "awaiting approval" gate for a plan that already ran.
380
441
  session.planApproved.value = true;
381
442
  }
443
+ suppressOutcomeDiagnostics ||= !session.planApproved.value;
382
444
  const destinationHint = resolveUserDestinationHint(prompt);
383
445
  const orientationSourceText = [
384
446
  prompt,
@@ -469,9 +531,9 @@ export async function runAgentTurn(prompt, options = {}) {
469
531
  return sections.join("\n\n");
470
532
  };
471
533
  const systemSections = [buildSystemContent(nativeToolsActive)];
472
- if (activePlan) {
473
- systemSections.push(planContextMessage(activePlan, session.planApproved.value));
474
- }
534
+ // The live plan is mutable state: it is injected once as a keyed request
535
+ // suffix (upsertPlanContextMessage) instead of being frozen into the stable
536
+ // system prefix, so the model never sees a stale and a fresh plan together.
475
537
  // Soft mid-work recovery (any domain): re-attach to jobs / open tasks /
476
538
  // last tools after interrupt or "continue" — not a hard gate.
477
539
  if (!informationalQuery && !idleOrSocialPrompt) {
@@ -483,6 +545,7 @@ export async function runAgentTurn(prompt, options = {}) {
483
545
  recentJobs: jobManager.getRecentJobs(12, session.sessionId),
484
546
  informationalQuery,
485
547
  idleOrSocial: idleOrSocialPrompt,
548
+ ...(options.previousTurn ? { previousTurn: options.previousTurn } : {}),
486
549
  });
487
550
  if (continueBrief) {
488
551
  systemSections.push(continueBrief);
@@ -585,7 +648,7 @@ export async function runAgentTurn(prompt, options = {}) {
585
648
  if (!has("plan")) {
586
649
  sections.push({
587
650
  kind: "plan",
588
- content: "ACTIVE PLAN\nNo persisted plan is active for this turn.",
651
+ content: "PLAN PROTOCOL\nThe live plan, when one exists, is appended to this request as a single ACTIVE PLAN message. Treat that message as the only authoritative plan state; never rely on plan details quoted in earlier turns.",
589
652
  mandatory: true,
590
653
  });
591
654
  }
@@ -636,13 +699,31 @@ export async function runAgentTurn(prompt, options = {}) {
636
699
  userMessage,
637
700
  ];
638
701
  liveMessages = messages;
639
- const responderWakeTurn = prompt.startsWith("Responder result arrived");
702
+ if (activePlan) {
703
+ upsertPlanContextMessage(messages, planContextMessage(activePlan, session.planApproved.value));
704
+ }
705
+ const responderWakeTurn = options.displayPrompt === null &&
706
+ prompt.startsWith("Responder result arrived");
640
707
  const responderWakeNotificationId = responderWakeTurn
641
708
  ? /^notification=(.+)$/m.exec(prompt)?.[1]?.trim()
642
709
  : undefined;
643
- const unreadResponderNotificationIds = new Set();
644
- if (responderWakeNotificationId) {
645
- unreadResponderNotificationIds.add(responderWakeNotificationId);
710
+ const responderWakeJobId = responderWakeTurn
711
+ ? /^job=(.+)$/m.exec(prompt)?.[1]?.trim()
712
+ : undefined;
713
+ const responderWakeResultRevision = responderWakeTurn
714
+ ? Number(/^resultRevision=(\d+)$/m.exec(prompt)?.[1]) || undefined
715
+ : undefined;
716
+ const matchesWakeRevision = (notification) => responderWakeResultRevision === undefined ||
717
+ (notification.resultRevision ?? 1) === responderWakeResultRevision;
718
+ const wakeNotification = responderWakeNotificationId
719
+ ? jobManager
720
+ .getPendingNotifications(session.sessionId)
721
+ .find((notification) => notification.id === responderWakeNotificationId &&
722
+ (!responderWakeJobId || notification.jobId === responderWakeJobId) &&
723
+ matchesWakeRevision(notification))
724
+ : undefined;
725
+ if (wakeNotification) {
726
+ unreadResponderNotificationIds.add(wakeNotification.id);
646
727
  }
647
728
  const refreshResponderInbox = () => {
648
729
  const running = jobManager
@@ -653,6 +734,7 @@ export async function runAgentTurn(prompt, options = {}) {
653
734
  .getPendingNotifications(session.sessionId)
654
735
  .filter((notification) => notification.responder &&
655
736
  unreadResponderNotificationIds.has(notification.id) &&
737
+ matchesWakeRevision(notification) &&
656
738
  !notification.readAt &&
657
739
  !notification.analyzedAt &&
658
740
  !notification.archivedAt)
@@ -715,6 +797,33 @@ export async function runAgentTurn(prompt, options = {}) {
715
797
  };
716
798
  const loopGuard = new LoopGuard();
717
799
  const engagementPolicy = new EngagementPolicyEngine();
800
+ const probeStateKey = (call) => {
801
+ const project = (job) => job
802
+ ? [
803
+ job.id,
804
+ job.status,
805
+ job.exitCode ?? null,
806
+ job.signal ?? null,
807
+ job.stdoutArtifact,
808
+ job.artifacts.stdout.bytes,
809
+ job.artifacts.stdout.sha256,
810
+ job.stderrArtifact,
811
+ job.artifacts.stderr.bytes,
812
+ job.artifacts.stderr.sha256,
813
+ ]
814
+ : undefined;
815
+ if (call.name === "shell.tail" && typeof call.args.id === "string") {
816
+ const projected = project(jobManager.getJob(call.args.id));
817
+ return projected ? JSON.stringify(projected) : undefined;
818
+ }
819
+ if (call.name === "shell.jobs") {
820
+ return JSON.stringify(jobManager
821
+ .getRecentJobs(100, session.sessionId)
822
+ .map((job) => project(job))
823
+ .filter(Boolean));
824
+ }
825
+ return undefined;
826
+ };
718
827
  // Track consecutive thinking-only responses so we can nudge the model
719
828
  // to actually act instead of silently returning an empty answer.
720
829
  let emptyVisibleRetries = 0;
@@ -807,7 +916,7 @@ export async function runAgentTurn(prompt, options = {}) {
807
916
  });
808
917
  };
809
918
  const reconcileOpenTaskBeforeFinalizing = async () => {
810
- const plan = await loadPlan(session.sessionId).catch(() => undefined);
919
+ let plan = await loadPlan(session.sessionId).catch(() => undefined);
811
920
  const open = plan?.tasks.find((task) => task.state === "in_progress" && !task.responderOwned);
812
921
  if (!plan || !open)
813
922
  return plan;
@@ -815,42 +924,80 @@ export async function runAgentTurn(prompt, options = {}) {
815
924
  if (!gate.ok)
816
925
  return plan;
817
926
  const reconciledTaskIds = [open.id];
818
- markTask(plan, open.id, "done", "Completion reconciled from verified task evidence.");
927
+ const working = plan;
928
+ markTask(working, open.id, "done", "Completion reconciled from verified task evidence.");
819
929
  while (true) {
820
- const observation = readyPlanTasks(plan).find((task) => isRuntimeObservationTask(task.title) ||
821
- (plan.kind === "pentest" && isRemoteObservationTask(task.title)));
930
+ const observation = readyPlanTasks(working).find((task) => isRuntimeObservationTask(task.title) ||
931
+ (working.kind === "pentest" && isRemoteObservationTask(task.title)));
822
932
  if (!observation)
823
933
  break;
824
- const observationGate = completionGateForTask(plan, observation.id);
934
+ const observationGate = completionGateForTask(working, observation.id);
825
935
  if (!observationGate.ok)
826
936
  break;
827
- markTask(plan, observation.id, "done", plan.kind === "pentest"
937
+ markTask(working, observation.id, "done", working.kind === "pentest"
828
938
  ? "Satisfied by verified remote evidence from the preceding task."
829
939
  : "Satisfied by the verified runtime evidence from the preceding task.");
830
940
  reconciledTaskIds.push(observation.id);
831
941
  }
832
- if (plan.status === "draft" || plan.status === "approved") {
833
- plan.status = "in_progress";
942
+ if (working.status === "draft" || working.status === "approved") {
943
+ working.status = "in_progress";
834
944
  }
835
- if (isPlanTerminal(plan)) {
836
- plan.status = isPlanSuccessful(plan) ? "completed" : "abandoned";
945
+ if (isPlanTerminal(working)) {
946
+ working.status = isPlanSuccessful(working) ? "completed" : "abandoned";
837
947
  }
838
- await savePlan(plan).catch(() => undefined);
948
+ // Replay the same reconciliation as a reducer so a concurrent
949
+ // responder settlement is preserved instead of overwritten.
950
+ const reconciledNotes = new Map(reconciledTaskIds.map((id) => [
951
+ id,
952
+ working.tasks.find((task) => task.id === id)?.note,
953
+ ]));
954
+ const reconcileResult = await mutatePlan(session.sessionId, (draft) => {
955
+ let changed = false;
956
+ for (const [id, note] of reconciledNotes) {
957
+ const task = draft.tasks.find((candidate) => candidate.id === id);
958
+ if (!task || task.state === "done")
959
+ continue;
960
+ task.state = "done";
961
+ if (note !== undefined)
962
+ task.note = note;
963
+ changed = true;
964
+ }
965
+ if (draft.status === "draft" || draft.status === "approved") {
966
+ draft.status = "in_progress";
967
+ changed = true;
968
+ }
969
+ if (isPlanTerminal(draft)) {
970
+ draft.status = isPlanSuccessful(draft) ? "completed" : "abandoned";
971
+ changed = true;
972
+ }
973
+ return changed;
974
+ }).catch(() => undefined);
975
+ if (reconcileResult?.ok && reconcileResult.plan)
976
+ plan = reconcileResult.plan;
839
977
  writePlanUpdate(plan, renderPlanForTerminal(plan) + "\n");
840
978
  writeNotice("info", `reconciled ${reconciledTaskIds.map((id) => `[${id}]`).join(", ")} from verified evidence`, chalk.dim(` ℹ reconciled ${reconciledTaskIds.map((id) => `[${id}]`).join(", ")} from verified evidence — no duplicate verification\n`));
841
979
  taskWorkLedger = null;
842
980
  return plan;
843
981
  };
844
982
  async function persistProjectRootOnPlan(root) {
845
- const live = await loadPlan(session.sessionId).catch(() => undefined);
846
- if (!live)
847
- return;
848
983
  const pm = detectPackageManager(root);
849
- patchPlanMeta(live, {
850
- projectRoot: root,
851
- ...(pm ? { packageManager: pm } : {}),
852
- });
853
- await savePlan(live).catch(() => undefined);
984
+ // Metadata patches go through the transactional boundary so a
985
+ // concurrent task transition or responder settlement is not clobbered.
986
+ await mutatePlan(session.sessionId, (draft) => {
987
+ patchPlanMeta(draft, {
988
+ projectRoot: root,
989
+ ...(pm ? { packageManager: pm } : {}),
990
+ });
991
+ }).catch(() => undefined);
992
+ }
993
+ /** Persist task evidence without rewriting the whole plan. */
994
+ async function persistTaskEvidence(taskId, evidence) {
995
+ await mutatePlan(session.sessionId, (draft) => {
996
+ const task = draft.tasks.find((candidate) => candidate.id === taskId);
997
+ if (!task)
998
+ return false;
999
+ task.evidence = evidence;
1000
+ }).catch(() => undefined);
854
1001
  }
855
1002
  refreshSessionState = (plan) => {
856
1003
  if (idleOrSocialPrompt || informationalQuery)
@@ -901,6 +1048,11 @@ export async function runAgentTurn(prompt, options = {}) {
901
1048
  : undefined,
902
1049
  };
903
1050
  snap.nextHint = inferNextHint(snap);
1051
+ // One live plan copy, refreshed at the same protocol-safe points as
1052
+ // SESSION STATE so advancing tasks are never contradicted by a stale copy.
1053
+ if (p) {
1054
+ upsertPlanContextMessage(messages, planContextMessage(p, session.planApproved.value));
1055
+ }
904
1056
  upsertSessionStateMessage(messages, buildSessionStateBlock(snap));
905
1057
  };
906
1058
  refreshSessionState(activePlan);
@@ -937,7 +1089,10 @@ export async function runAgentTurn(prompt, options = {}) {
937
1089
  kind: inferOutcomeKind({ userIntent: prompt, buildLike, pentestLike }),
938
1090
  continueExisting: continueExistingOutcome,
939
1091
  });
1092
+ loopGuard.restoreCompletedOperations(outcomeState.completedOperations ?? []);
940
1093
  await saveOutcomeState(outcomeState);
1094
+ // Canonical mutation/artifact ledger feeding the durable compaction envelope.
1095
+ const workLedger = new WorkLedger();
941
1096
  let governorState = createGovernorState();
942
1097
  let governorPauseReason;
943
1098
  let turnState = createTurnState();
@@ -992,10 +1147,54 @@ export async function runAgentTurn(prompt, options = {}) {
992
1147
  return release;
993
1148
  },
994
1149
  };
1150
+ /**
1151
+ * Apply a salvaged partial write through the NORMAL tool path so the
1152
+ * classifier, scope/engagement gates, confirmation prompt, and receipts
1153
+ * all apply exactly as they would for a model-emitted call. Salvage must
1154
+ * never mutate a file with `confirmed: true`, and an `fs.append` that was
1155
+ * cut off must stay an append (with its precondition) instead of becoming
1156
+ * a full overwrite.
1157
+ */
1158
+ async function applySalvagedWrite(salvaged) {
1159
+ const args = {
1160
+ path: salvaged.path,
1161
+ content: salvaged.content,
1162
+ };
1163
+ if (salvaged.operation === "append") {
1164
+ args.position = "end";
1165
+ if (typeof salvaged.expectedPriorBytes === "number") {
1166
+ args.expectedPriorBytes = salvaged.expectedPriorBytes;
1167
+ }
1168
+ }
1169
+ const call = {
1170
+ name: salvaged.operation === "append" ? "fs.append" : "fs.write",
1171
+ args,
1172
+ };
1173
+ const eventId = `tool-${++nextToolEventId}`;
1174
+ const res = await executeSingleTool(call, eventId, options.signal || new AbortController().signal);
1175
+ const ok = res.ok && res.result.ok;
1176
+ let bytesOnDisk = Buffer.byteLength(salvaged.content, "utf8");
1177
+ if (ok) {
1178
+ try {
1179
+ const stats = await stat(resolveFsToolPath(salvaged.path));
1180
+ bytesOnDisk = stats.size;
1181
+ }
1182
+ catch {
1183
+ // Keep the content-length estimate when the file cannot be stat'ed.
1184
+ }
1185
+ }
1186
+ return {
1187
+ ok,
1188
+ cancelled: Boolean(res.blockOrCancel),
1189
+ output: res.result.output,
1190
+ bytesOnDisk,
1191
+ };
1192
+ }
995
1193
  async function executeSingleTool(rawCall, toolEventId, parentSignal) {
996
1194
  const scratchDir = scratchDirFor(safeCwd());
997
1195
  let call = normalizeToolCall(rawCall);
998
1196
  let dispatchedTaskId;
1197
+ let delegation;
999
1198
  let engagementLease;
1000
1199
  let engagementGraph;
1001
1200
  let engagementRecord;
@@ -1016,7 +1215,13 @@ export async function runAgentTurn(prompt, options = {}) {
1016
1215
  return { ok: true, call, result, contextOutput: recoveryText };
1017
1216
  }
1018
1217
  if (narrowNmapOperation) {
1019
- const allowed = new Set(["net.scan", "shell.tail", "shell.jobs"]);
1218
+ const allowed = new Set([
1219
+ "net.scan",
1220
+ "shell.tail",
1221
+ "shell.jobs",
1222
+ "job.read",
1223
+ "task.read",
1224
+ ]);
1020
1225
  if (!allowed.has(call.name)) {
1021
1226
  const reason = `Narrow nmap request: ${call.name} was not run because the user requested only one nmap operation. ` +
1022
1227
  `Call net.scan with the requested target/options; do not create a plan or add DNS, WHOIS, HTTP, recon, or vulnerability steps.`;
@@ -1056,17 +1261,27 @@ export async function runAgentTurn(prompt, options = {}) {
1056
1261
  detail: String(retryReasonRaw.detail ?? ""),
1057
1262
  }
1058
1263
  : undefined;
1059
- // Only block identical *failed* re-tries without changed context.
1060
- // Successful re-calls always run for real — no "already succeeded /
1061
- // use prior results" nags (those caused model abnormalities).
1264
+ const currentProbeState = probeStateKey(call);
1062
1265
  const loopCheck = loopGuard.shouldBlock(call.name, call.args, {
1063
1266
  dependenciesChanged: retryDependenciesChanged,
1064
1267
  environmentChanged: retryEnvironmentChanged,
1268
+ ...(currentProbeState ? { stateKey: currentProbeState } : {}),
1065
1269
  ...(retryReason ? { retryReason } : {}),
1066
1270
  });
1067
1271
  if (loopCheck.block) {
1068
1272
  const reason = loopCheck.reason ??
1069
1273
  `${call.name} previously failed with identical arguments. Change the command/args and retry.`;
1274
+ if (loopCheck.kind === "unchanged-success") {
1275
+ const result = { ok: true, output: reason, exitCode: 0 };
1276
+ emitToolResult(toolEventId, result, reason);
1277
+ return {
1278
+ ok: true,
1279
+ call,
1280
+ result,
1281
+ contextOutput: reason,
1282
+ suppressedRepeat: true,
1283
+ };
1284
+ }
1070
1285
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1071
1286
  const result = { ok: false, output: reason, exitCode: 1 };
1072
1287
  emitToolResult(toolEventId, result, reason);
@@ -1080,29 +1295,63 @@ export async function runAgentTurn(prompt, options = {}) {
1080
1295
  if (call.name === "plan.create" ||
1081
1296
  call.name === "task.add" ||
1082
1297
  call.name === "task.move" ||
1298
+ call.name === "job.read" ||
1083
1299
  call.name === "task.read" ||
1084
1300
  call.name === "task.update") {
1085
- if (call.name === "task.read") {
1086
- const notificationId = typeof call.args.notificationId === "string"
1301
+ if (call.name === "job.read" || call.name === "task.read") {
1302
+ const requestedNotificationId = typeof call.args.notificationId === "string"
1087
1303
  ? call.args.notificationId.trim()
1088
1304
  : "";
1089
- const notification = jobManager
1090
- .getPendingNotifications(session.sessionId)
1091
- .find((candidate) => candidate.id === notificationId);
1092
- const visible = unreadResponderNotificationIds.has(notificationId);
1093
- const marked = Boolean(notification &&
1305
+ const requestedJobId = typeof call.args.jobId === "string" ? call.args.jobId.trim() : "";
1306
+ const pending = jobManager.getPendingNotifications(session.sessionId);
1307
+ const eligible = responderWakeTurn
1308
+ ? pending.filter(matchesWakeRevision)
1309
+ : pending;
1310
+ const byNotification = requestedNotificationId
1311
+ ? eligible.find((candidate) => candidate.id === requestedNotificationId)
1312
+ : undefined;
1313
+ const byJob = requestedJobId
1314
+ ? eligible.find((candidate) => candidate.jobId === requestedJobId)
1315
+ : undefined;
1316
+ const identifiersConflict = Boolean((byNotification && requestedJobId && byNotification.jobId !== requestedJobId) ||
1317
+ (byJob && requestedNotificationId && byJob.id !== requestedNotificationId));
1318
+ const notification = identifiersConflict
1319
+ ? undefined
1320
+ : (byNotification ?? byJob);
1321
+ const visible = Boolean(notification && unreadResponderNotificationIds.has(notification.id));
1322
+ const wakeIdentityMatches = Boolean(responderWakeTurn &&
1323
+ (requestedNotificationId || requestedJobId) &&
1324
+ (!requestedNotificationId ||
1325
+ requestedNotificationId === responderWakeNotificationId) &&
1326
+ (!requestedJobId || requestedJobId === responderWakeJobId));
1327
+ const staleWakeSettled = wakeIdentityMatches && !identifiersConflict && !notification;
1328
+ const persistedRead = Boolean(notification &&
1094
1329
  visible &&
1095
- jobManager.markRead(notificationId, session.sessionId));
1096
- const output = marked
1097
- ? `Responder notification ${notificationId} marked delivered and read after model analysis.`
1098
- : !notificationId
1099
- ? "task.read failed: notificationId is required."
1100
- : !visible
1101
- ? `task.read failed: notification ${notificationId} was not delivered to this model turn. Analyze a delivered result before marking it read.`
1102
- : `task.read failed: notification ${notificationId} is unavailable, archived, or its read state could not be persisted.`;
1103
- if (marked && notification) {
1330
+ jobManager.markRead(notification.id, session.sessionId));
1331
+ const marked = persistedRead || staleWakeSettled;
1332
+ const identifier = requestedJobId || requestedNotificationId;
1333
+ const revisionLabel = responderWakeResultRevision
1334
+ ? ` revision ${responderWakeResultRevision}`
1335
+ : "";
1336
+ const output = persistedRead
1337
+ ? `Responder job ${notification.jobId} (${notification.id}) marked delivered and read after model analysis.`
1338
+ : staleWakeSettled
1339
+ ? `Responder result ${identifier}${revisionLabel} was already settled or discarded; the stale wake is acknowledged idempotently.`
1340
+ : !requestedNotificationId && !requestedJobId
1341
+ ? `${call.name} failed: jobId or notificationId is required.`
1342
+ : identifiersConflict
1343
+ ? `${call.name} failed: jobId and notificationId refer to different Responder results.`
1344
+ : !notification
1345
+ ? `${call.name} failed: Responder result ${identifier} is unavailable, consumed, or archived.`
1346
+ : !visible
1347
+ ? `${call.name} failed: Responder result ${identifier} was not delivered to this model turn. Analyze a delivered result before marking it read.`
1348
+ : `${call.name} failed: read state for Responder result ${identifier} could not be persisted.`;
1349
+ if (persistedRead && notification) {
1104
1350
  upsertResponderResultLedger(messages, notification);
1105
- unreadResponderNotificationIds.delete(notificationId);
1351
+ unreadResponderNotificationIds.delete(notification.id);
1352
+ }
1353
+ else if (staleWakeSettled && responderWakeNotificationId) {
1354
+ unreadResponderNotificationIds.delete(responderWakeNotificationId);
1106
1355
  }
1107
1356
  if (!alreadyPrintedIds.has(toolEventId)) {
1108
1357
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
@@ -1219,7 +1468,7 @@ export async function runAgentTurn(prompt, options = {}) {
1219
1468
  taskWorkLedger = led;
1220
1469
  if (planResult.plan && led && led.successWorkCount > 0 && persisted) {
1221
1470
  persisted.evidence = taskEvidenceFromLedger(led);
1222
- await savePlan(planResult.plan).catch(() => undefined);
1471
+ await persistTaskEvidence(persisted.id, persisted.evidence);
1223
1472
  }
1224
1473
  }
1225
1474
  else if (stateRaw === "done" && resolved) {
@@ -1228,7 +1477,7 @@ export async function runAgentTurn(prompt, options = {}) {
1228
1477
  const t = planResult.plan.tasks.find((x) => x.id === resolved);
1229
1478
  if (t) {
1230
1479
  t.evidence = taskEvidenceFromLedger(taskWorkLedger);
1231
- await savePlan(planResult.plan).catch(() => undefined);
1480
+ await persistTaskEvidence(t.id, t.evidence);
1232
1481
  }
1233
1482
  }
1234
1483
  taskWorkLedger = null;
@@ -1357,7 +1606,18 @@ export async function runAgentTurn(prompt, options = {}) {
1357
1606
  livePlanForGate.status === "approved") {
1358
1607
  livePlanForGate.status = "in_progress";
1359
1608
  }
1360
- await savePlan(livePlanForGate).catch(() => undefined);
1609
+ // Opening a task is a transition applied by
1610
+ // the reducer, which also enforces the single-active invariant.
1611
+ await mutatePlan(session.sessionId, (draft) => {
1612
+ const target = draft.tasks.find((candidate) => candidate.id === nextPending.id);
1613
+ if (!target || target.state === "in_progress")
1614
+ return false;
1615
+ target.state = "in_progress";
1616
+ if (draft.status === "draft" || draft.status === "approved") {
1617
+ draft.status = "in_progress";
1618
+ }
1619
+ return true;
1620
+ }).catch(() => undefined);
1361
1621
  // Preserve evidence already credited to this task (e.g. pentest
1362
1622
  // recon that ran before the task was formally opened).
1363
1623
  if (!taskWorkLedger ||
@@ -1415,22 +1675,29 @@ export async function runAgentTurn(prompt, options = {}) {
1415
1675
  alreadyPrintedIds.add(toolEventId);
1416
1676
  }
1417
1677
  const scopeTarget = safeScopeTargetForToolCall(call);
1418
- const engagementAction = pentestSession || isPentestToolCall(call) || Boolean(scope)
1419
- ? safeEngagementActionForToolCall(call)
1420
- : undefined;
1421
- const engagementDecision = engagementAction
1422
- ? evaluateEngagementAction(scope, engagementAction)
1423
- : undefined;
1424
- if (engagementAction && engagementDecision) {
1678
+ const engagementActions = pentestSession || isPentestToolCall(call) || Boolean(scope)
1679
+ ? safeEngagementActionsForToolCall(call)
1680
+ : [];
1681
+ // The primary action carries the URL/port/path detail used for leases and
1682
+ // network-hop authorization; every action (one per named target) must pass
1683
+ // the scope check below before the tool runs.
1684
+ const engagementAction = engagementActions[0];
1685
+ let engagementDecision;
1686
+ for (const action of engagementActions) {
1687
+ const decisionForAction = evaluateEngagementAction(scope, action);
1688
+ if (!decisionForAction)
1689
+ continue;
1690
+ if (action === engagementAction)
1691
+ engagementDecision = decisionForAction;
1425
1692
  if (scope) {
1426
1693
  engagementGraph = await openEngagement(scope);
1427
1694
  engagementRecord = beginEngagementAction(engagementGraph, {
1428
1695
  tool: call.name,
1429
- target: engagementDecision.normalizedTarget || engagementAction.target,
1430
- phase: engagementDecision.phase,
1431
- capability: engagementDecision.capability,
1432
- authorized: engagementDecision.allowed,
1433
- reason: engagementDecision.reason,
1696
+ target: decisionForAction.normalizedTarget || action.target,
1697
+ phase: decisionForAction.phase,
1698
+ capability: decisionForAction.capability,
1699
+ authorized: decisionForAction.allowed,
1700
+ reason: decisionForAction.reason,
1434
1701
  });
1435
1702
  await saveEngagement(engagementGraph);
1436
1703
  }
@@ -1438,16 +1705,22 @@ export async function runAgentTurn(prompt, options = {}) {
1438
1705
  ...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
1439
1706
  ...(engagementRecord ? { actionId: engagementRecord.id } : {}),
1440
1707
  tool: call.name,
1441
- target: engagementDecision.normalizedTarget,
1442
- phase: engagementDecision.phase,
1443
- capability: engagementDecision.capability,
1444
- allowed: engagementDecision.allowed,
1445
- reason: engagementDecision.reason,
1708
+ target: decisionForAction.normalizedTarget,
1709
+ phase: decisionForAction.phase,
1710
+ capability: decisionForAction.capability,
1711
+ allowed: decisionForAction.allowed,
1712
+ reason: decisionForAction.reason,
1446
1713
  });
1447
- if (!engagementDecision.allowed) {
1448
- const target = engagementDecision.normalizedTarget || scopeTarget || engagementAction.target;
1449
- const reason = `Blocked engagement action for ${target}: ${engagementDecision.reason}. ` +
1450
- scopeHint(target);
1714
+ if (!decisionForAction.allowed) {
1715
+ const target = decisionForAction.normalizedTarget ||
1716
+ action.target ||
1717
+ scopeTarget ||
1718
+ "requested target";
1719
+ const reason = outOfScopeToolMessage({
1720
+ target,
1721
+ reason: decisionForAction.reason,
1722
+ allowed: scope?.authorizedTargets,
1723
+ });
1451
1724
  writeToolBlocked(toolEventId, call.name, reason, chalk.red(` ✗ ${reason}\n`));
1452
1725
  const result = { ok: false, output: reason, exitCode: 1 };
1453
1726
  emitToolResult(toolEventId, result, reason);
@@ -1554,6 +1827,53 @@ export async function runAgentTurn(prompt, options = {}) {
1554
1827
  const candidate = pickPendingTaskForToolCall(readyPlanTasks(planAtDispatch), call, planAtDispatch.tasks.map((task) => task.title));
1555
1828
  dispatchedTaskId = candidate?.id;
1556
1829
  }
1830
+ // An explicitly declared responder parent wins over inference.
1831
+ const declaredParent = readDeclaredParentTaskId(call);
1832
+ if (declaredParent) {
1833
+ const resolvedParent = resolveResponderParent({
1834
+ plan: planAtDispatch,
1835
+ declared: declaredParent,
1836
+ activeForegroundTaskIds: dispatchedTaskId ? [dispatchedTaskId] : [],
1837
+ });
1838
+ if (!resolvedParent.ok) {
1839
+ const reason = `${call.name} failed: ${resolvedParent.reason}`;
1840
+ const result = { ok: false, output: reason, exitCode: 1 };
1841
+ emitToolResult(toolEventId, result, reason);
1842
+ return { ok: false, call, result, contextOutput: reason };
1843
+ }
1844
+ dispatchedTaskId = resolvedParent.taskId ?? dispatchedTaskId;
1845
+ }
1846
+ // For an explicit delegation, create the child subtask before the
1847
+ // Process starts and launch the job already bound to it. The job therefore
1848
+ // Never carries the foreground parent in `taskId`, and settlement has a
1849
+ // Durable row to advance even if this turn dies right after spawn.
1850
+ if (isExplicitResponderDelegation(call) && planAtDispatch) {
1851
+ delegation = { id: `dg-${randomUUID().slice(0, 8)}` };
1852
+ const created = await mutatePlan(session.sessionId, (draft) => {
1853
+ const parentExists = !!dispatchedTaskId &&
1854
+ draft.tasks.some((task) => task.id === dispatchedTaskId);
1855
+ const child = appendPlanTask(draft, {
1856
+ title: delegationTaskTitle(call),
1857
+ state: "in_progress",
1858
+ note: `delegation=${delegation.id} awaiting launch`,
1859
+ dependencies: [],
1860
+ resourceLocks: [],
1861
+ ...(parentExists ? { parentTaskId: dispatchedTaskId } : {}),
1862
+ responderOwned: true,
1863
+ delegationId: delegation.id,
1864
+ });
1865
+ delegation.taskId = child.id;
1866
+ return true;
1867
+ }).catch(() => undefined);
1868
+ if (!created?.ok || !delegation.taskId) {
1869
+ delegation = undefined;
1870
+ writeNotice("warn", "Responder delegation record could not be persisted — the job will be linked after launch", chalk.yellow(" ⚠ responder delegation record not persisted; linking after launch\n"));
1871
+ }
1872
+ else if (created.plan) {
1873
+ pendingSessionStatePlan = created.plan;
1874
+ writePlanUpdate(created.plan, renderPlanForTerminal(created.plan) + "\n");
1875
+ }
1876
+ }
1557
1877
  if (dispatchedTaskId &&
1558
1878
  (!taskWorkLedger || taskWorkLedger.taskId !== dispatchedTaskId)) {
1559
1879
  const dispatchedTask = planAtDispatch?.tasks.find((task) => task.id === dispatchedTaskId);
@@ -1562,7 +1882,12 @@ export async function runAgentTurn(prompt, options = {}) {
1562
1882
  if (engagementAction) {
1563
1883
  engagementLease = engagementPolicy.acquire(scope, engagementAction);
1564
1884
  if (!engagementLease.decision.allowed) {
1565
- const reason = `Blocked engagement action: ${engagementLease.decision.reason}`;
1885
+ const target = engagementLease.decision.normalizedTarget || engagementAction.target;
1886
+ const reason = outOfScopeToolMessage({
1887
+ target,
1888
+ reason: engagementLease.decision.reason,
1889
+ allowed: scope?.authorizedTargets,
1890
+ });
1566
1891
  const result = { ok: false, output: reason, exitCode: 1 };
1567
1892
  emitToolResult(toolEventId, result, reason);
1568
1893
  return { ok: false, call, result, contextOutput: reason };
@@ -1664,7 +1989,9 @@ export async function runAgentTurn(prompt, options = {}) {
1664
1989
  confirmed: true,
1665
1990
  userPrompt: prompt,
1666
1991
  sessionId: session.sessionId,
1667
- ...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
1992
+ ...(delegation?.taskId ? { taskId: delegation.taskId } : {}),
1993
+ ...(delegation ? { delegationId: delegation.id } : {}),
1994
+ ...(dispatchedTaskId ? { parentTaskId: dispatchedTaskId } : {}),
1668
1995
  wakeOnCompletion: true,
1669
1996
  monitor: {
1670
1997
  toolName: call.name,
@@ -1775,13 +2102,18 @@ export async function runAgentTurn(prompt, options = {}) {
1775
2102
  // User Esc/Ctrl+C: force-settle may resolve with a cancel result
1776
2103
  // instead of throwing — still end the turn as aborted.
1777
2104
  if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
1778
- writeAbort();
2105
+ const result = {
2106
+ ok: false,
2107
+ output: "Cancelled by user.",
2108
+ exitCode: 130,
2109
+ };
2110
+ emitToolResult(toolEventId, result, result.output);
1779
2111
  return {
1780
2112
  ok: false,
1781
2113
  call,
1782
- result: { ok: false, output: "Aborted." },
1783
- contextOutput: "Aborted.",
1784
- lastAnswer: "Aborted.",
2114
+ result,
2115
+ contextOutput: result.output,
2116
+ aborted: true,
1785
2117
  };
1786
2118
  }
1787
2119
  if (liveBytes > 0) {
@@ -1793,13 +2125,18 @@ export async function runAgentTurn(prompt, options = {}) {
1793
2125
  jobManager.updateJobStatus(jobId, "failed", 1);
1794
2126
  if (isAbortError(toolError, toolAc.signal) || forceSettled) {
1795
2127
  if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
1796
- writeAbort();
2128
+ const result = {
2129
+ ok: false,
2130
+ output: "Cancelled by user.",
2131
+ exitCode: 130,
2132
+ };
2133
+ emitToolResult(toolEventId, result, result.output);
1797
2134
  return {
1798
2135
  ok: false,
1799
2136
  call,
1800
- result: { ok: false, output: "Aborted." },
1801
- contextOutput: "Aborted.",
1802
- lastAnswer: "Aborted.",
2137
+ result,
2138
+ contextOutput: result.output,
2139
+ aborted: true,
1803
2140
  };
1804
2141
  }
1805
2142
  result = {
@@ -1903,6 +2240,27 @@ export async function runAgentTurn(prompt, options = {}) {
1903
2240
  writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1904
2241
  }
1905
2242
  }
2243
+ if (delegation?.taskId && !result.backgroundJob) {
2244
+ // The delegation never became a durable job: settle its child instead of
2245
+ // Leaving a permanently yellow subtask behind.
2246
+ const delegationId = delegation.id;
2247
+ const settledState = result.ok ? "skipped" : "failed";
2248
+ const settlement = await mutatePlan(session.sessionId, (draft) => {
2249
+ const child = draft.tasks.find((task) => task.delegationId === delegationId);
2250
+ if (!child)
2251
+ return false;
2252
+ child.state = settledState;
2253
+ child.note = result.ok
2254
+ ? `delegation=${delegationId} ran in the foreground; no durable job was created`
2255
+ : `delegation=${delegationId} failed to launch`;
2256
+ return true;
2257
+ }).catch(() => undefined);
2258
+ if (settlement?.ok && settlement.plan) {
2259
+ pendingSessionStatePlan = settlement.plan;
2260
+ writePlanUpdate(settlement.plan, renderPlanForTerminal(settlement.plan) + "\n");
2261
+ }
2262
+ delegation = undefined;
2263
+ }
1906
2264
  if (result.backgroundJob) {
1907
2265
  const durableJob = jobManager.getJob(result.backgroundJob.id);
1908
2266
  // Responder linkage is opt-in: only jobs launched with responder:true
@@ -1910,9 +2268,10 @@ export async function runAgentTurn(prompt, options = {}) {
1910
2268
  // Plain background jobs stay pollable (shell.jobs/shell.tail) as before.
1911
2269
  if (durableJob?.responder) {
1912
2270
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1913
- let linkedTaskId = dispatchedTaskId;
2271
+ let linkedTaskId = delegation?.taskId;
1914
2272
  let linkedParentTaskId;
1915
2273
  let responderTaskId;
2274
+ let responderChildId;
1916
2275
  if (durableJob && livePlan) {
1917
2276
  const existing = livePlan.tasks.find((task) => task.jobId === durableJob.id);
1918
2277
  const parentTaskId = dispatchedTaskId && livePlan.tasks.some((task) => task.id === dispatchedTaskId)
@@ -1927,46 +2286,64 @@ export async function runAgentTurn(prompt, options = {}) {
1927
2286
  : "in_progress";
1928
2287
  const note = `job=${durableJob.id} pid=${durableJob.pid ?? "?"} status=${durableJob.status} ` +
1929
2288
  `artifact=${durableJob.stdoutArtifact}`;
1930
- const responderTask = existing ?? appendPlanTask(livePlan, {
1931
- title: `Responder · ${durableJob.name ?? durableJob.commandDisplay.slice(0, 96)}`,
1932
- state: terminalState,
1933
- note,
1934
- dependencies: [],
1935
- resourceLocks: [],
1936
- parentTaskId,
1937
- jobId: durableJob.id,
1938
- processId: durableJob.pid,
1939
- responderOwned: true,
1940
- });
1941
- responderTask.state = terminalState;
1942
- responderTask.note = note;
1943
- responderTask.jobId = durableJob.id;
1944
- responderTask.processId = durableJob.pid;
1945
- responderTask.responderOwned = true;
1946
- if (parentTaskId)
1947
- responderTask.parentTaskId = parentTaskId;
1948
- if (isPlanTerminal(livePlan)) {
1949
- livePlan.status = isPlanSuccessful(livePlan)
1950
- ? "completed"
1951
- : "abandoned";
1952
- }
1953
- else if (livePlan.status !== "draft") {
1954
- livePlan.status = "in_progress";
1955
- }
1956
- let responderPlanSaved = false;
1957
- try {
1958
- await savePlan(livePlan);
1959
- responderPlanSaved = true;
1960
- }
1961
- catch {
2289
+ const responderTitle = `Responder · ${durableJob.name ?? durableJob.commandDisplay.slice(0, 96)}`;
2290
+ // Upsert the child by delegation/job identity
2291
+ // inside the transactional boundary. A concurrent settlement that
2292
+ // already turned the child green is therefore never reverted, and the
2293
+ // child is never written as the foreground parent.
2294
+ const upsert = await mutatePlan(session.sessionId, (draft) => {
2295
+ const target = (durableJob.delegationId
2296
+ ? draft.tasks.find((task) => task.delegationId === durableJob.delegationId)
2297
+ : undefined) ??
2298
+ draft.tasks.find((task) => task.jobId === durableJob.id);
2299
+ const child = target ??
2300
+ appendPlanTask(draft, {
2301
+ title: responderTitle,
2302
+ state: terminalState,
2303
+ note,
2304
+ dependencies: [],
2305
+ resourceLocks: [],
2306
+ parentTaskId,
2307
+ jobId: durableJob.id,
2308
+ processId: durableJob.pid,
2309
+ responderOwned: true,
2310
+ ...(durableJob.delegationId
2311
+ ? { delegationId: durableJob.delegationId }
2312
+ : {}),
2313
+ });
2314
+ // Never regress a child that process settlement already finished.
2315
+ const settledTerminal = child.state === "done" || child.state === "failed";
2316
+ if (!settledTerminal) {
2317
+ child.state = terminalState;
2318
+ child.note = note;
2319
+ }
2320
+ child.jobId = durableJob.id;
2321
+ child.processId = durableJob.pid;
2322
+ child.responderOwned = true;
2323
+ if (durableJob.delegationId) {
2324
+ child.delegationId = durableJob.delegationId;
2325
+ }
2326
+ if (parentTaskId)
2327
+ child.parentTaskId = parentTaskId;
2328
+ if (isPlanTerminal(draft)) {
2329
+ draft.status = isPlanSuccessful(draft) ? "completed" : "abandoned";
2330
+ }
2331
+ else if (draft.status !== "draft") {
2332
+ draft.status = "in_progress";
2333
+ }
2334
+ responderChildId = child.id;
2335
+ return true;
2336
+ }).catch(() => undefined);
2337
+ if (!upsert?.ok || !responderChildId) {
1962
2338
  writeNotice("warn", `Responder job ${durableJob.id} started, but its plan subtask could not be persisted`, chalk.yellow(` ⚠ job ${durableJob.id} is running, but task linkage persistence failed\n`));
1963
2339
  }
1964
- if (responderPlanSaved) {
1965
- linkedTaskId = responderTask.id;
2340
+ else {
2341
+ linkedTaskId = responderChildId;
1966
2342
  linkedParentTaskId = parentTaskId;
1967
- responderTaskId = responderTask.id;
1968
- pendingSessionStatePlan = livePlan;
1969
- writePlanUpdate(livePlan, renderPlanForTerminal(livePlan) + "\n");
2343
+ responderTaskId = responderChildId;
2344
+ pendingSessionStatePlan = upsert.plan ?? livePlan;
2345
+ const rendered = upsert.plan ?? livePlan;
2346
+ writePlanUpdate(rendered, renderPlanForTerminal(rendered) + "\n");
1970
2347
  }
1971
2348
  }
1972
2349
  if (durableJob) {
@@ -2051,13 +2428,17 @@ export async function runAgentTurn(prompt, options = {}) {
2051
2428
  }
2052
2429
  await saveEngagement(engagementGraph);
2053
2430
  }
2431
+ workLedger.recordToolCall(call, result.ok, savedOutputPath);
2432
+ const completedProbeState = probeStateKey(call);
2054
2433
  const newEvidence = recordToolEvidence(outcomeState, {
2055
2434
  tool: call.name,
2056
2435
  callId: toolEventId,
2057
2436
  ok: result.ok,
2437
+ ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}),
2058
2438
  output: result.output,
2059
2439
  ...(savedOutputPath ? { artifact: savedOutputPath } : {}),
2060
2440
  ...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
2441
+ ...(completedProbeState ? { stateKey: completedProbeState } : {}),
2061
2442
  args: call.args,
2062
2443
  });
2063
2444
  let hypothesisDelta = 0;
@@ -2130,7 +2511,7 @@ export async function runAgentTurn(prompt, options = {}) {
2130
2511
  }
2131
2512
  }
2132
2513
  await saveOutcomeState(outcomeState);
2133
- loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode, result.output);
2514
+ loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode, result.output, completedProbeState ? { stateKey: completedProbeState } : undefined);
2134
2515
  // Evidence for verify-before-done: only successful real work counts.
2135
2516
  if (result.ok && isEvidenceWorkTool(call.name)) {
2136
2517
  const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
@@ -2202,7 +2583,7 @@ export async function runAgentTurn(prompt, options = {}) {
2202
2583
  taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
2203
2584
  taskWorkLedger = absorbed;
2204
2585
  }
2205
- await savePlan(liveAfter).catch(() => undefined);
2586
+ await persistTaskEvidence(task.id, task.evidence);
2206
2587
  }
2207
2588
  }
2208
2589
  }
@@ -2211,7 +2592,7 @@ export async function runAgentTurn(prompt, options = {}) {
2211
2592
  const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
2212
2593
  if (task) {
2213
2594
  task.evidence = taskEvidenceFromLedger(taskWorkLedger);
2214
- await savePlan(liveAfter).catch(() => undefined);
2595
+ await persistTaskEvidence(task.id, task.evidence);
2215
2596
  }
2216
2597
  }
2217
2598
  // Do NOT refreshSessionState here. executeSingleTool often finishes
@@ -2279,6 +2660,8 @@ export async function runAgentTurn(prompt, options = {}) {
2279
2660
  // Align with /compact default: small recency + dense memory (not keepRecent=6 fat tails).
2280
2661
  const AUTO_COMPACT_KEEP_RECENT = 2;
2281
2662
  let lastCompactionMsgCount = 0;
2663
+ const compactionAttempts = new CompactionAttemptLedger();
2664
+ let activeCompactionId;
2282
2665
  /** E5: identical tool bodies within this turn → pointer instead of re-append. */
2283
2666
  const toolResultHashes = new Map();
2284
2667
  /** E4: consecutive free-tier stream failures this turn. */
@@ -2288,8 +2671,13 @@ export async function runAgentTurn(prompt, options = {}) {
2288
2671
  // once per turn — the recovery planner already narrates each retry, so
2289
2672
  // repeating this on every failure just adds noise.
2290
2673
  let freeTierAdvisoryShown = false;
2291
- const summarizeForCompaction = async (summaryPrompt) => {
2292
- const response = await completeWithProvider({
2674
+ const summarizeForCompaction = async (summaryPrompt, stage) => {
2675
+ const streamFinalSummary = stage?.phase !== "map";
2676
+ const compactionId = streamFinalSummary ? activeCompactionId : undefined;
2677
+ const deltaParser = compactionId
2678
+ ? createThinkingStreamParser((text) => writeCompactionDelta(compactionId, text), undefined, { remember: false })
2679
+ : undefined;
2680
+ const request = {
2293
2681
  provider,
2294
2682
  model,
2295
2683
  messages: [
@@ -2297,11 +2685,32 @@ export async function runAgentTurn(prompt, options = {}) {
2297
2685
  { role: "user", content: summaryPrompt },
2298
2686
  ],
2299
2687
  temperature: 0.1,
2300
- // Prefer dense memory; enough room for findings without forcing a tiny stub.
2301
2688
  maxTokens: 2_048,
2302
2689
  signal: options.signal,
2690
+ };
2691
+ const response = await streamWithProvider(request, (token) => deltaParser?.push(token), { onStatus: () => undefined });
2692
+ deltaParser?.finish();
2693
+ const parsed = stripThinking(response.text);
2694
+ if (parsed.visible.trim() || !parsed.hasThinking)
2695
+ return response.text;
2696
+ const retry = await completeWithProvider({
2697
+ ...request,
2698
+ messages: [
2699
+ {
2700
+ role: "system",
2701
+ content: `${COMPACTION_SYSTEM_PROMPT}\nReturn only the continuation-memory summary. Do not include analysis, reasoning, or <think> tags.`,
2702
+ },
2703
+ { role: "user", content: summaryPrompt },
2704
+ ],
2705
+ temperature: 0,
2706
+ maxTokens: 8_192,
2707
+ thinking: { enabled: false, effort: "none" },
2303
2708
  });
2304
- return response.text;
2709
+ const retryVisible = stripThinking(retry.text).visible.trim();
2710
+ if (retryVisible && compactionId) {
2711
+ writeCompactionDelta(compactionId, retryVisible);
2712
+ }
2713
+ return retry.text;
2305
2714
  };
2306
2715
  /**
2307
2716
  * Estimate the complete next model request, including attached native-tool
@@ -2314,20 +2723,85 @@ export async function runAgentTurn(prompt, options = {}) {
2314
2723
  const nextTools = selectToolDefs(native, useCompactSystemPrompt);
2315
2724
  return buildContextBreakdown(contextMessages, nextTools).estimatedTotalTokens;
2316
2725
  };
2726
+ /**
2727
+ * Canonical state that must survive compaction verbatim. Built from the
2728
+ * plan store, outcome contract, responder ledger and mutation ledger — never
2729
+ * from the narrative summary.
2730
+ */
2731
+ async function buildTurnDurableEnvelope() {
2732
+ const plan = (await loadPlan(session.sessionId).catch(() => undefined)) ?? undefined;
2733
+ const root = getActiveProjectRoot() ?? plan?.meta?.projectRoot;
2734
+ const consumed = [];
2735
+ for (const message of messages) {
2736
+ if (!isResponderResultLedgerMessage(message))
2737
+ continue;
2738
+ for (const line of message.content.split("\n")) {
2739
+ const match = /notification=(\S+)/.exec(line);
2740
+ if (match?.[1])
2741
+ consumed.push(match[1]);
2742
+ }
2743
+ }
2744
+ const unread = jobManager
2745
+ .getPendingNotifications(session.sessionId)
2746
+ .map((notification) => notification.id);
2747
+ const toEnvelopeJob = (job) => ({
2748
+ id: job.id,
2749
+ status: job.status,
2750
+ command: job.commandDisplay || job.command,
2751
+ ...(job.taskId ? { taskId: job.taskId } : {}),
2752
+ ...(job.stdoutArtifact ? { artifact: job.stdoutArtifact } : {}),
2753
+ });
2754
+ const liveJobs = jobManager
2755
+ .getRunningJobs(session.sessionId)
2756
+ .map(toEnvelopeJob);
2757
+ const liveIds = new Set(liveJobs.map((job) => job.id));
2758
+ const finishedJobs = jobManager
2759
+ .getRecentJobs(12, session.sessionId)
2760
+ .filter((job) => !liveIds.has(job.id))
2761
+ .map(toEnvelopeJob);
2762
+ return buildDurableEnvelope({
2763
+ ...(plan ? { plan } : {}),
2764
+ outcome: outcomeState,
2765
+ ledger: workLedger,
2766
+ ...(root ? { projectRoot: root } : {}),
2767
+ ...(root
2768
+ ? { packageManager: plan?.meta?.packageManager ?? detectPackageManager(root) }
2769
+ : {}),
2770
+ responder: { unread, consumed: [...new Set(consumed)] },
2771
+ ...(liveJobs.length > 0 ? { liveJobs } : {}),
2772
+ ...(finishedJobs.length > 0 ? { finishedJobs } : {}),
2773
+ });
2774
+ }
2317
2775
  async function maybeAutoCompact(reason, force = false) {
2318
2776
  const beforeTokens = estimateNextRequestTokens(messages);
2319
- // E1: auto-compact trigger (default 72k soft; hard ceiling 100k).
2320
- const compactTrigger = autoCompactTriggerTokens();
2777
+ const budget = resolveRequestBudget({ provider, model });
2778
+ const compactTrigger = budget.effectiveTrigger;
2321
2779
  if (!force && beforeTokens < compactTrigger)
2322
2780
  return;
2781
+ // Structural eligibility only: there must be closed history to summarize.
2323
2782
  if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
2324
2783
  return;
2325
- // Avoid compaction loops: don't re-compact until enough new messages have
2326
- // accumulated since the last compaction.
2327
- if (messages.length <= lastCompactionMsgCount + 4)
2784
+ const durableEnvelope = await buildTurnDurableEnvelope();
2785
+ const attemptKey = compactionAttemptKey({
2786
+ messages,
2787
+ provider,
2788
+ model,
2789
+ dialect: toolDialect,
2790
+ triggerTokens: compactTrigger,
2791
+ schemaHash: toolSchemaHash(selectToolDefs(nativeToolsActive, useCompactSystemPrompt)),
2792
+ ...(durableEnvelope ? { durableEnvelope } : {}),
2793
+ });
2794
+ if (!force && compactionAttempts.isSuppressed(attemptKey))
2328
2795
  return;
2796
+ const compactionId = `compact-${randomUUID().slice(0, 12)}`;
2797
+ activeCompactionId = compactionId;
2798
+ writeCompactionStarted(compactionId, beforeTokens);
2329
2799
  try {
2330
- const result = await compactMessagesWithSummary(messages, summarizeForCompaction, { budgetTokens: 0, keepRecent: AUTO_COMPACT_KEEP_RECENT });
2800
+ const result = await compactMessagesWithSummary(messages, summarizeForCompaction, {
2801
+ budgetTokens: 0,
2802
+ keepRecent: AUTO_COMPACT_KEEP_RECENT,
2803
+ ...(durableEnvelope ? { durableEnvelope } : {}),
2804
+ });
2331
2805
  const summaryBody = result.messages.find((m) => isCompactionMemoryMessage(m))?.content ??
2332
2806
  "";
2333
2807
  if (!shouldApplyAutoCompact({
@@ -2337,9 +2811,27 @@ export async function runAgentTurn(prompt, options = {}) {
2337
2811
  afterTokens: result.afterTokens,
2338
2812
  afterMessages: result.messages,
2339
2813
  })) {
2814
+ writeCompactionFailed(compactionId, "The generated summary was not accepted; the original context was retained.", beforeTokens);
2815
+ return;
2816
+ }
2817
+ // otherwise the oversized request is sent anyway and corrective
2818
+ // compaction is suppressed as "already compacted".
2819
+ const candidateTokens = estimateNextRequestTokens(result.messages);
2820
+ if (!force && candidateTokens >= compactTrigger) {
2821
+ const dominant = describeDominantContextBlock(result.messages);
2822
+ compactionAttempts.recordFailure(attemptKey);
2823
+ await auditLog("agent.compact.overflow", {
2824
+ reason,
2825
+ candidateTokens,
2826
+ trigger: compactTrigger,
2827
+ dominant,
2828
+ });
2829
+ writeNotice("warn", `context is still ~${candidateTokens.toLocaleString()} tokens after compaction (limit ~${compactTrigger.toLocaleString()}) — largest block: ${dominant}`, chalk.yellow(` ⚠ context still ~${candidateTokens.toLocaleString()} tokens after compaction; largest block: ${dominant}\n`));
2830
+ writeCompactionFailed(compactionId, `Summary remained over the context limit; largest block: ${dominant}.`, beforeTokens);
2340
2831
  return;
2341
2832
  }
2342
2833
  messages.splice(0, messages.length, ...result.messages);
2834
+ compactionAttempts.recordSuccess(attemptKey);
2343
2835
  loopGuard.resetReadOnly();
2344
2836
  // Token stats use the same complete request estimate as the trigger.
2345
2837
  const compactedTokens = estimateNextRequestTokens(messages);
@@ -2347,10 +2839,7 @@ export async function runAgentTurn(prompt, options = {}) {
2347
2839
  // after older turns (which carried the plan context) were summarized.
2348
2840
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2349
2841
  if (livePlan) {
2350
- messages.push({
2351
- role: "system",
2352
- content: planContextMessage(livePlan, session.planApproved.value),
2353
- });
2842
+ upsertPlanContextMessage(messages, planContextMessage(livePlan, session.planApproved.value));
2354
2843
  }
2355
2844
  // Re-inject live SESSION STATE after compaction (older flags survive).
2356
2845
  refreshSessionState(livePlan);
@@ -2371,22 +2860,25 @@ export async function runAgentTurn(prompt, options = {}) {
2371
2860
  ? PLAN_IMPLEMENT_MEMORY_PREFIX
2372
2861
  : COMPACTION_MEMORY_PREFIX);
2373
2862
  // Card shows pre/post of the summarization; plan re-injection is noted.
2374
- writeCompacted(summaryText, beforeTokens, compactedTokens);
2863
+ writeCompactionCompleted(compactionId, summaryText, beforeTokens, compactedTokens);
2375
2864
  const planNote = afterTokens > compactedTokens
2376
2865
  ? ` (compacted to ~${compactedTokens.toLocaleString()}, +plan → ~${afterTokens.toLocaleString()})`
2377
2866
  : "";
2378
2867
  writeNotice("info", `context auto-compacted to fit the window (~${beforeTokens.toLocaleString()} → ~${compactedTokens.toLocaleString()} tokens${planNote})`, chalk.dim(` ℹ context auto-compacted (~${beforeTokens.toLocaleString()} → ~${compactedTokens.toLocaleString()} tokens${planNote})\n`));
2379
2868
  }
2380
2869
  catch (error) {
2870
+ const message = error instanceof Error ? error.message : String(error);
2871
+ writeCompactionFailed(compactionId, /aborted/i.test(message) ? "Compaction was cancelled." : message, beforeTokens);
2381
2872
  if (error instanceof Error &&
2382
2873
  (error.name === "AbortError" || error.message.includes("aborted"))) {
2383
2874
  throw error;
2384
2875
  }
2385
- // Summarization failed — DO NOT fall back to a mechanical dump. Keep the
2386
- // current context and continue; we'll try again as it keeps growing.
2387
- await auditLog("agent.compact.failed", {
2388
- reason: error instanceof Error ? error.message : String(error),
2389
- });
2876
+ compactionAttempts.recordFailure(attemptKey);
2877
+ await auditLog("agent.compact.failed", { reason: message });
2878
+ }
2879
+ finally {
2880
+ if (activeCompactionId === compactionId)
2881
+ activeCompactionId = undefined;
2390
2882
  }
2391
2883
  }
2392
2884
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
@@ -2549,16 +3041,17 @@ export async function runAgentTurn(prompt, options = {}) {
2549
3041
  });
2550
3042
  try {
2551
3043
  if (responderDelivery &&
2552
- !jobManager.markDelivered(responderDelivery.id)) {
3044
+ !jobManager.markDeliveryStarted(responderDelivery.id, session.sessionId)) {
2553
3045
  jobManager.releaseResponderNotificationClaim(responderDelivery.id);
2554
- throw new Error(`failed to mark responder notification ${responderDelivery.id} delivered`);
3046
+ throw new Error(`failed to record responder delivery attempt ${responderDelivery.id}`);
2555
3047
  }
2556
3048
  completion = await streamWithProvider({
2557
3049
  provider,
2558
3050
  model,
2559
3051
  allowModelFallback,
2560
3052
  messages,
2561
- temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
3053
+ // Sampling is provider/model policy (llm/sampling.ts).
3054
+ // Sending a fixed 0.2 here overrode it for every model.
2562
3055
  maxTokens: stepMaxTokens,
2563
3056
  signal: options.signal,
2564
3057
  thinking: retryWithoutThinking
@@ -2737,6 +3230,13 @@ export async function runAgentTurn(prompt, options = {}) {
2737
3230
  throw streamError;
2738
3231
  }
2739
3232
  recordRecoveryAttempt(recoveryState, failureKind);
3233
+ // The router refuses transparent retries after emission,
3234
+ // but the recovery ladder may still re-run the step. Say so, since
3235
+ // the visible answer restarts from scratch.
3236
+ if (streamAlreadyEmitted(streamError)) {
3237
+ const restartNotice = "partial answer discarded after a mid-stream failure — the reply restarts below";
3238
+ writeNotice("warn", restartNotice, chalk.yellow(` ⚠ ${restartNotice}\n`));
3239
+ }
2740
3240
  if (plan.notice) {
2741
3241
  writeNotice("warn", plan.notice, chalk.yellow(` ⚠ ${plan.notice}\n`));
2742
3242
  }
@@ -2764,6 +3264,14 @@ export async function runAgentTurn(prompt, options = {}) {
2764
3264
  // Always clear the spinner — abort, network error, or success.
2765
3265
  spinner.stop();
2766
3266
  }
3267
+ if (responderDelivery) {
3268
+ // The result text is now part of this turn, so consumption is durable.
3269
+ // A stream that aborted or threw above never reaches this point and the
3270
+ // receipt stays deliverable.
3271
+ if (!jobManager.markDelivered(responderDelivery.id, session.sessionId)) {
3272
+ jobManager.releaseResponderNotificationClaim(responderDelivery.id);
3273
+ }
3274
+ }
2767
3275
  provider = completion.provider;
2768
3276
  model = completion.model;
2769
3277
  if (completion.usage) {
@@ -2772,6 +3280,22 @@ export async function runAgentTurn(prompt, options = {}) {
2772
3280
  usage: completion.usage,
2773
3281
  model: completion.model,
2774
3282
  });
3283
+ // Cache telemetry: without read/create counts there is no way to tell
3284
+ // whether the stable prefix is actually being reused.
3285
+ const cacheRead = completion.usage.cachedPromptTokens ?? 0;
3286
+ const cacheCreated = completion.usage.cacheCreationTokens ?? 0;
3287
+ if (cacheRead > 0 || cacheCreated > 0) {
3288
+ await auditLog("agent.prompt.cache", {
3289
+ provider: completion.provider,
3290
+ model: completion.model,
3291
+ promptTokens: completion.usage.promptTokens,
3292
+ cacheReadTokens: cacheRead,
3293
+ cacheCreationTokens: cacheCreated,
3294
+ hitRatio: completion.usage.promptTokens > 0
3295
+ ? Number((cacheRead / completion.usage.promptTokens).toFixed(3))
3296
+ : 0,
3297
+ });
3298
+ }
2775
3299
  }
2776
3300
  deltaParser?.finish();
2777
3301
  // Sticky text-only may have flipped dialect during stream retry.
@@ -2903,25 +3427,28 @@ export async function runAgentTurn(prompt, options = {}) {
2903
3427
  truncatedToolRetries += 1;
2904
3428
  if (truncatedToolRetries <= 5) {
2905
3429
  try {
2906
- const writeResult = await fsWrite(salvaged.path, salvaged.content, { confirmed: true });
3430
+ const writeResult = await applySalvagedWrite(salvaged);
2907
3431
  if (writeResult.ok) {
2908
3432
  const lineCount = salvaged.content.split("\n").length;
2909
3433
  writeNotice("info", `native tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ native tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
2910
3434
  // Pair assistant tool_calls with synthetic results so the
2911
3435
  // next turn is not orphaned, then nudge for append.
2912
- appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls);
3436
+ appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls, completion.reasoningBlock);
2913
3437
  for (const tc of nativeToolCalls) {
2914
3438
  appendToolResult(messages, tc.id, tc.id === writeTc.id
2915
3439
  ? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
2916
3440
  : `Tool ${tc.name} result (exit=1, ok=false):\nCancelled — sibling write was truncated and salvaged.`, tc.name, tc.id === writeTc.id);
2917
3441
  }
2918
- const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
3442
+ const priorBytes = writeResult.bytesOnDisk;
3443
+ const salvagedToolName = salvaged.operation === "append"
3444
+ ? "fs.append"
3445
+ : "fs.write";
2919
3446
  const appendNudge = toolsAttached
2920
- ? `Your ${writeTc.name} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
3447
+ ? `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
2921
3448
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2922
3449
  `CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content not already on disk (prefer hundreds of lines per call). ` +
2923
3450
  `Do not re-read the full file; do not re-send content already saved. Use the platform tool interface — no markdown fences.`
2924
- : `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
3451
+ : `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
2925
3452
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2926
3453
  `CONTINUE with ONE large fs.append of the remaining content:\n` +
2927
3454
  '```tool\n{"name":"fs.append","args":{"path":' +
@@ -3055,23 +3582,23 @@ export async function runAgentTurn(prompt, options = {}) {
3055
3582
  truncatedToolRetries += 1;
3056
3583
  const salvaged = salvageTruncatedWrite(assistantText.visible);
3057
3584
  if (salvaged && truncatedToolRetries <= 5) {
3058
- // Write the salvaged partial content
3585
+ // Write the salvaged partial content through the normal
3586
+ // authorization path (classifier + confirmation + receipt).
3059
3587
  try {
3060
- const writeResult = await fsWrite(salvaged.path, salvaged.content, {
3061
- confirmed: true,
3062
- });
3588
+ const writeResult = await applySalvagedWrite(salvaged);
3063
3589
  if (writeResult.ok) {
3064
3590
  const lineCount = salvaged.content.split("\n").length;
3065
3591
  writeNotice("info", `tool call was truncated — salvaged ${lineCount} lines and wrote to ${salvaged.path}`, chalk.cyan(` ℹ tool call was truncated — salvaged ${lineCount} lines to ${salvaged.path}\n`));
3066
3592
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
3067
- const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
3593
+ const priorBytes = writeResult.bytesOnDisk;
3594
+ const salvagedToolName = salvaged.operation === "append" ? "fs.append" : "fs.write";
3068
3595
  messages.push({
3069
3596
  role: "user",
3070
3597
  content: toolsAttached
3071
- ? `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
3598
+ ? `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
3072
3599
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
3073
3600
  `CONTINUE by calling fs.append now with path=${JSON.stringify(salvaged.path)}, expectedPriorBytes=${priorBytes}, and content set to ONLY the remaining content (prefer large chunks). Use the platform tool interface — no markdown fences.`
3074
- : `Your fs.write tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (${priorBytes} bytes) to ${salvaged.path}. ` +
3601
+ : `Your ${salvagedToolName} tool call was cut off at the token limit, but the system salvaged the partial content and wrote ${lineCount} lines (file is now ${priorBytes} bytes) to ${salvaged.path}. ` +
3075
3602
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
3076
3603
  `CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
3077
3604
  '```tool\n{"name":"fs.append","args":{"path":' +
@@ -3120,9 +3647,7 @@ export async function runAgentTurn(prompt, options = {}) {
3120
3647
  const salvaged = salvageTruncatedWrite(assistantText.visible);
3121
3648
  if (salvaged) {
3122
3649
  try {
3123
- const writeResult = await fsWrite(salvaged.path, salvaged.content, {
3124
- confirmed: true,
3125
- });
3650
+ const writeResult = await applySalvagedWrite(salvaged);
3126
3651
  if (writeResult.ok) {
3127
3652
  const lineCount = salvaged.content.split("\n").length;
3128
3653
  writeNotice("info", `malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}`, chalk.cyan(` ℹ malformed tool call salvaged — wrote ${lineCount} lines to ${salvaged.path}\n`));
@@ -3169,7 +3694,7 @@ export async function runAgentTurn(prompt, options = {}) {
3169
3694
  const unread = [...unreadResponderNotificationIds];
3170
3695
  pushAssistantHistory(assistantText.visible);
3171
3696
  messages.push(recoveryUserMessage(`You have ${unread.length} delivered Responder result(s) that remain unread: ${unread.join(", ")}. ` +
3172
- "If analysis is incomplete, call the necessary bounded evidence tool now. If you have seen and analyzed each result and are satisfied its responder subtask is finished, you MUST call task.read for each exact notificationId before giving a final response. Do not merely say it is read."));
3697
+ "If analysis is incomplete, call only the bounded evidence tool needed now. If each result has been analyzed and is satisfactory, you MUST call job.read with its jobId or exact notificationId before giving a final response. job.read does not require an active plan; do not create or update a plan merely to acknowledge a result."));
3173
3698
  continue;
3174
3699
  }
3175
3700
  const narratedAction = looksLikeActionNarration(cleaned);
@@ -3201,6 +3726,7 @@ export async function runAgentTurn(prompt, options = {}) {
3201
3726
  looksLikeErrorDiagnosisWithFixIntent(cleaned);
3202
3727
  const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
3203
3728
  planNarrated ||
3729
+ ((narratedAction || narratedWebAction) && !informationalQuery) ||
3204
3730
  (session.planApproved.value &&
3205
3731
  planHasOpenWorkNow &&
3206
3732
  (narratedAction || errorFixNarration)) ||
@@ -3348,7 +3874,7 @@ export async function runAgentTurn(prompt, options = {}) {
3348
3874
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
3349
3875
  const unfinished = livePlan?.tasks.filter((task) => !task.responderOwned &&
3350
3876
  (task.state === "pending" || task.state === "in_progress"));
3351
- const deferReport = shouldYieldForResponderBeforeReport(livePlan, jobManager.getRunningJobs(session.sessionId), jobManager.getPendingNotifications(session.sessionId), responderWakeNotificationId);
3877
+ const deferReport = shouldYieldForDeclaredResponderDependency(livePlan, jobManager.getRunningJobs(session.sessionId), jobManager.getPendingNotifications(session.sessionId), responderWakeNotificationId);
3352
3878
  if (livePlan &&
3353
3879
  unfinished &&
3354
3880
  unfinished.length > 0 &&
@@ -3370,9 +3896,17 @@ export async function runAgentTurn(prompt, options = {}) {
3370
3896
  const remainingCriteria = [];
3371
3897
  if (session.planApproved.value) {
3372
3898
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
3373
- const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
3374
- const failedTasks = livePlan?.tasks.filter((t) => t.state === "failed") ?? [];
3899
+ // Foreground work decides the turn outcome. Responder children run
3900
+ // concurrently by design and are reported separately.
3901
+ const unfinished = livePlan ? foregroundRemaining(livePlan) : [];
3902
+ const failedTasks = livePlan?.tasks.filter((task) => !task.responderOwned && task.state === "failed") ?? [];
3375
3903
  remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
3904
+ const openResponderChildren = livePlan
3905
+ ? responderOpenTasks(livePlan)
3906
+ : [];
3907
+ if (openResponderChildren.length > 0) {
3908
+ remainingCriteria.push(...openResponderChildren.map((task) => `[${task.id}] responder result awaiting analysis: ${task.title}`));
3909
+ }
3376
3910
  if (failedTasks.length > 0)
3377
3911
  outcomeStatus = "failed";
3378
3912
  else if (unfinished.length > 0)
@@ -3514,7 +4048,7 @@ export async function runAgentTurn(prompt, options = {}) {
3514
4048
  writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
3515
4049
  }
3516
4050
  if (historyNativeCalls.length) {
3517
- appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
4051
+ appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock);
3518
4052
  }
3519
4053
  else {
3520
4054
  const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
@@ -3563,7 +4097,8 @@ export async function runAgentTurn(prompt, options = {}) {
3563
4097
  if (res.ok && res.call.name === "plan.create") {
3564
4098
  planCreatedThisTurn = true;
3565
4099
  }
3566
- productiveSteps += 1;
4100
+ if (!res.suppressedRepeat)
4101
+ productiveSteps += 1;
3567
4102
  // E5: collapse identical large tool bodies within this turn to a pointer.
3568
4103
  const deduped = dedupeToolContextOutput({
3569
4104
  content: res.contextOutput,
@@ -3728,7 +4263,7 @@ export async function runAgentTurn(prompt, options = {}) {
3728
4263
  }
3729
4264
  // User Esc/Ctrl+C only — never cancel siblings because a delete failed
3730
4265
  // or a confirm was declined; the model must see every tool result.
3731
- if (res.lastAnswer === "Aborted.")
4266
+ if (res.aborted)
3732
4267
  aborted = true;
3733
4268
  };
3734
4269
  // Multi-task sync guard: when one model message advances more than one
@@ -3749,7 +4284,24 @@ export async function runAgentTurn(prompt, options = {}) {
3749
4284
  : parsed.taskId;
3750
4285
  intents.push({ call: b.call, taskId: resolvedId, state: parsed.state });
3751
4286
  }
3752
- if (isSimultaneousTaskAdvance(intents)) {
4287
+ const openIds = openingTaskIds(intents);
4288
+ if (openIds.length > 1) {
4289
+ // A multi-open is never confirmable; the store keeps at
4290
+ // most one active foreground task.
4291
+ session.pendingTaskBatch.value = undefined;
4292
+ const openDescriptors = openIds.map((taskId) => ({
4293
+ taskId,
4294
+ title: livePlanForBatch?.tasks.find((t) => t.id === taskId)?.title ?? "",
4295
+ targetState: "in_progress",
4296
+ }));
4297
+ batchReminderNote = buildMultiOpenRejection(openDescriptors);
4298
+ for (const intent of intents) {
4299
+ if (intent.state === "in_progress")
4300
+ batchRemindCalls.add(intent.call);
4301
+ }
4302
+ writeNotice("warn", multiOpenToast(openIds.length), chalk.yellow(` ⚠ ${multiOpenToast(openIds.length)}\n`));
4303
+ }
4304
+ else if (isSimultaneousTaskAdvance(intents)) {
3753
4305
  const signature = batchUpdateSignature(intents);
3754
4306
  if (session.pendingTaskBatch.value === signature) {
3755
4307
  session.pendingTaskBatch.value = undefined;
@@ -3870,7 +4422,7 @@ export async function runAgentTurn(prompt, options = {}) {
3870
4422
  return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
3871
4423
  }
3872
4424
  if (aborted) {
3873
- lastAnswer = "Aborted.";
4425
+ lastAnswer = "";
3874
4426
  outcomeState.outcome.status = "aborted";
3875
4427
  await saveOutcomeState(outcomeState);
3876
4428
  moveTurn("aborted", "turn aborted");
@@ -3901,11 +4453,12 @@ export async function runAgentTurn(prompt, options = {}) {
3901
4453
  }
3902
4454
  catch (error) {
3903
4455
  const isAbort = isAbortError(error, options.signal);
3904
- const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
3905
4456
  if (isAbort) {
3906
4457
  writeAbort();
3907
- return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
4458
+ return finishTurn("", 0, "aborted");
3908
4459
  }
4460
+ releaseUnreadResponderClaims();
4461
+ const msg = `Error: ${error instanceof Error ? error.message : String(error)}`;
3909
4462
  if (options.onMessages) {
3910
4463
  try {
3911
4464
  options.onMessages(buildTurnHistory(liveMessages, msg));
@@ -3921,8 +4474,8 @@ export async function runAgentTurn(prompt, options = {}) {
3921
4474
  throw error;
3922
4475
  }
3923
4476
  }
3924
- /** Compatibility boundary for callers that still consume rendered text. */
4477
+ /** Compatibility boundary for callers that consume visible assistant text. */
3925
4478
  export async function runAgentLoop(prompt, options = {}) {
3926
- return renderTurnOutcome(await runAgentTurn(prompt, options));
4479
+ return (await runAgentTurn(prompt, options)).answer;
3927
4480
  }
3928
4481
  //# sourceMappingURL=runner.js.map