@pentoshi/clai 3.5.1 → 3.7.2

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 (369) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/compaction-summary.d.ts +17 -0
  3. package/dist/agent/compaction-summary.js +64 -0
  4. package/dist/agent/compaction-summary.js.map +1 -0
  5. package/dist/agent/confirm-port.d.ts +3 -1
  6. package/dist/agent/confirm-port.js +5 -1
  7. package/dist/agent/confirm-port.js.map +1 -1
  8. package/dist/agent/context-manager.js +53 -24
  9. package/dist/agent/context-manager.js.map +1 -1
  10. package/dist/agent/events.d.ts +6 -0
  11. package/dist/agent/evidence-governor.d.ts +37 -0
  12. package/dist/agent/evidence-governor.js +70 -0
  13. package/dist/agent/evidence-governor.js.map +1 -0
  14. package/dist/agent/loop-guard.d.ts +29 -9
  15. package/dist/agent/loop-guard.js +117 -26
  16. package/dist/agent/loop-guard.js.map +1 -1
  17. package/dist/agent/must-continue.d.ts +56 -0
  18. package/dist/agent/must-continue.js +240 -0
  19. package/dist/agent/must-continue.js.map +1 -0
  20. package/dist/agent/outcomes.d.ts +101 -0
  21. package/dist/agent/outcomes.js +359 -0
  22. package/dist/agent/outcomes.js.map +1 -0
  23. package/dist/agent/plan-decision.d.ts +33 -0
  24. package/dist/agent/plan-decision.js +104 -0
  25. package/dist/agent/plan-decision.js.map +1 -0
  26. package/dist/agent/plan-tool.d.ts +34 -0
  27. package/dist/agent/plan-tool.js +594 -76
  28. package/dist/agent/plan-tool.js.map +1 -1
  29. package/dist/agent/project-root.d.ts +33 -0
  30. package/dist/agent/project-root.js +161 -0
  31. package/dist/agent/project-root.js.map +1 -0
  32. package/dist/agent/prompt-composer.d.ts +21 -0
  33. package/dist/agent/prompt-composer.js +46 -0
  34. package/dist/agent/prompt-composer.js.map +1 -0
  35. package/dist/agent/runner.d.ts +11 -1
  36. package/dist/agent/runner.js +1915 -886
  37. package/dist/agent/runner.js.map +1 -1
  38. package/dist/agent/scope-context.d.ts +3 -0
  39. package/dist/agent/scope-context.js +24 -0
  40. package/dist/agent/scope-context.js.map +1 -0
  41. package/dist/agent/scratch-write.d.ts +4 -0
  42. package/dist/agent/scratch-write.js +54 -0
  43. package/dist/agent/scratch-write.js.map +1 -0
  44. package/dist/agent/session-policy.d.ts +7 -0
  45. package/dist/agent/session-policy.js +61 -0
  46. package/dist/agent/session-policy.js.map +1 -1
  47. package/dist/agent/session-state.d.ts +41 -0
  48. package/dist/agent/session-state.js +107 -0
  49. package/dist/agent/session-state.js.map +1 -0
  50. package/dist/agent/step-budget.d.ts +14 -0
  51. package/dist/agent/step-budget.js +26 -0
  52. package/dist/agent/step-budget.js.map +1 -0
  53. package/dist/agent/task-analyzer.d.ts +12 -3
  54. package/dist/agent/task-analyzer.js +263 -23
  55. package/dist/agent/task-analyzer.js.map +1 -1
  56. package/dist/agent/task-evidence.d.ts +173 -0
  57. package/dist/agent/task-evidence.js +802 -0
  58. package/dist/agent/task-evidence.js.map +1 -0
  59. package/dist/agent/task-plan.d.ts +57 -0
  60. package/dist/agent/task-plan.js +147 -1
  61. package/dist/agent/task-plan.js.map +1 -1
  62. package/dist/agent/tool-call-parser.d.ts +19 -11
  63. package/dist/agent/tool-call-parser.js +96 -71
  64. package/dist/agent/tool-call-parser.js.map +1 -1
  65. package/dist/agent/tool-history.d.ts +34 -0
  66. package/dist/agent/tool-history.js +200 -0
  67. package/dist/agent/tool-history.js.map +1 -0
  68. package/dist/agent/tool-output-formatting.d.ts +13 -1
  69. package/dist/agent/tool-output-formatting.js +75 -22
  70. package/dist/agent/tool-output-formatting.js.map +1 -1
  71. package/dist/agent/turn-outcome.d.ts +11 -0
  72. package/dist/agent/turn-outcome.js +17 -0
  73. package/dist/agent/turn-outcome.js.map +1 -0
  74. package/dist/agent/turn-state.d.ts +11 -0
  75. package/dist/agent/turn-state.js +28 -0
  76. package/dist/agent/turn-state.js.map +1 -0
  77. package/dist/agent/workspace-orient.d.ts +75 -0
  78. package/dist/agent/workspace-orient.js +509 -0
  79. package/dist/agent/workspace-orient.js.map +1 -0
  80. package/dist/app/adapters/agent-event-adapter.d.ts +2 -13
  81. package/dist/app/adapters/agent-event-adapter.js +63 -25
  82. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  83. package/dist/app/adapters/current-agent-adapter.d.ts +0 -5
  84. package/dist/app/adapters/current-agent-adapter.js +4 -7
  85. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  86. package/dist/app/adapters/current-jobs-adapter.js +1 -0
  87. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  88. package/dist/app/adapters/current-terminal-adapter.d.ts +0 -7
  89. package/dist/app/adapters/current-terminal-adapter.js +0 -7
  90. package/dist/app/adapters/current-terminal-adapter.js.map +1 -1
  91. package/dist/app/adapters/current-updates-adapter.d.ts +0 -5
  92. package/dist/app/adapters/current-updates-adapter.js +0 -5
  93. package/dist/app/adapters/current-updates-adapter.js.map +1 -1
  94. package/dist/app/adapters/in-memory-clipboard-adapter.d.ts +0 -5
  95. package/dist/app/adapters/in-memory-clipboard-adapter.js +0 -5
  96. package/dist/app/adapters/in-memory-clipboard-adapter.js.map +1 -1
  97. package/dist/app/commands/command.d.ts +0 -5
  98. package/dist/app/commands/command.js.map +1 -1
  99. package/dist/app/commands/registry.d.ts +0 -14
  100. package/dist/app/commands/registry.js +0 -20
  101. package/dist/app/commands/registry.js.map +1 -1
  102. package/dist/app/controllers/disposable.d.ts +0 -5
  103. package/dist/app/controllers/disposable.js +0 -5
  104. package/dist/app/controllers/disposable.js.map +1 -1
  105. package/dist/app/controllers/job-controller.d.ts +1 -5
  106. package/dist/app/controllers/job-controller.js +0 -4
  107. package/dist/app/controllers/job-controller.js.map +1 -1
  108. package/dist/app/controllers/plan-controller.d.ts +5 -8
  109. package/dist/app/controllers/plan-controller.js +30 -12
  110. package/dist/app/controllers/plan-controller.js.map +1 -1
  111. package/dist/app/controllers/session-controller.d.ts +17 -32
  112. package/dist/app/controllers/session-controller.js +60 -35
  113. package/dist/app/controllers/session-controller.js.map +1 -1
  114. package/dist/app/controllers/turn-controller.d.ts +2 -6
  115. package/dist/app/controllers/turn-controller.js +8 -8
  116. package/dist/app/controllers/turn-controller.js.map +1 -1
  117. package/dist/app/events/app-event.d.ts +4 -7
  118. package/dist/app/events/app-event.js +0 -6
  119. package/dist/app/events/app-event.js.map +1 -1
  120. package/dist/app/events/event-buffer.d.ts +6 -17
  121. package/dist/app/events/event-buffer.js +27 -24
  122. package/dist/app/events/event-buffer.js.map +1 -1
  123. package/dist/app/events/sequencer.d.ts +0 -9
  124. package/dist/app/events/sequencer.js +0 -4
  125. package/dist/app/events/sequencer.js.map +1 -1
  126. package/dist/app/ports/agent-port.d.ts +13 -3
  127. package/dist/app/ports/clipboard-port.d.ts +0 -5
  128. package/dist/app/ports/confirm-port.d.ts +0 -7
  129. package/dist/app/ports/jobs-port.d.ts +2 -5
  130. package/dist/app/ports/persistence-port.d.ts +0 -4
  131. package/dist/app/ports/secret-port.d.ts +0 -5
  132. package/dist/app/ports/terminal-port.d.ts +0 -6
  133. package/dist/app/ports/updates-port.d.ts +0 -4
  134. package/dist/attachments/service.d.ts +29 -0
  135. package/dist/attachments/service.js +47 -0
  136. package/dist/attachments/service.js.map +1 -0
  137. package/dist/commands/doctor.js +3 -0
  138. package/dist/commands/doctor.js.map +1 -1
  139. package/dist/commands/providers.js +0 -3
  140. package/dist/commands/providers.js.map +1 -1
  141. package/dist/commands/search-providers.d.ts +0 -33
  142. package/dist/commands/search-providers.js +0 -33
  143. package/dist/commands/search-providers.js.map +1 -1
  144. package/dist/commands/update.js +1 -9
  145. package/dist/commands/update.js.map +1 -1
  146. package/dist/index.js +22 -42
  147. package/dist/index.js.map +1 -1
  148. package/dist/llm/adapters/anthropic-tools.d.ts +99 -0
  149. package/dist/llm/adapters/anthropic-tools.js +225 -0
  150. package/dist/llm/adapters/anthropic-tools.js.map +1 -0
  151. package/dist/llm/adapters/gemini-tools.d.ts +54 -0
  152. package/dist/llm/adapters/gemini-tools.js +139 -0
  153. package/dist/llm/adapters/gemini-tools.js.map +1 -0
  154. package/dist/llm/adapters/ollama-tools.d.ts +18 -0
  155. package/dist/llm/adapters/ollama-tools.js +58 -0
  156. package/dist/llm/adapters/ollama-tools.js.map +1 -0
  157. package/dist/llm/adapters/openai-tools.d.ts +39 -0
  158. package/dist/llm/adapters/openai-tools.js +71 -0
  159. package/dist/llm/adapters/openai-tools.js.map +1 -0
  160. package/dist/llm/agentrouter.js +23 -4
  161. package/dist/llm/agentrouter.js.map +1 -1
  162. package/dist/llm/anthropic.js +93 -92
  163. package/dist/llm/anthropic.js.map +1 -1
  164. package/dist/llm/aws-mantle.js +93 -56
  165. package/dist/llm/aws-mantle.js.map +1 -1
  166. package/dist/llm/bynara.js +23 -4
  167. package/dist/llm/bynara.js.map +1 -1
  168. package/dist/llm/capabilities.d.ts +13 -6
  169. package/dist/llm/capabilities.js +113 -13
  170. package/dist/llm/capabilities.js.map +1 -1
  171. package/dist/llm/gemini.js +63 -48
  172. package/dist/llm/gemini.js.map +1 -1
  173. package/dist/llm/groq.js +23 -10
  174. package/dist/llm/groq.js.map +1 -1
  175. package/dist/llm/http.d.ts +22 -34
  176. package/dist/llm/http.js +97 -69
  177. package/dist/llm/http.js.map +1 -1
  178. package/dist/llm/kimchi.js +23 -4
  179. package/dist/llm/kimchi.js.map +1 -1
  180. package/dist/llm/nvidia.js +23 -4
  181. package/dist/llm/nvidia.js.map +1 -1
  182. package/dist/llm/ollama.js +49 -31
  183. package/dist/llm/ollama.js.map +1 -1
  184. package/dist/llm/openai.js +23 -4
  185. package/dist/llm/openai.js.map +1 -1
  186. package/dist/llm/openrouter.js +23 -4
  187. package/dist/llm/openrouter.js.map +1 -1
  188. package/dist/llm/qwen-cloud.js +23 -4
  189. package/dist/llm/qwen-cloud.js.map +1 -1
  190. package/dist/llm/router.d.ts +0 -6
  191. package/dist/llm/router.js +48 -21
  192. package/dist/llm/router.js.map +1 -1
  193. package/dist/llm/tool-protocol.d.ts +67 -0
  194. package/dist/llm/tool-protocol.js +278 -0
  195. package/dist/llm/tool-protocol.js.map +1 -0
  196. package/dist/modes/agent.d.ts +2 -1
  197. package/dist/modes/agent.js +4 -2
  198. package/dist/modes/agent.js.map +1 -1
  199. package/dist/modes/ask.d.ts +0 -18
  200. package/dist/modes/ask.js +71 -66
  201. package/dist/modes/ask.js.map +1 -1
  202. package/dist/os/cwd.d.ts +0 -21
  203. package/dist/os/cwd.js +0 -24
  204. package/dist/os/cwd.js.map +1 -1
  205. package/dist/os/pkgmgr.js +0 -6
  206. package/dist/os/pkgmgr.js.map +1 -1
  207. package/dist/prompts/index.d.ts +17 -21
  208. package/dist/prompts/index.js +131 -259
  209. package/dist/prompts/index.js.map +1 -1
  210. package/dist/prompts/system.agent.md +242 -0
  211. package/dist/prompts/system.ask.md +56 -0
  212. package/dist/repl/prompt-line.js +0 -36
  213. package/dist/repl/prompt-line.js.map +1 -1
  214. package/dist/repl/slash-commands.d.ts +0 -9
  215. package/dist/repl/slash-commands.js +2 -23
  216. package/dist/repl/slash-commands.js.map +1 -1
  217. package/dist/repl.d.ts +5 -0
  218. package/dist/repl.js +176 -55
  219. package/dist/repl.js.map +1 -1
  220. package/dist/safety/classifier.js +17 -9
  221. package/dist/safety/classifier.js.map +1 -1
  222. package/dist/safety/engagement-policy.d.ts +46 -0
  223. package/dist/safety/engagement-policy.js +198 -0
  224. package/dist/safety/engagement-policy.js.map +1 -0
  225. package/dist/store/config.d.ts +7 -0
  226. package/dist/store/config.js +1 -0
  227. package/dist/store/config.js.map +1 -1
  228. package/dist/store/engagement.d.ts +115 -0
  229. package/dist/store/engagement.js +278 -0
  230. package/dist/store/engagement.js.map +1 -0
  231. package/dist/store/logs.d.ts +5 -0
  232. package/dist/store/logs.js +52 -2
  233. package/dist/store/logs.js.map +1 -1
  234. package/dist/store/plan.d.ts +59 -5
  235. package/dist/store/plan.js +182 -8
  236. package/dist/store/plan.js.map +1 -1
  237. package/dist/store/scope.d.ts +4 -1
  238. package/dist/store/scope.js +2 -2
  239. package/dist/store/scope.js.map +1 -1
  240. package/dist/tools/capabilities.d.ts +2 -0
  241. package/dist/tools/capabilities.js +101 -6
  242. package/dist/tools/capabilities.js.map +1 -1
  243. package/dist/tools/command-intent.d.ts +1 -0
  244. package/dist/tools/command-intent.js +31 -0
  245. package/dist/tools/command-intent.js.map +1 -1
  246. package/dist/tools/definitions.d.ts +20 -0
  247. package/dist/tools/definitions.js +578 -0
  248. package/dist/tools/definitions.js.map +1 -0
  249. package/dist/tools/elevated-shell.d.ts +60 -0
  250. package/dist/tools/elevated-shell.js +233 -0
  251. package/dist/tools/elevated-shell.js.map +1 -0
  252. package/dist/tools/file-diff.d.ts +125 -0
  253. package/dist/tools/file-diff.js +495 -0
  254. package/dist/tools/file-diff.js.map +1 -0
  255. package/dist/tools/fs.d.ts +11 -13
  256. package/dist/tools/fs.js +280 -80
  257. package/dist/tools/fs.js.map +1 -1
  258. package/dist/tools/http.d.ts +15 -0
  259. package/dist/tools/http.js +202 -61
  260. package/dist/tools/http.js.map +1 -1
  261. package/dist/tools/jobs.d.ts +71 -10
  262. package/dist/tools/jobs.js +403 -124
  263. package/dist/tools/jobs.js.map +1 -1
  264. package/dist/tools/net-ping-sweep.js +69 -16
  265. package/dist/tools/net-ping-sweep.js.map +1 -1
  266. package/dist/tools/nmap-runner.d.ts +19 -7
  267. package/dist/tools/nmap-runner.js +182 -80
  268. package/dist/tools/nmap-runner.js.map +1 -1
  269. package/dist/tools/pentest-workflows.d.ts +8 -0
  270. package/dist/tools/pentest-workflows.js +71 -0
  271. package/dist/tools/pentest-workflows.js.map +1 -0
  272. package/dist/tools/registry.d.ts +9 -0
  273. package/dist/tools/registry.js +272 -63
  274. package/dist/tools/registry.js.map +1 -1
  275. package/dist/tools/shell.d.ts +3 -0
  276. package/dist/tools/shell.js +35 -13
  277. package/dist/tools/shell.js.map +1 -1
  278. package/dist/tools/tool-types.d.ts +11 -0
  279. package/dist/tools/validate.d.ts +11 -0
  280. package/dist/tools/validate.js +53 -0
  281. package/dist/tools/validate.js.map +1 -1
  282. package/dist/tools/web/search.js +48 -4
  283. package/dist/tools/web/search.js.map +1 -1
  284. package/dist/tui-v2/app/App.js +25 -39
  285. package/dist/tui-v2/app/App.js.map +1 -1
  286. package/dist/tui-v2/app/command-handlers.js +20 -1
  287. package/dist/tui-v2/app/command-handlers.js.map +1 -1
  288. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  289. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  290. package/dist/tui-v2/app/commands/session-commands.js +5 -0
  291. package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
  292. package/dist/tui-v2/app/plan-lifecycle.d.ts +25 -3
  293. package/dist/tui-v2/app/plan-lifecycle.js +87 -24
  294. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  295. package/dist/tui-v2/bootstrap/patch-opentui-text.d.ts +6 -0
  296. package/dist/tui-v2/bootstrap/patch-opentui-text.js +53 -0
  297. package/dist/tui-v2/bootstrap/patch-opentui-text.js.map +1 -0
  298. package/dist/tui-v2/bootstrap/start-tui-v2.js +7 -0
  299. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  300. package/dist/tui-v2/components/jobs/jobs-panel.js +23 -14
  301. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  302. package/dist/tui-v2/components/modal/confirm-modal.d.ts +1 -1
  303. package/dist/tui-v2/components/modal/confirm-modal.js +18 -7
  304. package/dist/tui-v2/components/modal/confirm-modal.js.map +1 -1
  305. package/dist/tui-v2/components/overlay/overlay-host.js +1 -1
  306. package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
  307. package/dist/tui-v2/components/pager/pager.d.ts +6 -0
  308. package/dist/tui-v2/components/pager/pager.js +313 -67
  309. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  310. package/dist/tui-v2/components/status/status-line.d.ts +9 -0
  311. package/dist/tui-v2/components/status/status-line.js +17 -3
  312. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  313. package/dist/tui-v2/components/transcript/assistant-message.js +1 -1
  314. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  315. package/dist/tui-v2/components/transcript/compacted-row.js +1 -1
  316. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  317. package/dist/tui-v2/components/transcript/tool-card.d.ts +9 -1
  318. package/dist/tui-v2/components/transcript/tool-card.js +115 -10
  319. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  320. package/dist/tui-v2/components/transcript/transcript-row.js +2 -2
  321. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  322. package/dist/tui-v2/composer/composer-editor.js +16 -0
  323. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  324. package/dist/tui-v2/controllers/overlay-controller.d.ts +16 -2
  325. package/dist/tui-v2/controllers/overlay-controller.js +92 -12
  326. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  327. package/dist/tui-v2/rendering/artifact-pager-source.d.ts +19 -0
  328. package/dist/tui-v2/rendering/artifact-pager-source.js +103 -0
  329. package/dist/tui-v2/rendering/artifact-pager-source.js.map +1 -0
  330. package/dist/tui-v2/rendering/file-diff-view.d.ts +63 -0
  331. package/dist/tui-v2/rendering/file-diff-view.js +263 -0
  332. package/dist/tui-v2/rendering/file-diff-view.js.map +1 -0
  333. package/dist/tui-v2/rendering/open-tool-output.d.ts +9 -1
  334. package/dist/tui-v2/rendering/open-tool-output.js +65 -12
  335. package/dist/tui-v2/rendering/open-tool-output.js.map +1 -1
  336. package/dist/tui-v2/rendering/pager-chrome.d.ts +12 -0
  337. package/dist/tui-v2/rendering/pager-chrome.js +56 -0
  338. package/dist/tui-v2/rendering/pager-chrome.js.map +1 -0
  339. package/dist/tui-v2/rendering/plan-view.js +1 -1
  340. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  341. package/dist/tui-v2/rendering/syntax-highlight.d.ts +37 -0
  342. package/dist/tui-v2/rendering/syntax-highlight.js +1303 -0
  343. package/dist/tui-v2/rendering/syntax-highlight.js.map +1 -0
  344. package/dist/tui-v2/rendering/theme.d.ts +28 -0
  345. package/dist/tui-v2/rendering/theme.js +28 -0
  346. package/dist/tui-v2/rendering/theme.js.map +1 -1
  347. package/dist/tui-v2/rendering/tool-presenter.d.ts +11 -1
  348. package/dist/tui-v2/rendering/tool-presenter.js +101 -5
  349. package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
  350. package/dist/tui-v2/state/transcript-hydrate.js +5 -0
  351. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  352. package/dist/tui-v2/state/transcript-reducer.js +12 -2
  353. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  354. package/dist/tui-v2/state/transcript-store.d.ts +7 -0
  355. package/dist/tui-v2/state/transcript-store.js +46 -0
  356. package/dist/tui-v2/state/transcript-store.js.map +1 -1
  357. package/dist/tui-v2/state/transcript-types.d.ts +12 -0
  358. package/dist/tui-v2/state/transcript-types.js +9 -0
  359. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  360. package/dist/types.d.ts +75 -1
  361. package/dist/ui/ansi-box.d.ts +2 -0
  362. package/dist/ui/ansi-box.js +8 -1
  363. package/dist/ui/ansi-box.js.map +1 -1
  364. package/dist/ui/mentions.d.ts +6 -0
  365. package/dist/ui/mentions.js +50 -12
  366. package/dist/ui/mentions.js.map +1 -1
  367. package/dist/ui/plan-pane.js +1 -1
  368. package/dist/ui/plan-pane.js.map +1 -1
  369. package/package.json +16 -11
@@ -1,117 +1,66 @@
1
1
  import chalk from "chalk";
2
- import { homedir } from "node:os";
3
- import { join, relative, resolve } from "node:path";
2
+ import { join } from "node:path";
4
3
  import { streamWithProvider, completeWithProvider } from "../llm/router.js";
4
+ import { resolveToolDialect } from "../llm/capabilities.js";
5
+ import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
6
+ import { sanitizeAssistantText } from "../ui/ansi-box.js";
5
7
  import { randomUUID } from "node:crypto";
6
8
  import { jobManager } from "../tools/jobs.js";
7
- import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, } from "../prompts/index.js";
9
+ import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
8
10
  import { getConfig } from "../store/config.js";
9
11
  import { groqInputTokenBudget } from "../llm/groq.js";
10
12
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
11
13
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
12
- import { looksInteractiveStdin } from "../tools/shell.js";
14
+ import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
15
+ import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, } from "./tool-history.js";
13
16
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
14
- import { compactMessagesWithSummary, estimateTokens, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
17
+ import { compactMessagesWithSummary, estimateMessagesTokens, AUTO_COMPACT_TOKEN_BUDGET, COMPACTION_MEMORY_PREFIX, } from "./context-manager.js";
15
18
  import { auditLog } from "../store/logs.js";
16
19
  import { loadProjectContext } from "../store/project.js";
17
- import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
20
+ import { loadScope, isScopeActive } from "../store/scope.js";
18
21
  import { ensureProviderConfigured } from "../commands/providers.js";
19
22
  import { createThinkingStreamParser, rememberThinkingFromText, renderThinkingSummary, stripThinking, } from "../ui/thinking.js";
20
23
  import { renderMarkdown, indentAndWrapText } from "../ui/markdown.js";
21
24
  import { startThinkingSpinner } from "../ui/spinner.js";
22
25
  import { safeCwd } from "../os/cwd.js";
23
- import { analyzeTask } from "./task-analyzer.js";
26
+ import { analyzeTask, formatTaskAnalysisHint, isNarrowExplicitNmapOperation, } from "./task-analyzer.js";
27
+ import { computeMaxIterations, computeStepBudget } from "./step-budget.js";
28
+ import { isScratchOnlyWrite } from "./scratch-write.js";
29
+ import { COMPACTION_SYSTEM_PROMPT, } from "./compaction-summary.js";
24
30
  import { LoopGuard } from "./loop-guard.js";
25
- import { loadPlan } from "../store/plan.js";
26
- import { pathInsideSandbox, fsWrite } from "../tools/fs.js";
27
- import { stripSentinelTokens, parseToolCall, recognizeBareToolJson, looksLikeTruncatedToolCall, salvageTruncatedWrite, countToolFences, parseAllToolCalls, groupToolCallsForExecution, buildTurnHistory, collapseRepeatedText, textBeforeToolCall, formatToolArgs, looksLikePentestTask, looksLikeBuildTask, looksLikeInformationalQuery, looksLikeIdleOrSocialPrompt, looksLikeActionNarration, looksLikeWebActionNarration, looksLikePlanNarration, requiresFreshWebSearch, freshnessGuardMessage, buildWorkflowDirective, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
28
- import { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
31
+ import { loadPlan, savePlan, markTask, readyPlanTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
32
+ import { fsWrite, isOutsideWorkingDirectory, resolveFsToolPath, } from "../tools/fs.js";
33
+ 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";
34
+ import { createSessionPolicy, isPreApprovalAllowedTool, isPlanModeAllowedShellCommand, isPlanModeAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
29
35
  import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
30
- import { planContextMessage, handlePlanTool, } from "./plan-tool.js";
36
+ import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
37
+ import { absorbLooseWorkIntoLedger, applyDestinationCwd, canMarkTaskDone, hasLocalRuntimeProof, hasRemoteWorkProof, isBatchSoftFailTool, isDevServerCall, isEvidenceWorkTool, isFeatureImplementationCall, isPackageInstallCommand, isPlanPreflightTool, isPortListeningOutput, isReadOnlyReconTool, isRemoteActiveTestCall, isRemoteObservationTask, isRemoteReconToolCall, isRuntimeObservationTask, isScaffoldCreateCommand, isServerReadyOutput, ledgerFromTaskEvidence, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, taskEvidenceFromLedger, toolStallBudgetMs, userAskedForFeatureApp, } from "./task-evidence.js";
38
+ import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
39
+ import { detectPackageManager } from "./workspace-orient.js";
40
+ import { budgetRemaining, consumeBudget, createRecoveryBudgets, freestyleClaimsAppReady, looksLikeShallowPentestReport, recoveryForErrorDiagnosis, recoveryForFailedProbe, recoveryForFreshness, recoveryForMissingFeature, recoveryForMissingPlan, recoveryForNarration, recoveryForPrematureComplete, recoveryForRuntimeVerify, recoveryForShallowPentest, } from "./must-continue.js";
41
+ import { scopeContextMessage } from "./scope-context.js";
42
+ import { EngagementPolicyEngine, actionFromUrl, engagementActionForToolCall, evaluateEngagementAction, } from "../safety/engagement-policy.js";
43
+ import { patchPlanMeta } from "../store/plan.js";
44
+ import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
45
+ import { buildWorkspaceOrientation, discoverImmediateProjectRoots, guessProjectFolderName, isBareParentDirectory, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, resolveScaffoldTargetPath, } from "./workspace-orient.js";
31
46
  import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
32
47
  import { buildRichStopSummary } from "./stop-summary.js";
33
- // Re-exported so existing imports of these names from "./runner.js" keep
34
- // working unchanged the parsing/classification engine now lives in
35
- // tool-call-parser.ts, and the session/plan/confirm/formatting helpers now
36
- // live in their own dedicated modules.
48
+ import { composeAgentSystemPrompt } from "./prompt-composer.js";
49
+ import { createGovernorState, governProgress, } from "./evidence-governor.js";
50
+ import { createTurnState, transitionTurn, } from "./turn-state.js";
51
+ import { deriveOutcomeStatus, inferOutcomeKind, openOutcomeState, recordAnswerEvidence, recordFailedHypothesis, recordToolEvidence, saveOutcomeState, validateCriterionEvidence, } from "./outcomes.js";
52
+ import { createTurnOutcome, renderTurnOutcome } from "./turn-outcome.js";
53
+ import { beginEngagementAction, finishEngagementAction, recordEngagementCheckpoint, reconcileEngagementJob, openEngagement, saveEngagement, } from "../store/engagement.js";
37
54
  export * from "./tool-call-parser.js";
38
55
  export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
39
56
  export function styleToolChatter(call, text) {
40
57
  return shouldDimToolChatter(call) ? chalk.dim(text) : text;
41
58
  }
42
- /**
43
- * Tool names that may write into the project tree. Writes restricted to the
44
- * per-project scratch directory (under tmpdir()/clai/<name>) are exempted
45
- * from the active-plan and plan-approved gates so the model can use scratch
46
- * space to build / inspect / stage work without first creating a plan.
47
- */
48
- const SCRATCH_WRITABLE_TOOLS = new Set([
49
- "fs.write",
50
- "fs.writeMany",
51
- "fs.edit",
52
- "fs.replaceLines",
53
- "fs.append",
54
- "fs.delete",
55
- ]);
56
- /**
57
- * Expand `~` the same way `src/tools/fs.ts` does so callers can compare an
58
- * already-expanded scratch path against paths supplied by the model.
59
- */
60
- function expandHomeLocal(path) {
61
- if (path === "~")
62
- return homedir();
63
- if (path.startsWith("~/") || path.startsWith("~\\")) {
64
- return resolve(homedir(), path.slice(2));
65
- }
66
- return path;
67
- }
68
- /**
69
- * Extract the target path(s) a write-shaped tool call would touch. Returns
70
- * an empty array when the call has no resolvable path (so the caller can
71
- * treat it as NOT scratch-only and fall through to the normal gates).
72
- */
73
- function scratchWriteTargetPaths(call) {
74
- if (call.name === "fs.writeMany") {
75
- const files = call.args.files;
76
- if (!Array.isArray(files))
77
- return [];
78
- const paths = [];
79
- for (const entry of files) {
80
- if (entry && typeof entry === "object" && "path" in entry) {
81
- const p = entry.path;
82
- if (typeof p === "string" && p.length > 0)
83
- paths.push(p);
84
- }
85
- }
86
- return paths;
87
- }
88
- const pathArg = call.args.path;
89
- if (typeof pathArg !== "string" || pathArg.length === 0)
90
- return [];
91
- return [pathArg];
92
- }
93
- /**
94
- * True iff every target path this call would write is inside the resolved
95
- * scratch directory. A path is considered inside when its `path.relative`
96
- * against the scratch root is empty (the scratch root itself) or does not
97
- * start with `..` (no parent traversal). Calls without a recognizable
98
- * target path return false so they fall through to the normal gates.
99
- */
100
- function isScratchOnlyWrite(call, scratchDir) {
101
- if (!SCRATCH_WRITABLE_TOOLS.has(call.name))
102
- return false;
103
- const paths = scratchWriteTargetPaths(call);
104
- if (paths.length === 0)
105
- return false;
106
- const resolvedScratch = resolve(scratchDir);
107
- return paths.every((raw) => {
108
- const expanded = expandHomeLocal(raw);
109
- const resolved = resolve(expanded);
110
- const rel = relative(resolvedScratch, resolved);
111
- return rel === "" || (!rel.startsWith("..") && rel !== "..");
112
- });
113
- }
114
- export async function runAgentLoop(prompt, options = {}) {
59
+ export async function runAgentTurn(prompt, options = {}) {
60
+ const agentMode = options.mode === "plan" || options.mode === "agent" || options.mode === "ask"
61
+ ? options.mode
62
+ : "agent";
63
+ const isPlanMode = agentMode === "plan";
115
64
  const writesDirectly = !options.onEvent;
116
65
  const emit = (event) => options.onEvent?.(event);
117
66
  // Whether the CURRENT model iteration has already committed its visible
@@ -150,13 +99,14 @@ export async function runAgentLoop(prompt, options = {}) {
150
99
  const writeAssistantMessage = (text) => {
151
100
  // Never surface an empty message: the reducer drops it and a direct
152
101
  // stdout writer would print a stray blank line.
153
- if (!text.trim())
102
+ const clean = sanitizeAssistantText(text);
103
+ if (!clean.trim())
154
104
  return;
155
105
  visibleCommitted = true;
156
- emit({ type: "assistant-message", text });
157
- const rendered = renderMarkdown(text);
106
+ emit({ type: "assistant-message", text: clean });
107
+ const rendered = renderMarkdown(clean);
158
108
  if (writesDirectly) {
159
- process.stdout.write(text.endsWith("\n") ? rendered : `${rendered}\n`);
109
+ process.stdout.write(clean.endsWith("\n") ? rendered : `${rendered}\n`);
160
110
  }
161
111
  };
162
112
  const writeThinkingBlock = (content) => {
@@ -212,6 +162,9 @@ export async function runAgentLoop(prompt, options = {}) {
212
162
  if (artifactPath) {
213
163
  event.artifactPath = artifactPath;
214
164
  }
165
+ if (result.fileChanges && result.fileChanges.length > 0) {
166
+ event.fileChanges = result.fileChanges;
167
+ }
215
168
  emit(event);
216
169
  };
217
170
  /** Strip a known prefix from a string, returning the remainder unchanged. */
@@ -234,20 +187,36 @@ export async function runAgentLoop(prompt, options = {}) {
234
187
  // conversation back to the caller. Assigned once `messages` is built below;
235
188
  // all later mutations are in-place so this reference stays current.
236
189
  let liveMessages = [];
237
- const finishTurn = (answer, steps) => {
190
+ const finishTurn = (answer, steps, status = "succeeded", remainingCriteria = [], reason) => {
191
+ const outcome = createTurnOutcome({
192
+ status,
193
+ answer,
194
+ steps,
195
+ remainingCriteria,
196
+ reason,
197
+ });
198
+ const rendered = renderTurnOutcome(outcome);
199
+ writeAssistantMessage(rendered);
238
200
  if (options.onMessages) {
239
201
  try {
240
- options.onMessages(buildTurnHistory(liveMessages, answer));
202
+ options.onMessages(buildTurnHistory(liveMessages, rendered));
241
203
  }
242
204
  catch {
243
205
  // Persisting history must never break the turn.
244
206
  }
245
207
  }
246
- emit({ type: "turn-end", finalAnswer: answer, steps });
247
- return answer;
208
+ options.onOutcome?.(outcome);
209
+ emit({ type: "turn-end", outcome, finalAnswer: rendered, steps });
210
+ return outcome;
248
211
  };
249
212
  try {
250
- emit({ type: "turn-start", prompt });
213
+ emit({
214
+ type: "turn-start",
215
+ prompt,
216
+ ...(options.displayPrompt !== undefined
217
+ ? { displayPrompt: options.displayPrompt }
218
+ : {}),
219
+ });
251
220
  const config = getConfig();
252
221
  const maxSteps = options.maxSteps ?? 70;
253
222
  const confirmPort = options.confirm ?? inquirerConfirmPort;
@@ -265,6 +234,7 @@ export async function runAgentLoop(prompt, options = {}) {
265
234
  // agent burns its turn searching the date instead of writing files.
266
235
  const buildLikeTurn = looksLikeBuildTask(prompt, options.history);
267
236
  const pentestLikeTurn = looksLikePentestTask(prompt, options.history);
237
+ const narrowNmapOperation = isNarrowExplicitNmapOperation(prompt);
268
238
  // A plain informational follow-up ("what do you know so far", "summarize
269
239
  // the findings") in a resumed/continuing build or pentest session must
270
240
  // NOT inherit that session's "must act" behavior — it should be answered
@@ -290,17 +260,20 @@ export async function runAgentLoop(prompt, options = {}) {
290
260
  // provider's 413 as a context-window failure after the fact.
291
261
  const inputTokenBudget = provider === "groq" ? groqInputTokenBudget(model) : undefined;
292
262
  const useCompactSystemPrompt = inputTokenBudget !== undefined;
293
- const systemSections = [
294
- (useCompactSystemPrompt
295
- ? renderCompactAgentSystemPrompt
296
- : renderAgentSystemPrompt)(toolNames.join(", ")),
297
- ];
298
- if (projectContext) {
299
- systemSections.push(`Project context from .clai/context.md:\n${projectContext}`);
300
- }
301
- if (freshWebSearchRequired) {
302
- systemSections.push(freshnessGuardMessage());
303
- }
263
+ const resolveNativeTools = (p, m) => {
264
+ const dialect = resolveToolDialect(p, m, config.toolCalling);
265
+ return { dialect, native: dialect !== "none" };
266
+ };
267
+ let { dialect: toolDialect, native: nativeToolsActive } = resolveNativeTools(provider, model);
268
+ const selectToolDefs = (native, compact) => {
269
+ if (!native)
270
+ return undefined;
271
+ const base = compact
272
+ ? getCompactToolDefinitions()
273
+ : getToolDefinitions();
274
+ const allow = new Set([...toolNames, ...PLAN_TOOL_NAMES]);
275
+ return base.filter((d) => allow.has(d.name));
276
+ };
304
277
  let lastAnswer = "";
305
278
  const session = options.session ?? createSessionPolicy();
306
279
  // Active plan context
@@ -308,7 +281,7 @@ export async function runAgentLoop(prompt, options = {}) {
308
281
  // context. When the user has approved it (via /implement) we instruct the
309
282
  // agent to execute task by task; otherwise the agent should refine/wait.
310
283
  const activePlan = await loadPlan(session.sessionId).catch(() => undefined);
311
- if (activePlan) {
284
+ if (activePlan && isPlanApprovedByStatus(activePlan.status)) {
312
285
  // session.planApproved is in-memory only (never persisted), so a
313
286
  // resumed session (via /history) or a fresh SessionPolicy after
314
287
  // context compaction always starts it back at false — even when the
@@ -316,30 +289,128 @@ export async function runAgentLoop(prompt, options = {}) {
316
289
  // completed via /implement. Re-derive the flag from the plan's status
317
290
  // on every load so resuming a session never re-blocks tool calls
318
291
  // behind a stale "awaiting approval" gate for a plan that already ran.
319
- if (isPlanApprovedByStatus(activePlan.status)) {
320
- session.planApproved.value = true;
292
+ session.planApproved.value = true;
293
+ }
294
+ const destinationHint = resolveUserDestinationHint(prompt);
295
+ const orientationSourceText = [
296
+ prompt,
297
+ activePlan?.goal,
298
+ activePlan?.detail,
299
+ activePlan?.tasks.map((task) => task.title).join(" "),
300
+ ].filter(Boolean).join("\n");
301
+ const fromPlan = extractProjectRootFromPlan(activePlan);
302
+ const fromPrompt = extractProjectRootFromText(prompt);
303
+ const guessedName = guessProjectFolderName(orientationSourceText);
304
+ const orientationParent = destinationHint ?? (isBareParentDirectory(safeCwd()) ? safeCwd() : undefined);
305
+ const guessedProject = orientationParent && guessedName ? join(orientationParent, guessedName) : undefined;
306
+ const discoveredProjects = orientationParent
307
+ ? discoverImmediateProjectRoots(orientationParent)
308
+ : [];
309
+ // Sticky project root so relative fs paths never hit the agent package.
310
+ // Preference is explicit durable plan metadata, explicit prompt paths,
311
+ // exact natural-language folder guesses, then one unambiguous discovered
312
+ // project. Never pin bare Desktop/home or invent a path before it exists.
313
+ let pinnedProject = false;
314
+ for (const candidate of [fromPlan, fromPrompt, guessedProject]) {
315
+ if (setActiveProjectRootIfValid(candidate)) {
316
+ pinnedProject = true;
317
+ break;
321
318
  }
319
+ }
320
+ if (!pinnedProject && discoveredProjects.length === 1) {
321
+ setActiveProjectRootIfValid(discoveredProjects[0]);
322
+ }
323
+ const buildSystemContent = (native) => {
324
+ const sections = [
325
+ (useCompactSystemPrompt
326
+ ? renderCompactAgentSystemPrompt
327
+ : renderAgentSystemPrompt)(toolNames.join(", "), {
328
+ nativeTools: native,
329
+ }),
330
+ ];
331
+ if (projectContext) {
332
+ sections.push(`Project context from .clai/context.md:\n${projectContext}`);
333
+ }
334
+ const projectRoot = getActiveProjectRoot();
335
+ if (projectRoot) {
336
+ sections.push(`ACTIVE PROJECT ROOT: ${projectRoot}\n` +
337
+ `All relative paths (./src/…, manifests, configs) resolve under this directory — NOT the agent process cwd. ` +
338
+ `Prefer absolute paths under this root. shell cwd for install / run / build must be this root ` +
339
+ `(or its parent when creating a NEW named subfolder with a scaffolder). ` +
340
+ `Never write user app source into the agent package tree.`);
341
+ }
342
+ else if (destinationHint) {
343
+ sections.push(`USER DESTINATION: create or continue work under "${destinationHint}" (parent folder). ` +
344
+ `Pick or detect a project subfolder; do not scaffold into the agent working tree unless the user asked for that.`);
345
+ }
346
+ // Stack-agnostic PWD / existing-project snapshot so weak models cannot
347
+ // skip explore and re-scaffold into non-empty dirs.
348
+ if (buildLikeTurn &&
349
+ !informationalQuery &&
350
+ !idleOrSocialPrompt) {
351
+ const guessedName = guessProjectFolderName([prompt, activePlan?.goal, activePlan?.detail, activePlan?.tasks.map((t) => t.title).join(" ")]
352
+ .filter(Boolean)
353
+ .join("\n"));
354
+ const extraPaths = [];
355
+ if (destinationHint && guessedName) {
356
+ extraPaths.push(join(destinationHint, guessedName));
357
+ }
358
+ const fromText = extractProjectRootFromPlan(activePlan) ??
359
+ extractProjectRootFromText(prompt);
360
+ if (fromText)
361
+ extraPaths.push(fromText);
362
+ const orientInput = {
363
+ cwd: safeCwd(),
364
+ extraPaths,
365
+ };
366
+ if (destinationHint)
367
+ orientInput.destinationHint = destinationHint;
368
+ const candidate = getActiveProjectRoot() ?? fromText;
369
+ if (candidate)
370
+ orientInput.candidateProject = candidate;
371
+ sections.push(buildWorkspaceOrientation(orientInput));
372
+ }
373
+ if (freshWebSearchRequired) {
374
+ sections.push(freshnessGuardMessage());
375
+ }
376
+ return sections.join("\n\n");
377
+ };
378
+ const systemSections = [buildSystemContent(nativeToolsActive)];
379
+ if (activePlan) {
322
380
  systemSections.push(planContextMessage(activePlan, session.planApproved.value));
323
381
  }
324
- // For build/scaffold turns with no active plan yet, inject an explicit
325
- // workflow so the agent does NOT rush to write files in one shot. It must
326
- // explore the directory, read the relevant existing files to understand
327
- // what's already there, create a comprehensive multi-task plan, then
328
- // implement task by task until the goal is met. This mirrors how a careful
329
- // coding agent (Claude Code) operates.
382
+ if (isPlanMode) {
383
+ systemSections.push(planModeDirective());
384
+ }
385
+ else if (agentMode === "agent") {
386
+ systemSections.push(agentModeDirective());
387
+ }
388
+ // Build focus card: orientation + feature quality, not forced plan theater.
330
389
  if (buildLikeTurn &&
331
- !activePlan &&
390
+ !informationalQuery &&
391
+ !idleOrSocialPrompt &&
392
+ !isPlanMode) {
393
+ systemSections.push(buildWorkflowDirective());
394
+ }
395
+ if (isPlanMode &&
396
+ buildLikeTurn &&
332
397
  !informationalQuery &&
333
398
  !idleOrSocialPrompt) {
334
399
  systemSections.push(buildWorkflowDirective());
335
400
  }
336
- // Pentest / security engagements need a different shape than a coding
401
+ // A bounded explicit nmap request is one operation, not an invitation to
402
+ // manufacture a full engagement plan or add unrelated recon steps.
403
+ if (narrowNmapOperation &&
404
+ !informationalQuery &&
405
+ !idleOrSocialPrompt &&
406
+ !isPlanMode) {
407
+ systemSections.push(narrowNmapOperationDirective());
408
+ }
409
+ // Broader pentest / security engagements need a different shape than a coding
337
410
  // build: recon first, then a plan built from real findings, then
338
- // incremental task additions as new attack surface appears. The
339
- // directive is only injected before a plan exists; once a plan is in
340
- // place (or being refined), the ACTIVE PLAN block already carries the
341
- // current task state and recon-vs-active-tool guidance.
411
+ // incremental task additions as new attack surface appears.
342
412
  if (pentestLikeTurn &&
413
+ !narrowNmapOperation &&
343
414
  !activePlan &&
344
415
  !informationalQuery &&
345
416
  !idleOrSocialPrompt) {
@@ -355,19 +426,90 @@ export async function runAgentLoop(prompt, options = {}) {
355
426
  if (pentestSession && !idleOrSocialPrompt) {
356
427
  systemSections.push(pentestNoLocalServerDirective());
357
428
  }
358
- const renderedSystemPrompt = systemSections.join("\n\n");
359
- // Reserve most of a constrained model's input budget for the user message,
360
- // recent conversation, tool results, and provider framing. Dynamic project
361
- // context or a saved plan must not silently grow the compact base prompt
362
- // back above the model's TPM ceiling.
363
- const maxSystemTokens = inputTokenBudget
364
- ? Math.min(2_000, Math.floor(inputTokenBudget * 0.4))
365
- : undefined;
366
- const systemTruncationNote = "\n\n[Additional system context omitted to fit the provider input budget.]";
367
- const fullSystemPrompt = maxSystemTokens !== undefined &&
368
- estimateTokens(renderedSystemPrompt) > maxSystemTokens
369
- ? renderedSystemPrompt.slice(0, Math.max(0, Math.floor(maxSystemTokens * 3.3) - systemTruncationNote.length)) + systemTruncationNote
370
- : renderedSystemPrompt;
429
+ {
430
+ const engScope = await loadScope().catch(() => undefined);
431
+ const scopeBlock = scopeContextMessage(engScope);
432
+ if (scopeBlock && (pentestSession || pentestLikeTurn) && !idleOrSocialPrompt) {
433
+ systemSections.push(scopeBlock);
434
+ }
435
+ }
436
+ // Soft task analysis for multi-step work (never a forced plan script).
437
+ {
438
+ const earlyAnalysis = analyzeTask(prompt);
439
+ if (!idleOrSocialPrompt &&
440
+ !informationalQuery &&
441
+ !narrowNmapOperation &&
442
+ (earlyAnalysis.shouldPlan ||
443
+ earlyAnalysis.complexity === "complex" ||
444
+ buildLikeTurn ||
445
+ pentestLikeTurn)) {
446
+ systemSections.push(formatTaskAnalysisHint(earlyAnalysis));
447
+ }
448
+ }
449
+ const promptSections = () => {
450
+ const sections = systemSections.slice(1).map((content) => ({
451
+ kind: content.startsWith("ACTIVE PLAN")
452
+ ? "plan"
453
+ : content.startsWith("ENGAGEMENT SCOPE")
454
+ ? "scope"
455
+ : content.includes("MODE")
456
+ ? "mode"
457
+ : content.includes("OUTCOME")
458
+ ? "outcome"
459
+ : content.includes("WORKFLOW") || content.includes("FOCUS")
460
+ ? "focus"
461
+ : "context",
462
+ content,
463
+ mandatory: content.startsWith("ACTIVE PLAN") ||
464
+ content.startsWith("ENGAGEMENT SCOPE") ||
465
+ content.includes("MODE") ||
466
+ content.includes("OUTCOME"),
467
+ }));
468
+ const has = (kind) => sections.some((section) => section.kind === kind);
469
+ if (!has("outcome")) {
470
+ sections.push({
471
+ kind: "outcome",
472
+ content: `OUTCOME CONTRACT\nGoal: ${prompt}\nSuccess requires evidence that the requested result is complete; otherwise return partial, blocked, failed, aborted, or paused_budget with remaining criteria.`,
473
+ mandatory: true,
474
+ });
475
+ }
476
+ if (!has("plan")) {
477
+ sections.push({
478
+ kind: "plan",
479
+ content: "ACTIVE PLAN\nNo persisted plan is active for this turn.",
480
+ mandatory: true,
481
+ });
482
+ }
483
+ if (!has("scope")) {
484
+ sections.push({
485
+ kind: "scope",
486
+ content: "ENGAGEMENT SCOPE\nNo active remote-security scope applies to this turn.",
487
+ mandatory: true,
488
+ });
489
+ }
490
+ sections.push({
491
+ kind: "context",
492
+ content: `TASK STATE\nMode: ${agentMode}. Current request: ${prompt}`,
493
+ mandatory: true,
494
+ });
495
+ return sections;
496
+ };
497
+ const composeCurrentSystemPrompt = (native) => composeAgentSystemPrompt({
498
+ mode: agentMode,
499
+ nativeToolsActive: native,
500
+ maxTokens: inputTokenBudget
501
+ ? Math.min(2_000, Math.floor(inputTokenBudget * 0.4))
502
+ : undefined,
503
+ sections: [
504
+ {
505
+ kind: "constitution",
506
+ content: buildSystemContent(native),
507
+ mandatory: true,
508
+ },
509
+ ...promptSections(),
510
+ ],
511
+ }).content;
512
+ const fullSystemPrompt = composeCurrentSystemPrompt(nativeToolsActive);
371
513
  const userMessage = { role: "user", content: prompt };
372
514
  if (options.images && options.images.length > 0) {
373
515
  userMessage.images = options.images;
@@ -378,6 +520,8 @@ export async function runAgentLoop(prompt, options = {}) {
378
520
  userMessage,
379
521
  ];
380
522
  liveMessages = messages;
523
+ /** Assigned after session flags exist — see below. */
524
+ let refreshSessionState = () => undefined;
381
525
  const recoveryUserMessage = (content) => {
382
526
  const message = { role: "user", content };
383
527
  if (options.images && options.images.length > 0) {
@@ -388,19 +532,6 @@ export async function runAgentLoop(prompt, options = {}) {
388
532
  }
389
533
  return message;
390
534
  };
391
- // Every provider must receive a syntactically valid assistant turn between
392
- // the original user prompt and a recovery nudge. In particular, Gemini
393
- // serializes an empty assistant message as an empty `model` text part,
394
- // which can cause every retry to return empty as well. Keep hidden thinking
395
- // out of history, but record a compact non-empty sentinel when there was no
396
- // visible output.
397
- //
398
- // The Ink reducer intentionally keeps streamed text transient until it
399
- // receives `assistant-message`, because a stream may turn out to be raw
400
- // tool JSON. Recovery paths used to add prose to model history and retry
401
- // without that event, so the user could watch a valid-looking response
402
- // vanish when the retry emitted its first tool call. Surface only prose
403
- // that is safe to render; malformed/bare tool payloads remain hidden.
404
535
  const recoveryProse = (content) => {
405
536
  const text = textBeforeToolCall(stripSentinelTokens(content)).trim();
406
537
  if (!text ||
@@ -411,109 +542,242 @@ export async function runAgentLoop(prompt, options = {}) {
411
542
  return text;
412
543
  };
413
544
  const pushAssistantHistory = (content) => {
414
- // Preserve genuine streamed prose before a recovery retry so the visible
415
- // text isn't wiped by the next tool-call/turn event. Skip when this
416
- // iteration already surfaced its prose (the normal tool path commits
417
- // `beforeTool` itself) so the same text is never rendered twice.
545
+ const cleaned = sanitizeAssistantText(content);
418
546
  if (!visibleCommitted) {
419
- const prose = recoveryProse(content);
547
+ const prose = recoveryProse(cleaned);
420
548
  if (prose)
421
549
  writeAssistantMessage(prose);
422
550
  }
423
551
  messages.push({
424
552
  role: "assistant",
425
- content: content.trim()
426
- ? content
553
+ content: cleaned.trim()
554
+ ? cleaned
427
555
  : "[No visible assistant response was produced.]",
428
556
  });
429
557
  };
430
- // Track recent tool calls to detect models stuck in a loop calling the
431
- // same tool with the same arguments over and over (e.g. pentest.recon
432
- // called 3× on the same target without summarizing).
433
558
  const loopGuard = new LoopGuard();
559
+ const engagementPolicy = new EngagementPolicyEngine();
434
560
  // Track consecutive thinking-only responses so we can nudge the model
435
561
  // to actually act instead of silently returning an empty answer.
436
562
  let emptyVisibleRetries = 0;
437
- // A model that spent an entire completion in hidden reasoning gets one
438
- // visible-output retry with provider thinking disabled. This is per-turn
439
- // only: a subsequent successful response restores the configured setting.
440
563
  let retryWithoutThinking = false;
441
564
  // Track tool calls truncated by the token limit so we can ask the model
442
565
  // to retry in smaller pieces instead of leaking broken JSON as an answer.
443
566
  let truncatedToolRetries = 0;
444
- // Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
445
- // so we can nudge the model to re-emit a proper fenced call a few times
446
- // before giving up, instead of leaking the JSON as a final answer.
447
567
  let bareToolJsonRetries = 0;
448
568
  // Track a ```tool fence that is present but whose JSON could not be parsed
449
569
  // (e.g. malformed extra/missing braces that are NOT simple truncation). We
450
570
  // retry instead of leaking the raw block as the final answer.
451
571
  let malformedFenceRetries = 0;
452
- // For volatile live-info prompts, make one corrective pass if a model
453
- // ignores the freshness guard and tries to answer from stale memory.
454
572
  let sawFreshWebSearch = false;
455
- let freshnessRetryUsed = false;
456
- // Guard against a model that declares an approved plan "complete" while
457
- // tasks are still pending and it never ran the work. We nudge it back to
458
- // executing the next task a bounded number of times before giving up.
459
- let prematureCompletionRetries = 0;
460
- let runtimeVerificationRetries = 0;
573
+ const recovery = createRecoveryBudgets();
461
574
  let sawServerStart = false;
575
+ let sawPlanCreateOk = false;
462
576
  let sawServerTail = false;
463
577
  let sawLocalHttpProbe = false;
464
- // Guard against a model that NARRATES intent ("let me explore the
465
- // directory…") but emits no tool call, so nothing runs and the turn ends
466
- // prematurely. On build/scaffold/plan turns where nothing has executed yet,
467
- // we nudge it to emit a real tool call instead of accepting the narration
468
- // as a final answer. Bounded so a model that truly can't emit the format
469
- // still terminates.
470
- let actionIntentRetries = 0;
471
- // Multi-tool execution queue
472
- // Models naturally emit several tool calls in one message e.g. the
473
- // plan-execution rhythm "task.update in_progress do the work
474
- // task.update done", or a batch of fs.write calls. Rather than running
475
- // only the first and discarding the rest (which made models believe work
476
- // ran when it didn't, and broke plan execution), we parse ALL calls in a
477
- // message, run the first this iteration, and queue the rest here to run on
478
- // subsequent iterations WITHOUT another model round-trip. The queue is
479
- // cleared whenever a call fails, is blocked, or needs the model to react,
480
- // so the model always sees errors and stays in control.
578
+ let sawFailedLocalHttpProbe = false;
579
+ let sawLocalAppMaterialWork = false;
580
+ let sawScaffoldOk = false;
581
+ let sawFeatureImplWrite = false;
582
+ let sawActivePentestTest = false;
583
+ const featureAppAsk = userAskedForFeatureApp(prompt);
584
+ let taskWorkLedger = null;
585
+ /**
586
+ * Successful real tools this turn that may not yet be credited to a task
587
+ * (preflight tool.check before in_progress, or work before plan existed).
588
+ * Absorbed into the task ledger when opening or marking done.
589
+ */
590
+ const sessionLooseWork = [];
591
+ const planHasVerifiedRuntime = (plan) => plan.tasks.some((task) => hasLocalRuntimeProof(task.evidence));
592
+ const planHasVerifiedRemoteWork = (plan) => plan.tasks.some((task) => hasRemoteWorkProof(task.evidence));
593
+ /** Rehydrate turn-local runtime/remote flags from durable plan evidence (resume). */
594
+ const rehydrateSessionFlagsFromPlan = (plan) => {
595
+ if (!plan)
596
+ return;
597
+ for (const task of plan.tasks) {
598
+ const e = task.evidence;
599
+ if (!e)
600
+ continue;
601
+ if (e.sawDevServerStart || e.sawServerReady || e.sawPortListening) {
602
+ sawServerStart = true;
603
+ }
604
+ if (e.sawServerReady || e.sawDevServerStart)
605
+ sawServerTail = true;
606
+ if (e.sawLocalHttpProbeOk)
607
+ sawLocalHttpProbe = true;
608
+ if (e.sawRemoteActiveTestOk)
609
+ sawActivePentestTest = true;
610
+ }
611
+ };
612
+ rehydrateSessionFlagsFromPlan(activePlan);
613
+ /** Merge loose turn work + live ledger for a task before evidence gates. */
614
+ const ledgerForTaskGate = (plan, taskId) => {
615
+ const task = plan.tasks.find((candidate) => candidate.id === taskId);
616
+ const durableLedger = ledgerFromTaskEvidence(taskId, task?.evidence);
617
+ let ledger = taskWorkLedger?.taskId === taskId &&
618
+ taskWorkLedger.successWorkCount >= durableLedger.successWorkCount
619
+ ? taskWorkLedger
620
+ : durableLedger;
621
+ ledger = absorbLooseWorkIntoLedger(ledger, taskId, task?.title ?? "", sessionLooseWork, { planKind: plan.kind });
622
+ // Keep the live ledger in sync so subsequent tools append correctly.
623
+ if (ledger && ledger.successWorkCount > 0) {
624
+ if (!taskWorkLedger ||
625
+ taskWorkLedger.taskId !== taskId ||
626
+ taskWorkLedger.successWorkCount < ledger.successWorkCount) {
627
+ taskWorkLedger = ledger;
628
+ }
629
+ }
630
+ return ledger;
631
+ };
632
+ const completionGateForTask = (plan, taskId) => {
633
+ const task = plan.tasks.find((candidate) => candidate.id === taskId);
634
+ const ledger = ledgerForTaskGate(plan, taskId);
635
+ return canMarkTaskDone(ledger, taskId, {
636
+ taskTitle: task?.title,
637
+ featureAppRequired: featureAppAsk,
638
+ existingProject: scaffoldLooksMaterialized(getActiveProjectRoot()),
639
+ runtimeVerified: planHasVerifiedRuntime(plan),
640
+ planKind: plan.kind,
641
+ remoteWorkVerified: planHasVerifiedRemoteWork(plan),
642
+ });
643
+ };
644
+ const reconcileOpenTaskBeforeFinalizing = async () => {
645
+ const plan = await loadPlan(session.sessionId).catch(() => undefined);
646
+ const open = plan?.tasks.find((task) => task.state === "in_progress");
647
+ if (!plan || !open)
648
+ return plan;
649
+ const gate = completionGateForTask(plan, open.id);
650
+ if (!gate.ok)
651
+ return plan;
652
+ const reconciledTaskIds = [open.id];
653
+ markTask(plan, open.id, "done", "Completion reconciled from verified task evidence.");
654
+ while (true) {
655
+ const observation = readyPlanTasks(plan).find((task) => isRuntimeObservationTask(task.title) ||
656
+ (plan.kind === "pentest" && isRemoteObservationTask(task.title)));
657
+ if (!observation)
658
+ break;
659
+ const observationGate = completionGateForTask(plan, observation.id);
660
+ if (!observationGate.ok)
661
+ break;
662
+ markTask(plan, observation.id, "done", plan.kind === "pentest"
663
+ ? "Satisfied by verified remote evidence from the preceding task."
664
+ : "Satisfied by the verified runtime evidence from the preceding task.");
665
+ reconciledTaskIds.push(observation.id);
666
+ }
667
+ if (plan.status === "draft" || plan.status === "approved") {
668
+ plan.status = "in_progress";
669
+ }
670
+ if (isPlanTerminal(plan)) {
671
+ plan.status = isPlanSuccessful(plan) ? "completed" : "abandoned";
672
+ }
673
+ await savePlan(plan).catch(() => undefined);
674
+ writePlanUpdate(plan, renderPlanForTerminal(plan) + "\n");
675
+ 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`));
676
+ taskWorkLedger = null;
677
+ return plan;
678
+ };
679
+ async function persistProjectRootOnPlan(root) {
680
+ const live = await loadPlan(session.sessionId).catch(() => undefined);
681
+ if (!live)
682
+ return;
683
+ const pm = detectPackageManager(root);
684
+ patchPlanMeta(live, {
685
+ projectRoot: root,
686
+ ...(pm ? { packageManager: pm } : {}),
687
+ });
688
+ await savePlan(live).catch(() => undefined);
689
+ }
690
+ refreshSessionState = (plan) => {
691
+ if (idleOrSocialPrompt || informationalQuery)
692
+ return;
693
+ if (!buildLikeTurn && !pentestLikeTurn && !plan && !activePlan)
694
+ return;
695
+ const p = plan ?? activePlan;
696
+ const root = getActiveProjectRoot() ?? p?.meta?.projectRoot;
697
+ const pm = p?.meta?.packageManager ??
698
+ (root ? detectPackageManager(root) : undefined);
699
+ const open = p?.tasks.find((t) => t.state === "in_progress");
700
+ const pending = p?.tasks
701
+ .filter((t) => t.state === "pending")
702
+ .map((t) => `[${t.id}] ${t.title}`);
703
+ const done = p?.tasks
704
+ .filter((t) => t.state === "done" || t.state === "skipped")
705
+ .map((t) => t.id);
706
+ const snap = {
707
+ goal: p?.goal ?? prompt.slice(0, 160),
708
+ projectRoot: root,
709
+ packageManager: pm,
710
+ planStatus: p?.status,
711
+ planKind: p?.kind,
712
+ openTask: open ? `[${open.id}] ${open.title}` : undefined,
713
+ pendingTasks: pending,
714
+ doneTasks: done,
715
+ featureAppRequired: featureAppAsk,
716
+ featureSeen: sawFeatureImplWrite,
717
+ scaffoldOk: sawScaffoldOk,
718
+ serverStarted: sawServerStart,
719
+ serverProbedOk: sawLocalHttpProbe,
720
+ lastProbeFailed: sawFailedLocalHttpProbe,
721
+ lastOkTool: taskWorkLedger?.lastOkTool,
722
+ engagementNote: pentestSession
723
+ ? "remote/security engagement — no local dev server as completion"
724
+ : undefined,
725
+ };
726
+ snap.nextHint = inferNextHint(snap);
727
+ upsertSessionStateMessage(messages, buildSessionStateBlock(snap));
728
+ };
729
+ refreshSessionState(activePlan);
481
730
  let pendingCalls = [];
482
- // Step budget
483
- // The budget governs how many *productive* steps (a tool execution or a
484
- // final answer) the agent may take. Recovery iterations — nudging a model
485
- // that only produced thinking, asking it to re-emit a malformed tool call,
486
- // a freshness retry, or a loop-guard summary — do NOT consume this budget;
487
- // they get a separate hard ceiling so a wedged model can't spin forever.
488
- //
489
- // Complexity is a coarse signal from prompt length, but short follow-up
490
- // prompts ("do it", "build fully on your own", "app is not complete") in
491
- // the middle of a multi-file build must NOT be capped like a one-shot
492
- // lookup — that was the reason a React scaffold stopped half-built after
493
- // 10 steps. We bump the budget when the prompt (or recent history) looks
494
- // like a build/scaffold or a continuation of one.
731
+ let narrowNmapDispatchCount = 0;
732
+ const deferredPostToolMessages = [];
495
733
  const analysis = analyzeTask(prompt);
496
734
  const hasHistory = (options.history?.length ?? 0) > 0;
497
735
  const buildLike = buildLikeTurn;
498
736
  const pentestLike = looksLikePentestTask(prompt, options.history);
499
- let stepBudget = analysis.complexity === "simple"
500
- ? 20
501
- : analysis.complexity === "standard"
502
- ? 40
503
- : maxSteps;
504
- if (buildLike || pentestLike) {
505
- // Scaffolding / multi-file work / pentest tasks need room.
506
- // Continuation prompts ("do it") inherit this too.
507
- stepBudget = Math.max(stepBudget, maxSteps);
508
- }
509
- else if (hasHistory) {
510
- // A follow-up to an ongoing task should never be capped tighter than a
511
- // standard one-shot, even if it's only a couple of words.
512
- stepBudget = Math.max(stepBudget, 40);
513
- }
514
- // Hard ceiling on total loop iterations (productive + recovery) so a model
515
- // stuck emitting only thinking or malformed calls can't loop indefinitely.
516
- let maxIterations = stepBudget * 3;
737
+ const continueExistingOutcome = /^(?:continue|resume|proceed|keep\s+going|finish|next)\b/i.test(prompt.trim()) ||
738
+ Boolean(activePlan && !isPlanTerminal(activePlan));
739
+ const outcomeState = await openOutcomeState({
740
+ sessionId: session.sessionId,
741
+ userIntent: prompt,
742
+ kind: inferOutcomeKind({ userIntent: prompt, buildLike, pentestLike }),
743
+ continueExisting: continueExistingOutcome,
744
+ });
745
+ await saveOutcomeState(outcomeState);
746
+ let governorState = createGovernorState();
747
+ let governorPauseReason;
748
+ let turnState = createTurnState();
749
+ const moveTurn = (to, reason) => {
750
+ if (turnState.state === to)
751
+ return;
752
+ try {
753
+ turnState = transitionTurn(turnState, to, reason);
754
+ }
755
+ catch {
756
+ // Recovery paths may skip an intermediate presentation state; route
757
+ // active work through verifying/exploring rather than forging state.
758
+ if (to === "succeeded" || to === "partial") {
759
+ if (turnState.state === "understanding") {
760
+ turnState = transitionTurn(turnState, "exploring", "response prepared for verification");
761
+ }
762
+ if (turnState.state === "acting" || turnState.state === "exploring") {
763
+ turnState = transitionTurn(turnState, "verifying", reason);
764
+ }
765
+ turnState = transitionTurn(turnState, to, reason);
766
+ }
767
+ }
768
+ };
769
+ let retryDependenciesChanged = false;
770
+ let retryEnvironmentChanged = false;
771
+ const stepBudget = computeStepBudget({
772
+ analysis,
773
+ maxSteps,
774
+ buildLike,
775
+ pentestLike,
776
+ hasHistory,
777
+ });
778
+ // Iteration count is only an emergency protection for recovery/model loops;
779
+ // normal continuation is governed by evidence and resource deltas above.
780
+ const maxIterations = Math.max(210, computeMaxIterations(stepBudget));
517
781
  let productiveSteps = 0;
518
782
  let step = -1;
519
783
  let nextToolEventId = 0;
@@ -532,10 +796,21 @@ export async function runAgentLoop(prompt, options = {}) {
532
796
  },
533
797
  };
534
798
  async function executeSingleTool(rawCall, toolEventId, parentSignal) {
535
- // Resolved once per call so the scratch-only exemption can compare the
536
- // model-supplied paths against the canonical per-project scratch root.
537
799
  const scratchDir = scratchDirFor(safeCwd());
538
800
  let call = normalizeToolCall(rawCall);
801
+ let dispatchedTaskId;
802
+ let engagementLease;
803
+ let engagementGraph;
804
+ let engagementRecord;
805
+ if (call.args?.__nativeParseError) {
806
+ const raw = String(call.args._raw ?? "").slice(0, 200);
807
+ const reason = "Tool call arguments were not valid JSON (truncated or malformed). " +
808
+ "Retry with smaller content, or use fs.writeMany / fs.append continuation. " +
809
+ (raw ? `Partial: ${raw}` : "");
810
+ const result = { ok: false, output: reason, exitCode: 1 };
811
+ emitToolResult(toolEventId, result, reason);
812
+ return { ok: false, call, result, contextOutput: reason };
813
+ }
539
814
  if (call.name === "image.ocr" && !imageOcrEnabled) {
540
815
  writeNotice("info", "skipped OCR because the original image is attached to the vision model", chalk.dim(" ℹ skipped OCR — inspecting the attached image directly\n"));
541
816
  const recoveryText = "The original image is attached to this message and you can inspect it directly. " +
@@ -543,14 +818,41 @@ export async function runAgentLoop(prompt, options = {}) {
543
818
  const result = { ok: true, output: recoveryText };
544
819
  return { ok: true, call, result, contextOutput: recoveryText };
545
820
  }
546
- const loopCheck = loopGuard.shouldBlock(call.name, call.args);
821
+ if (narrowNmapOperation) {
822
+ const allowed = new Set(["net.scan", "shell.tail", "shell.jobs"]);
823
+ if (!allowed.has(call.name)) {
824
+ const reason = `Narrow nmap request: ${call.name} was not run because the user requested only one nmap operation. ` +
825
+ `Call net.scan with the requested target/options; do not create a plan or add DNS, WHOIS, HTTP, recon, or vulnerability steps.`;
826
+ const result = { ok: false, output: reason, exitCode: 1 };
827
+ emitToolResult(toolEventId, result, reason);
828
+ return { ok: false, call, result, contextOutput: reason };
829
+ }
830
+ if (call.name === "net.scan") {
831
+ if (narrowNmapDispatchCount >= 1) {
832
+ const reason = "Narrow nmap request: a scan has already been dispatched this turn. " +
833
+ "Do not broaden or retry it automatically; report the existing result/job status and ask before another scan.";
834
+ const result = { ok: false, output: reason, exitCode: 1 };
835
+ emitToolResult(toolEventId, result, reason);
836
+ return { ok: false, call, result, contextOutput: reason };
837
+ }
838
+ narrowNmapDispatchCount += 1;
839
+ }
840
+ }
841
+ const retryReasonRaw = call.args._retryReason;
842
+ const retryReason = retryReasonRaw && typeof retryReasonRaw === "object"
843
+ ? {
844
+ code: String(retryReasonRaw.code ?? ""),
845
+ detail: String(retryReasonRaw.detail ?? ""),
846
+ }
847
+ : undefined;
848
+ const loopCheck = loopGuard.shouldBlock(call.name, call.args, {
849
+ dependenciesChanged: retryDependenciesChanged,
850
+ environmentChanged: retryEnvironmentChanged,
851
+ ...(retryReason ? { retryReason } : {}),
852
+ });
547
853
  if (loopCheck.block) {
548
- const isWrite = call.name === "fs.write" ||
549
- call.name === "fs.writeMany" ||
550
- call.name === "fs.edit" ||
551
- call.name === "fs.replaceLines" ||
552
- call.name === "fs.append";
553
- const reason = `${call.name} was already called with the same arguments — ${isWrite ? "moving on" : "forcing summary"}`;
854
+ const reason = loopCheck.reason ??
855
+ `${call.name} was already called with the same arguments. Use the prior result and choose a different next step.`;
554
856
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
555
857
  const result = { ok: false, output: reason, exitCode: 1 };
556
858
  return {
@@ -558,19 +860,123 @@ export async function runAgentLoop(prompt, options = {}) {
558
860
  call,
559
861
  result,
560
862
  contextOutput: reason,
561
- blockOrCancel: true,
562
863
  };
563
864
  }
564
865
  if (loopCheck.reason) {
565
866
  writeNotice("info", loopCheck.reason, chalk.dim(` ℹ ${loopCheck.reason}\n`));
566
867
  }
567
868
  if (call.name === "plan.create" || call.name === "task.update") {
869
+ // Evidence gate: refuse done until at least one successful work tool
870
+ // ran under this task (model must see results and be satisfied).
871
+ if (call.name === "task.update") {
872
+ const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
873
+ const taskIdRaw = typeof call.args.taskId === "string"
874
+ ? call.args.taskId
875
+ : typeof call.args.id === "string"
876
+ ? call.args.id
877
+ : "";
878
+ if (stateRaw === "done" && taskIdRaw) {
879
+ const live = await loadPlan(session.sessionId).catch(() => undefined);
880
+ const resolved = (live ? resolvePlanTaskId(live, taskIdRaw) : undefined) ??
881
+ taskIdRaw;
882
+ const target = live?.tasks.find((task) => task.id === resolved);
883
+ // Soft-auto: pending + deps complete is allowed through to plan-tool,
884
+ // which will open then complete in one call. Only hard-block when
885
+ // the task is not ready for that path (failed / deps / missing).
886
+ const depsIncomplete = target?.dependencies?.some((dependency) => {
887
+ const dependencyTask = live?.tasks.find((t) => t.id === dependency);
888
+ return (!dependencyTask ||
889
+ (dependencyTask.state !== "done" && dependencyTask.state !== "skipped"));
890
+ }) ?? false;
891
+ const canSoftComplete = target?.state === "pending" && !depsIncomplete;
892
+ const gate = !live
893
+ ? {
894
+ ok: false,
895
+ reason: `Task ${resolved} cannot be marked done because its active plan is unavailable.`,
896
+ }
897
+ : target?.state === "in_progress" || canSoftComplete
898
+ ? completionGateForTask(live, resolved)
899
+ : target?.state === "failed"
900
+ ? {
901
+ ok: false,
902
+ reason: `Task ${resolved} is failed — retry with in_progress first, then mark done after recovery work.`,
903
+ }
904
+ : {
905
+ ok: false,
906
+ reason: `Task ${resolved} must be in_progress before it can be marked done. Start or retry the task, perform fresh work, then complete it.`,
907
+ };
908
+ if (!gate.ok) {
909
+ writeNotice("warn", gate.reason, chalk.yellow(` ⚠ ${gate.reason}\n`));
910
+ if (!alreadyPrintedIds.has(toolEventId)) {
911
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
912
+ chalk.gray(` ${formatToolArgs(call)}`);
913
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
914
+ alreadyPrintedIds.add(toolEventId);
915
+ }
916
+ const result = {
917
+ ok: false,
918
+ output: gate.reason,
919
+ exitCode: 1,
920
+ };
921
+ emitToolResult(toolEventId, result, gate.reason);
922
+ writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
923
+ return {
924
+ ok: false,
925
+ call,
926
+ result,
927
+ contextOutput: gate.reason,
928
+ };
929
+ }
930
+ }
931
+ }
568
932
  const planResult = await handlePlanTool(call, session, {
569
933
  loopGuard,
570
934
  step,
935
+ autoApprove: !isPlanMode,
571
936
  });
572
937
  if (planResult.handled) {
573
938
  loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
939
+ if (planResult.ok && call.name === "task.update") {
940
+ const stateRaw = typeof call.args.state === "string" ? call.args.state : "";
941
+ const taskIdRaw = typeof call.args.taskId === "string"
942
+ ? call.args.taskId
943
+ : typeof call.args.id === "string"
944
+ ? call.args.id
945
+ : "";
946
+ const resolved = (planResult.plan
947
+ ? resolvePlanTaskId(planResult.plan, taskIdRaw)
948
+ : undefined) ?? taskIdRaw;
949
+ if (stateRaw === "in_progress" && resolved) {
950
+ // Keep accumulated evidence when recon already credited this task
951
+ // before an explicit in_progress (common on pentest plans).
952
+ // Also absorb turn-level preflight (tool.check before open).
953
+ const persisted = planResult.plan?.tasks.find((task) => task.id === resolved);
954
+ const baseLed = taskWorkLedger?.taskId === resolved
955
+ ? taskWorkLedger
956
+ : ledgerFromTaskEvidence(resolved, persisted?.evidence);
957
+ const led = absorbLooseWorkIntoLedger(baseLed, resolved, persisted?.title ?? "", sessionLooseWork, { planKind: planResult.plan?.kind }) ?? baseLed;
958
+ taskWorkLedger = led;
959
+ if (planResult.plan && led && led.successWorkCount > 0 && persisted) {
960
+ persisted.evidence = taskEvidenceFromLedger(led);
961
+ await savePlan(planResult.plan).catch(() => undefined);
962
+ }
963
+ }
964
+ else if (stateRaw === "done" && resolved) {
965
+ // Persist absorbed evidence before clearing the live ledger.
966
+ if (planResult.plan && taskWorkLedger?.taskId === resolved) {
967
+ const t = planResult.plan.tasks.find((x) => x.id === resolved);
968
+ if (t) {
969
+ t.evidence = taskEvidenceFromLedger(taskWorkLedger);
970
+ await savePlan(planResult.plan).catch(() => undefined);
971
+ }
972
+ }
973
+ taskWorkLedger = null;
974
+ }
975
+ else if ((stateRaw === "failed" || stateRaw === "skipped") &&
976
+ taskWorkLedger?.taskId === resolved) {
977
+ taskWorkLedger = null;
978
+ }
979
+ }
574
980
  if (!alreadyPrintedIds.has(toolEventId)) {
575
981
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
576
982
  writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
@@ -578,6 +984,10 @@ export async function runAgentLoop(prompt, options = {}) {
578
984
  }
579
985
  if (planResult.plan) {
580
986
  writePlanUpdate(planResult.plan, planResult.display);
987
+ // Refresh sticky root only if path already exists (not bare Desktop).
988
+ const root = extractProjectRootFromPlan(planResult.plan);
989
+ if (root)
990
+ setActiveProjectRootIfValid(root);
581
991
  }
582
992
  const result = { ok: planResult.ok, output: planResult.modelNote };
583
993
  emitToolResult(toolEventId, result, planResult.modelNote);
@@ -598,12 +1008,48 @@ export async function runAgentLoop(prompt, options = {}) {
598
1008
  decision,
599
1009
  scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
600
1010
  });
1011
+ const livePlanForPreGate = await loadPlan(session.sessionId).catch(() => undefined);
1012
+ // Plan mode: gather freely while the draft awaits accept. Once the user
1013
+ // approves (planApproved), mutates must run even if mode still says "plan"
1014
+ // for a beat — otherwise implement loops forever on gather-only blocks.
1015
+ if (isPlanMode &&
1016
+ !session.planApproved.value &&
1017
+ !isScratchOnlyWrite(call, scratchDir)) {
1018
+ const cmd = typeof call.args.command === "string" ? call.args.command : "";
1019
+ const shellBlocked = (call.name === "shell.exec" || call.name === "shell.start") &&
1020
+ !isPlanModeAllowedShellCommand(cmd);
1021
+ const allowed = isPlanModeAllowedTool(call.name) && !shellBlocked;
1022
+ if (!allowed) {
1023
+ const reason = `plan mode — ${call.name} is blocked (gather-only). ` +
1024
+ `Use any recon/enum/scan/research tool; do not write project files or run active exploits. ` +
1025
+ `Put exploit/implement steps in plan.create tasks for after accept. ` +
1026
+ `Accept the plan (y/i or /implement) to switch to agent and execute.`;
1027
+ writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1028
+ if (!alreadyPrintedIds.has(toolEventId)) {
1029
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
1030
+ chalk.gray(` ${formatToolArgs(call)}`);
1031
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1032
+ alreadyPrintedIds.add(toolEventId);
1033
+ }
1034
+ const result = { ok: false, output: reason, exitCode: 1 };
1035
+ emitToolResult(toolEventId, result, reason);
1036
+ writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
1037
+ return {
1038
+ ok: false,
1039
+ call,
1040
+ result,
1041
+ contextOutput: reason,
1042
+ };
1043
+ }
1044
+ }
601
1045
  const isMutatingAction = (decision.level === "confirm" || decision.level === "block") &&
602
1046
  !isPreApprovalAllowedTool(call.name) &&
603
1047
  !isScratchOnlyWrite(call, scratchDir);
604
1048
  if (isMutatingAction) {
605
- if (activePlan && !session.planApproved.value) {
606
- const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1049
+ const planNow = livePlanForPreGate ??
1050
+ (await loadPlan(session.sessionId).catch(() => undefined));
1051
+ if (planNow && !session.planApproved.value) {
1052
+ const reason = `plan awaiting approval — ${call.name} is blocked until the plan is accepted (/implement or Accept)`;
607
1053
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
608
1054
  const result = { ok: false, output: reason, exitCode: 1 };
609
1055
  return {
@@ -615,38 +1061,80 @@ export async function runAgentLoop(prompt, options = {}) {
615
1061
  };
616
1062
  }
617
1063
  }
618
- // Task-scoped execution gate
619
- // Once a plan is approved, every non-plan tool call must run while
620
- // exactly one task is "in_progress". This stops a model from batching
621
- // tool calls for many/all tasks in one turn and only touching task
622
- // state at the very end (or never) — the failure mode where a model
623
- // claimed most tasks "done" in prose without ever recording it in the
624
- // plan. Multiple tool calls per task are still fine; they just must be
625
- // bracketed by task.update in_progress → (work) → task.update done.
626
1064
  if (session.planApproved.value) {
627
1065
  const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
628
1066
  if (livePlanForGate) {
629
1067
  const unfinished = livePlanForGate.tasks.some((t) => t.state === "pending" || t.state === "in_progress");
630
1068
  const inProgress = livePlanForGate.tasks.find((t) => t.state === "in_progress");
631
1069
  if (unfinished && !inProgress) {
632
- const nextPending = livePlanForGate.tasks.find((t) => t.state === "pending");
633
- const reason = nextPending
634
- ? `${call.name} blocked — no task is in_progress. Call task.update {taskId:"${nextPending.id}", state:"in_progress"} before doing any work for it.`
635
- : `${call.name} blocked no task is in_progress.`;
636
- writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
637
- const result = { ok: false, output: reason, exitCode: 1 };
638
- // Recoverable ordering mistake (NOT a user/session control gate):
639
- // feed the reason back so the model marks the task in_progress and
640
- // retries within the same turn, rather than ending the turn.
641
- return {
642
- ok: false,
643
- call,
644
- result,
645
- contextOutput: `${reason}\nThis tool did NOT run. Emit task.update {state:"in_progress"} for the task first, then the work.`,
646
- };
1070
+ // tool.check / fs.list preflight: allow without auto-opening a task
1071
+ // (auto-start on preflight made models skip task.update and confused scope).
1072
+ const skipTaskGate = isPlanPreflightTool(call.name) ||
1073
+ (livePlanForGate.kind === "pentest" &&
1074
+ isReadOnlyReconTool(call.name));
1075
+ if (skipTaskGate) {
1076
+ // fall through
1077
+ }
1078
+ else {
1079
+ const pending = readyPlanTasks(livePlanForGate);
1080
+ // Title/command matching is only a soft ownership hint. If no
1081
+ // heuristic matches, preserve plan order instead of blocking.
1082
+ const nextPending = pickPendingTaskForToolCall(pending, call, livePlanForGate.tasks.map((t) => t.title)) ?? pending[0];
1083
+ if (nextPending) {
1084
+ markTask(livePlanForGate, nextPending.id, "in_progress");
1085
+ if (livePlanForGate.status === "draft" ||
1086
+ livePlanForGate.status === "approved") {
1087
+ livePlanForGate.status = "in_progress";
1088
+ }
1089
+ await savePlan(livePlanForGate).catch(() => undefined);
1090
+ // Preserve evidence already credited to this task (e.g. pentest
1091
+ // recon that ran before the task was formally opened).
1092
+ if (!taskWorkLedger ||
1093
+ taskWorkLedger.taskId !== nextPending.id) {
1094
+ taskWorkLedger = ledgerFromTaskEvidence(nextPending.id, nextPending.evidence);
1095
+ }
1096
+ writePlanUpdate(livePlanForGate, renderPlanForTerminal(livePlanForGate) + "\n");
1097
+ writeNotice("info", `auto-started [${nextPending.id}] so work can continue`, chalk.dim(` ℹ no task was in_progress — auto-started [${nextPending.id}] "${nextPending.title}" before ${call.name}\n`));
1098
+ }
1099
+ }
647
1100
  }
648
1101
  }
649
1102
  }
1103
+ call = applyDestinationCwd(call, destinationHint ?? getActiveProjectRoot());
1104
+ // Soft preflight: refuse scaffold into an existing non-empty project
1105
+ // (avoids endless "Operation cancelled" retries across all stacks).
1106
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1107
+ typeof call.args.command === "string" &&
1108
+ isScaffoldCreateCommand(call.args.command)) {
1109
+ const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
1110
+ const conflict = scaffoldTargetConflictMessage(call.args.command, cwdArg);
1111
+ if (conflict) {
1112
+ const target = resolveScaffoldTargetPath(call.args.command, cwdArg);
1113
+ const materialized = scaffoldLooksMaterialized(target);
1114
+ if (target && materialized && setActiveProjectRootIfValid(target, { force: true })) {
1115
+ await persistProjectRootOnPlan(target);
1116
+ }
1117
+ const message = materialized
1118
+ ? `Scaffold skipped: the target already contains a usable project${target ? ` at ${target}` : ""}. Continue that project directly; do not re-run the scaffolder.`
1119
+ : `Scaffold was not run: the existing target${target ? ` at ${target}` : ""} is incomplete. Inspect and repair it before completing the scaffold task; do not retry the scaffolder into this non-empty directory.`;
1120
+ writeNotice("info", message, chalk.dim(` ℹ ${message}\n`));
1121
+ if (!alreadyPrintedIds.has(toolEventId)) {
1122
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
1123
+ chalk.gray(` ${formatToolArgs(call)}`);
1124
+ writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
1125
+ alreadyPrintedIds.add(toolEventId);
1126
+ }
1127
+ const result = { ok: true, output: message, exitCode: 0 };
1128
+ emitToolResult(toolEventId, result, message);
1129
+ writeToolOutput(toolEventId, "ok\n", chalk.green(" ✓") + "\n");
1130
+ return {
1131
+ ok: true,
1132
+ call,
1133
+ result,
1134
+ contextOutput: message,
1135
+ };
1136
+ }
1137
+ }
650
1138
  if (call.name === "web.search") {
651
1139
  sawFreshWebSearch = true;
652
1140
  }
@@ -656,20 +1144,49 @@ export async function runAgentLoop(prompt, options = {}) {
656
1144
  alreadyPrintedIds.add(toolEventId);
657
1145
  }
658
1146
  const scopeTarget = scopeTargetForToolCall(call);
659
- if (scopeTarget &&
660
- (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
661
- writeNotice("info", `scope optional: ${scopeHint(scopeTarget)}`, chalk.dim(` scope optional: ${scopeHint(scopeTarget)}\n`));
1147
+ const engagementAction = pentestSession || isPentestToolCall(call) || Boolean(scope)
1148
+ ? engagementActionForToolCall(call)
1149
+ : undefined;
1150
+ const engagementDecision = engagementAction
1151
+ ? evaluateEngagementAction(scope, engagementAction)
1152
+ : undefined;
1153
+ if (engagementAction && engagementDecision) {
1154
+ if (scope) {
1155
+ engagementGraph = await openEngagement(scope);
1156
+ engagementRecord = beginEngagementAction(engagementGraph, {
1157
+ tool: call.name,
1158
+ target: engagementDecision.normalizedTarget || engagementAction.target,
1159
+ phase: engagementDecision.phase,
1160
+ capability: engagementDecision.capability,
1161
+ authorized: engagementDecision.allowed,
1162
+ reason: engagementDecision.reason,
1163
+ });
1164
+ await saveEngagement(engagementGraph);
1165
+ }
1166
+ await auditLog("engagement.policy", {
1167
+ ...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
1168
+ ...(engagementRecord ? { actionId: engagementRecord.id } : {}),
1169
+ tool: call.name,
1170
+ target: engagementDecision.normalizedTarget,
1171
+ phase: engagementDecision.phase,
1172
+ capability: engagementDecision.capability,
1173
+ allowed: engagementDecision.allowed,
1174
+ reason: engagementDecision.reason,
1175
+ });
1176
+ if (!engagementDecision.allowed) {
1177
+ const target = engagementDecision.normalizedTarget || scopeTarget || engagementAction.target;
1178
+ const reason = `Blocked engagement action for ${target}: ${engagementDecision.reason}. ` +
1179
+ scopeHint(target);
1180
+ writeToolBlocked(toolEventId, call.name, reason, chalk.red(` ✗ ${reason}\n`));
1181
+ const result = { ok: false, output: reason, exitCode: 1 };
1182
+ emitToolResult(toolEventId, result, reason);
1183
+ return { ok: false, call, result, contextOutput: reason };
1184
+ }
662
1185
  }
663
1186
  if (decision.level === "block") {
664
1187
  writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
665
1188
  const message = `Blocked: ${call.name} — ${decision.reason}`;
666
1189
  const result = { ok: false, output: message, exitCode: 1 };
667
- // Safety classifier blocks are recoverable model mistakes: feed the
668
- // failed result back to the model and let it choose a safer next step
669
- // instead of ending the entire agent turn. Hard workflow gates
670
- // (plan not approved, task not in_progress, auth declined, aborts)
671
- // still use blockOrCancel above/below because continuing would violate
672
- // user/session control rather than merely correcting a bad command.
673
1190
  return {
674
1191
  ok: false,
675
1192
  call,
@@ -703,29 +1220,43 @@ export async function runAgentLoop(prompt, options = {}) {
703
1220
  if (needsPentestAuth) {
704
1221
  pentestJustConfirmed = true;
705
1222
  }
706
- let forceManualConfirm = call.name === "fs.delete";
707
- if (call.name.startsWith("fs.") &&
708
- !isPreApprovalAllowedTool(call.name)) {
709
- const pathArg = typeof call.args.path === "string" ? call.args.path : undefined;
710
- if (pathArg) {
711
- const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\")
712
- ? join(homedir(), p.slice(2))
713
- : p === "~"
714
- ? homedir()
715
- : p;
716
- const resolved = resolve(expandHomeLocal(pathArg));
717
- const mode = call.name === "fs.read" ||
718
- call.name === "fs.list" ||
719
- call.name === "fs.search"
720
- ? "read"
721
- : "write";
722
- if (!pathInsideSandbox(resolved, mode)) {
723
- forceManualConfirm = true;
1223
+ // Always confirm destructive deletes and any write outside cwd —
1224
+ // even when permissions=allow-all or -y (user requirement).
1225
+ let forceConfirm = call.name === "fs.delete";
1226
+ if (call.name === "fs.write" ||
1227
+ call.name === "fs.writeMany" ||
1228
+ call.name === "fs.edit" ||
1229
+ call.name === "fs.append" ||
1230
+ call.name === "fs.replaceLines" ||
1231
+ call.name === "fs.delete") {
1232
+ const paths = [];
1233
+ if (typeof call.args.path === "string")
1234
+ paths.push(call.args.path);
1235
+ if (Array.isArray(call.args.files)) {
1236
+ for (const entry of call.args.files) {
1237
+ if (entry &&
1238
+ typeof entry === "object" &&
1239
+ typeof entry.path === "string") {
1240
+ paths.push(entry.path);
1241
+ }
1242
+ }
1243
+ }
1244
+ for (const p of paths) {
1245
+ try {
1246
+ if (isOutsideWorkingDirectory(resolveFsToolPath(p))) {
1247
+ forceConfirm = true;
1248
+ break;
1249
+ }
1250
+ }
1251
+ catch {
1252
+ forceConfirm = true;
1253
+ break;
724
1254
  }
725
1255
  }
726
1256
  }
727
- if (decision.level === "confirm" && !pentestJustConfirmed) {
728
- const ok = await confirmToolExecution(call, forceManualConfirm ? false : Boolean(options.autoConfirm), session, confirmPort);
1257
+ if ((decision.level === "confirm" || forceConfirm) &&
1258
+ !pentestJustConfirmed) {
1259
+ const ok = await confirmToolExecution(call, forceConfirm ? false : Boolean(options.autoConfirm), session, confirmPort, forceConfirm ? { forceConfirm: true } : undefined);
729
1260
  restoreInteractiveStdin();
730
1261
  if (!ok) {
731
1262
  const lastAnswer = "Cancelled.";
@@ -746,24 +1277,40 @@ export async function runAgentLoop(prompt, options = {}) {
746
1277
  releasePrompt();
747
1278
  }
748
1279
  parentSignal.throwIfAborted();
1280
+ const planAtDispatch = await loadPlan(session.sessionId).catch(() => undefined);
1281
+ dispatchedTaskId = planAtDispatch?.tasks.find((task) => task.state === "in_progress")?.id;
1282
+ if (!dispatchedTaskId && planAtDispatch?.kind === "pentest") {
1283
+ const candidate = pickPendingTaskForToolCall(readyPlanTasks(planAtDispatch), call, planAtDispatch.tasks.map((task) => task.title));
1284
+ dispatchedTaskId = candidate?.id;
1285
+ }
1286
+ if (dispatchedTaskId &&
1287
+ (!taskWorkLedger || taskWorkLedger.taskId !== dispatchedTaskId)) {
1288
+ const dispatchedTask = planAtDispatch?.tasks.find((task) => task.id === dispatchedTaskId);
1289
+ taskWorkLedger = ledgerFromTaskEvidence(dispatchedTaskId, dispatchedTask?.evidence);
1290
+ }
1291
+ if (engagementAction) {
1292
+ engagementLease = engagementPolicy.acquire(scope, engagementAction);
1293
+ if (!engagementLease.decision.allowed) {
1294
+ const reason = `Blocked engagement action: ${engagementLease.decision.reason}`;
1295
+ const result = { ok: false, output: reason, exitCode: 1 };
1296
+ emitToolResult(toolEventId, result, reason);
1297
+ return { ok: false, call, result, contextOutput: reason };
1298
+ }
1299
+ }
1300
+ if (turnState.state === "understanding" || turnState.state === "exploring") {
1301
+ moveTurn("acting", `executing ${call.name}`);
1302
+ }
749
1303
  options.onToolStart?.(call);
750
1304
  // Card was "queued" since writeToolCall; flip to running only when work starts.
751
1305
  emit({ type: "tool-start", id: toolEventId });
752
1306
  writeStatus(call.name, chalk.dim(` → ${call.name}\n`));
753
- const interactiveCommand = (call.name === "shell.exec" &&
754
- typeof call.args.command === "string" &&
755
- looksInteractiveStdin(call.args.command)) ||
756
- call.name === "net.scan" ||
757
- call.name === "pentest.recon";
758
- if (interactiveCommand && process.stdin.isTTY) {
759
- writeNotice("warn", "this command may prompt for a password — type it when asked", chalk.yellow(" ⚠ this command may prompt for a password — type it when asked\n"));
760
- }
1307
+ // Elevation uses the secure secret modal (TUI) or is refused — never
1308
+ // a raw TTY "Password:" that freezes the UI. No misleading notice.
761
1309
  const toolAc = new AbortController();
762
1310
  const onParentAbort = () => toolAc.abort();
763
1311
  parentSignal.addEventListener("abort", onParentAbort);
764
1312
  let result;
765
1313
  let liveBytes = 0;
766
- const shouldDimLive = !interactiveCommand;
767
1314
  // Stream every live byte — never drop mid-run. After the tool finishes we
768
1315
  // still replace the spool with the authoritative full `result.output`.
769
1316
  const printLive = (chunk) => {
@@ -776,22 +1323,34 @@ export async function runAgentLoop(prompt, options = {}) {
776
1323
  liveBytes += chunk.length;
777
1324
  const indented = chunk.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
778
1325
  const body = indented.startsWith("\n") ? indented : ` ${indented}`;
779
- writeToolOutput(toolEventId, chunk, shouldDimLive ? chalk.dim(body) : body);
1326
+ writeToolOutput(toolEventId, chunk, chalk.dim(body));
780
1327
  };
781
1328
  const jobId = randomUUID().slice(0, 8);
1329
+ const emptyJobArtifact = () => ({
1330
+ path: "",
1331
+ chunks: [],
1332
+ bytes: 0,
1333
+ droppedBytes: 0,
1334
+ redacted: false,
1335
+ sha256: "",
1336
+ });
782
1337
  const backgroundJob = {
783
1338
  id: jobId,
784
1339
  command: `${call.name} ${formatToolArgs(call)}`,
1340
+ commandDisplay: `${call.name} ${formatToolArgs(call)}`,
785
1341
  cwd: safeCwd(),
786
1342
  status: "running",
787
1343
  startedAt: new Date().toISOString(),
788
1344
  artifactPath: "",
1345
+ stdoutArtifact: "",
1346
+ stderrArtifact: "",
1347
+ artifacts: { stdout: emptyJobArtifact(), stderr: emptyJobArtifact() },
1348
+ redactionProfile: "provider-secrets-v1",
1349
+ ownerSessionId: session.sessionId,
789
1350
  };
790
1351
  jobManager.registerJob(jobId, backgroundJob, toolAc);
791
- // Long-lived commands should use shell.start/background jobs. Reset this
792
- // watchdog whenever a blocking tool emits output so only a genuinely
793
- // stalled operation is cancelled.
794
- const TOOL_STALL_ABORT_MS = 60_000; // 1 minute
1352
+ const TOOL_STALL_ABORT_MS = toolStallBudgetMs(call);
1353
+ const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
795
1354
  let stallTimer;
796
1355
  let stalledByWatchdog = false;
797
1356
  const resetStallTimer = () => {
@@ -800,7 +1359,7 @@ export async function runAgentLoop(prompt, options = {}) {
800
1359
  stallTimer = setTimeout(() => {
801
1360
  if (!toolAc.signal.aborted) {
802
1361
  stalledByWatchdog = true;
803
- writeNotice("warn", `${call.name} has been running for >60s — cancelling stalled tool`, chalk.yellow(` ⏳ ${call.name} stalled for >60s — cancelling\n`));
1362
+ writeNotice("warn", `${call.name} has been running for >${stallSecs}s without output — cancelling stalled tool`, chalk.yellow(` ⏳ ${call.name} stalled for >${stallSecs}s without output — cancelling\n`));
804
1363
  toolAc.abort();
805
1364
  }
806
1365
  }, TOOL_STALL_ABORT_MS);
@@ -818,6 +1377,33 @@ export async function runAgentLoop(prompt, options = {}) {
818
1377
  },
819
1378
  confirmed: true,
820
1379
  userPrompt: prompt,
1380
+ ...(engagementAction && scope
1381
+ ? {
1382
+ engagementAuthorization: {
1383
+ target: engagementDecision?.normalizedTarget || engagementAction.target,
1384
+ ...(scope.expiresAt ? { expiresAt: scope.expiresAt } : {}),
1385
+ },
1386
+ authorizeNetworkHop: async (url, resolvedAddresses) => {
1387
+ const hop = actionFromUrl({
1388
+ url,
1389
+ method: engagementAction.method,
1390
+ phase: engagementAction.phase,
1391
+ capability: engagementAction.capability,
1392
+ resolvedAddresses,
1393
+ });
1394
+ const hopDecision = evaluateEngagementAction(scope, hop);
1395
+ await auditLog("engagement.policy.hop", {
1396
+ ...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
1397
+ ...(engagementRecord ? { actionId: engagementRecord.id } : {}),
1398
+ url,
1399
+ resolvedAddresses,
1400
+ allowed: hopDecision.allowed,
1401
+ reason: hopDecision.reason,
1402
+ });
1403
+ return { allowed: hopDecision.allowed, reason: hopDecision.reason };
1404
+ },
1405
+ }
1406
+ : {}),
821
1407
  });
822
1408
  if (liveBytes > 0) {
823
1409
  writeToolOutput(toolEventId, "\n", "\n");
@@ -827,10 +1413,6 @@ export async function runAgentLoop(prompt, options = {}) {
827
1413
  catch (toolError) {
828
1414
  jobManager.updateJobStatus(jobId, "failed", 1);
829
1415
  if (isAbortError(toolError, toolAc.signal)) {
830
- // Only the parent signal represents a user/session cancellation.
831
- // A watchdog abort is a local tool timeout; treating it as a global
832
- // abort used to end the entire agent turn and strand sibling recon
833
- // calls in an incomplete state.
834
1416
  if (parentSignal.aborted) {
835
1417
  writeAbort();
836
1418
  return {
@@ -857,8 +1439,85 @@ export async function runAgentLoop(prompt, options = {}) {
857
1439
  finally {
858
1440
  if (stallTimer)
859
1441
  clearTimeout(stallTimer);
1442
+ engagementLease?.release();
860
1443
  parentSignal.removeEventListener("abort", onParentAbort);
861
1444
  }
1445
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1446
+ typeof call.args.command === "string" &&
1447
+ isScaffoldCreateCommand(call.args.command)) {
1448
+ const cmd = call.args.command;
1449
+ const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
1450
+ const out = result.output ?? "";
1451
+ // Prefer path reported by the scaffolder (handles quoted-cd mis-parse leftovers).
1452
+ const fromOutput = out.match(/Scaffolding project in\s+([^\n]+?)\s*\.{0,3}\s*$/im)?.[1]?.trim().replace(/['"]/g, "");
1453
+ const fromScaffold = (fromOutput && fromOutput.startsWith("/")
1454
+ ? fromOutput
1455
+ : undefined) ??
1456
+ extractProjectRootFromScaffold(cmd, cwdArg);
1457
+ const cancelled = isScaffoldCancelledOutput(out);
1458
+ let materialized = scaffoldLooksMaterialized(fromScaffold);
1459
+ // One re-check: create-vite can report success before FS snapshot is visible.
1460
+ if (!materialized && fromScaffold) {
1461
+ materialized = scaffoldLooksMaterialized(fromScaffold);
1462
+ }
1463
+ const abortedMid = !result.ok &&
1464
+ (result.exitCode === 124 ||
1465
+ result.exitCode === 130 ||
1466
+ /timed out|aborted|Command aborted/i.test(out));
1467
+ const resumableMaterialized = Boolean(fromScaffold && materialized && (cancelled || abortedMid || !result.ok));
1468
+ if (resumableMaterialized && fromScaffold) {
1469
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1470
+ await persistProjectRootOnPlan(fromScaffold);
1471
+ result = {
1472
+ ...result,
1473
+ ok: true,
1474
+ exitCode: 0,
1475
+ output: out +
1476
+ (out.endsWith("\n") ? "" : "\n") +
1477
+ `The scaffold reported ${cancelled ? "cancellation/refusal" : "interruption"}, but a usable project tree already exists at ${fromScaffold} ` +
1478
+ `(package/manifest present). Treat this as resumable: do NOT re-run the scaffolder. ` +
1479
+ `Inspect the existing files, finish any missing install, implement the requested feature, then run/verify.`,
1480
+ };
1481
+ writeNotice("info", `project root → ${fromScaffold} (existing materialized scaffold — continue)`, chalk.dim(` ℹ existing scaffold at ${fromScaffold} — continue, do not re-create\n`));
1482
+ }
1483
+ else if (result.ok && cancelled && !materialized) {
1484
+ result = {
1485
+ ok: false,
1486
+ output: out +
1487
+ (out.endsWith("\n") ? "" : "\n") +
1488
+ `Scaffold FAILED: tool reported cancel/refuse. ` +
1489
+ (fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
1490
+ `If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
1491
+ exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
1492
+ };
1493
+ }
1494
+ else if (result.ok && !materialized) {
1495
+ // Soft warn only when we truly see no tree — do not flip ok if output
1496
+ // clearly scaffolded (path may still resolve on next tool).
1497
+ const claimedScaffold = /Scaffolding project in\b/i.test(out);
1498
+ if (!claimedScaffold) {
1499
+ result = {
1500
+ ok: false,
1501
+ output: out +
1502
+ (out.endsWith("\n") ? "" : "\n") +
1503
+ `Scaffold FAILED: target project tree was not created. ` +
1504
+ (fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
1505
+ `If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
1506
+ exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
1507
+ };
1508
+ }
1509
+ else if (fromScaffold) {
1510
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1511
+ await persistProjectRootOnPlan(fromScaffold);
1512
+ writeNotice("info", `project root → ${fromScaffold} (scaffold output claimed success — continue)`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1513
+ }
1514
+ }
1515
+ else if (result.ok && fromScaffold && materialized) {
1516
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1517
+ await persistProjectRootOnPlan(fromScaffold);
1518
+ writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1519
+ }
1520
+ }
862
1521
  const output = result.output.trim();
863
1522
  // Always keep a full on-disk copy of tool output (any size) so the
864
1523
  // pager never depends on a truncated in-memory preview.
@@ -884,15 +1543,177 @@ export async function runAgentLoop(prompt, options = {}) {
884
1543
  exitCode: result.exitCode,
885
1544
  output: result.output.slice(0, 4_000),
886
1545
  });
1546
+ if (engagementGraph && engagementRecord) {
1547
+ if (result.backgroundJob) {
1548
+ const checkpointInput = {
1549
+ jobId: result.backgroundJob.id,
1550
+ status: result.backgroundJob.status,
1551
+ artifactPath: result.backgroundJob.artifactPath,
1552
+ offset: result.backgroundJob.nextOffset ?? 0,
1553
+ observation: result.output.slice(0, 16_000),
1554
+ };
1555
+ const reconciled = reconcileEngagementJob(engagementGraph, checkpointInput);
1556
+ if (!reconciled || reconciled.actionId !== engagementRecord.id) {
1557
+ recordEngagementCheckpoint(engagementGraph, {
1558
+ actionId: engagementRecord.id,
1559
+ ...checkpointInput,
1560
+ });
1561
+ }
1562
+ }
1563
+ else {
1564
+ finishEngagementAction(engagementGraph, engagementRecord.id, {
1565
+ ok: result.ok,
1566
+ observation: result.output.slice(0, 16_000),
1567
+ ...(savedOutputPath ? { artifactPath: savedOutputPath } : {}),
1568
+ scannerLead: call.name === "net.scan" || call.name.startsWith("pentest."),
1569
+ });
1570
+ }
1571
+ await saveEngagement(engagementGraph);
1572
+ }
1573
+ const newEvidence = recordToolEvidence(outcomeState, {
1574
+ tool: call.name,
1575
+ callId: toolEventId,
1576
+ ok: result.ok,
1577
+ output: result.output,
1578
+ ...(savedOutputPath ? { artifact: savedOutputPath } : {}),
1579
+ ...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
1580
+ args: call.args,
1581
+ });
1582
+ let hypothesisDelta = 0;
1583
+ if (!result.ok) {
1584
+ const before = outcomeState.failedHypotheses.length;
1585
+ recordFailedHypothesis(outcomeState, {
1586
+ signature: `${call.name}:${result.exitCode ?? 1}`,
1587
+ premise: `${call.name} with ${JSON.stringify(call.args).slice(0, 1_000)}`,
1588
+ });
1589
+ hypothesisDelta = outcomeState.failedHypotheses.length - before;
1590
+ retryDependenciesChanged = false;
1591
+ retryEnvironmentChanged = false;
1592
+ moveTurn("exploring", `${call.name} failed; revise the premise`);
1593
+ }
1594
+ else {
1595
+ const mutatesDependencies = /^(?:fs\.(?:write|writeMany|edit|replaceLines|append|delete)|pkg\.install)$/.test(call.name) ||
1596
+ ((call.name === "shell.exec" || call.name === "shell.start") &&
1597
+ /\b(?:install|mkdir|create|generate|build)\b/i.test(String(call.args.command ?? "")));
1598
+ retryDependenciesChanged ||= mutatesDependencies;
1599
+ retryEnvironmentChanged ||=
1600
+ call.name === "pkg.install" ||
1601
+ ((call.name === "shell.exec" || call.name === "shell.start") &&
1602
+ isPackageInstallCommand(String(call.args.command ?? "")));
1603
+ }
1604
+ const governed = governProgress(governorState, "activity", {
1605
+ evidenceDelta: newEvidence.length,
1606
+ hypothesisDelta,
1607
+ repetitionScore: loopGuard.getAttemptCount(call.name, call.args) > 1 ? 1 : 0,
1608
+ policy: {
1609
+ resourceEnvelope: Math.max(12, maxSteps),
1610
+ emergencyCeiling: Math.max(70, maxSteps * 3),
1611
+ reflectionAfterNoDelta: 3,
1612
+ pauseAfterNoDelta: 6,
1613
+ repetitionThreshold: 0.8,
1614
+ },
1615
+ });
1616
+ governorState = governed.state;
1617
+ if (governed.recommendation === "reflect") {
1618
+ deferredPostToolMessages.push({
1619
+ role: "system",
1620
+ content: `PROGRESS GOVERNOR: ${governed.reason}. Reassess the current premise and choose the next action that can produce criterion-linked evidence.`,
1621
+ });
1622
+ }
1623
+ else if (governed.recommendation === "paused_budget") {
1624
+ governorPauseReason = governed.reason;
1625
+ }
1626
+ await saveOutcomeState(outcomeState);
887
1627
  loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
888
- // Inject approach evaluation when consecutive failures are detected.
889
- // Lets the MODEL decide (with full context) whether to continue a
890
- // legitimately long approach, switch, or stop — instead of a
891
- // hardcoded kill threshold.
1628
+ // Evidence for verify-before-done: only successful real work counts.
1629
+ if (result.ok && isEvidenceWorkTool(call.name)) {
1630
+ const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
1631
+ // Never credit whichever task happens to be open after execution: a
1632
+ // later task.update in the same batch may already have changed it.
1633
+ const creditId = dispatchedTaskId;
1634
+ const cmd = typeof call.args.command === "string" ? call.args.command : "";
1635
+ const signals = {};
1636
+ if (isFeatureImplementationCall(call))
1637
+ signals.featureWrite = true;
1638
+ if (call.name === "fs.write" ||
1639
+ call.name === "fs.writeMany" ||
1640
+ call.name === "fs.edit" ||
1641
+ call.name === "fs.replaceLines" ||
1642
+ call.name === "fs.append") {
1643
+ signals.sourceWrite = true;
1644
+ }
1645
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1646
+ isPackageInstallCommand(cmd)) {
1647
+ signals.installOk = true;
1648
+ }
1649
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1650
+ isScaffoldCreateCommand(cmd)) {
1651
+ signals.scaffoldOk = true;
1652
+ }
1653
+ if (isDevServerCall(call))
1654
+ signals.devServerStart = true;
1655
+ const out = result.output ?? "";
1656
+ if ((call.name === "shell.tail" || call.name === "shell.start") &&
1657
+ isServerReadyOutput(out)) {
1658
+ signals.serverReady = true;
1659
+ }
1660
+ if (call.name === "shell.exec" &&
1661
+ isPortListeningOutput(cmd, out)) {
1662
+ signals.portListening = true;
1663
+ }
1664
+ if (localHttpProbeIsSuccess(out) ||
1665
+ (sawLocalHttpProbe && !sawFailedLocalHttpProbe)) {
1666
+ // Prefer explicit success parse on this result
1667
+ if (/\b(localhost|127\.0\.0\.1)\b/i.test(`${call.name} ${cmd} ${JSON.stringify(call.args)}`)) {
1668
+ if (localHttpProbeIsSuccess(out)) {
1669
+ signals.localHttpProbeOk = true;
1670
+ }
1671
+ }
1672
+ }
1673
+ // Remote/pentest evidence — never conflate with local app runtime
1674
+ if (isRemoteReconToolCall(call))
1675
+ signals.remoteReconOk = true;
1676
+ if (isRemoteActiveTestCall(call))
1677
+ signals.remoteActiveTestOk = true;
1678
+ // Always bank the success for later absorb (preflight / no open task).
1679
+ sessionLooseWork.push({
1680
+ toolName: call.name,
1681
+ ...(Object.keys(signals).length > 0 ? { signals } : {}),
1682
+ });
1683
+ taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, creditId, call.name, signals);
1684
+ // If nothing was open, still try to attach to the next ready explore
1685
+ // task so "Check Node/npm" can complete without thrash.
1686
+ if ((!creditId || !taskWorkLedger || taskWorkLedger.taskId !== creditId) && liveAfter) {
1687
+ const ready = readyPlanTasks(liveAfter)[0];
1688
+ if (ready) {
1689
+ const absorbed = absorbLooseWorkIntoLedger(ledgerFromTaskEvidence(ready.id, ready.evidence), ready.id, ready.title, [{ toolName: call.name, signals }], { planKind: liveAfter.kind });
1690
+ if (absorbed && absorbed.successWorkCount > 0) {
1691
+ const task = liveAfter.tasks.find((t) => t.id === ready.id);
1692
+ if (task) {
1693
+ task.evidence = taskEvidenceFromLedger(absorbed);
1694
+ if (!taskWorkLedger ||
1695
+ taskWorkLedger.taskId !== ready.id ||
1696
+ taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
1697
+ taskWorkLedger = absorbed;
1698
+ }
1699
+ await savePlan(liveAfter).catch(() => undefined);
1700
+ }
1701
+ }
1702
+ }
1703
+ }
1704
+ if (liveAfter && creditId && taskWorkLedger?.taskId === creditId) {
1705
+ const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
1706
+ if (task) {
1707
+ task.evidence = taskEvidenceFromLedger(taskWorkLedger);
1708
+ await savePlan(liveAfter).catch(() => undefined);
1709
+ }
1710
+ }
1711
+ refreshSessionState(liveAfter);
1712
+ }
892
1713
  if (!result.ok) {
893
1714
  const reflection = loopGuard.getFailureReflection();
894
1715
  if (reflection) {
895
- messages.push({ role: "system", content: reflection });
1716
+ deferredPostToolMessages.push({ role: "system", content: reflection });
896
1717
  const failCount = loopGuard.consecutiveFailureCount();
897
1718
  writeNotice("warn", `${failCount} consecutive failures — model evaluating approach`, chalk.yellow(` ⚠ ${failCount} consecutive failures — evaluating approach\n`));
898
1719
  }
@@ -931,9 +1752,6 @@ export async function runAgentLoop(prompt, options = {}) {
931
1752
  artifactPath: savedOutputPath,
932
1753
  summary: contextOutput,
933
1754
  });
934
- // Classic REPL only: short path notice. Never push long
935
- // "Ctrl+O or /output last … (path)" strings into the TUI status
936
- // footer — they collide with activity and garble the chrome.
937
1755
  if (writesDirectly && savedOutputPath) {
938
1756
  const short = chalk.dim(` saved ${savedOutputPath}\n`);
939
1757
  process.stdout.write(short);
@@ -945,15 +1763,6 @@ export async function runAgentLoop(prompt, options = {}) {
945
1763
  }
946
1764
  return { ok: result.ok, call, result, contextOutput };
947
1765
  }
948
- // Automatic context compaction
949
- // As a long turn accumulates tool outputs and reasoning, the context can
950
- // grow past what the model can hold. We proactively summarize the older
951
- // turns into a single continuation memory (the SAME model-written summary
952
- // the /compact command uses — never a mechanical transcript dump) and then
953
- // re-inject the ACTIVE PLAN so the agent never loses track of the plan,
954
- // what is done, and what remains. The estimate is chars/4; the budget is
955
- // deliberately conservative so we compact a little early rather than hit a
956
- // provider context-window error mid-task.
957
1766
  const AUTO_COMPACT_KEEP_RECENT = 6;
958
1767
  let lastCompactionMsgCount = 0;
959
1768
  const summarizeForCompaction = async (summaryPrompt) => {
@@ -961,10 +1770,7 @@ export async function runAgentLoop(prompt, options = {}) {
961
1770
  provider,
962
1771
  model,
963
1772
  messages: [
964
- {
965
- role: "system",
966
- content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
967
- },
1773
+ { role: "system", content: COMPACTION_SYSTEM_PROMPT },
968
1774
  { role: "user", content: summaryPrompt },
969
1775
  ],
970
1776
  temperature: 0.1,
@@ -1000,6 +1806,8 @@ export async function runAgentLoop(prompt, options = {}) {
1000
1806
  content: planContextMessage(livePlan, session.planApproved.value),
1001
1807
  });
1002
1808
  }
1809
+ // Re-inject live SESSION STATE after compaction (older flags survive).
1810
+ refreshSessionState(livePlan);
1003
1811
  lastCompactionMsgCount = messages.length;
1004
1812
  // Final count the model actually receives (may include re-injected plan).
1005
1813
  const afterTokens = estimateMessagesTokens(messages);
@@ -1008,10 +1816,6 @@ export async function runAgentLoop(prompt, options = {}) {
1008
1816
  estimatedTokens: afterTokens,
1009
1817
  reason,
1010
1818
  });
1011
- // Extract the inserted compaction memory so we can surface the
1012
- // summary itself (not just token-count stats). The summary lives in
1013
- // the first system message whose content begins with
1014
- // COMPACTION_MEMORY_PREFIX.
1015
1819
  const insertedSummary = messages.find((m) => m.role === "system" &&
1016
1820
  m.content.startsWith(COMPACTION_MEMORY_PREFIX))?.content ?? "";
1017
1821
  const summaryText = insertedSummary.startsWith(`${COMPACTION_MEMORY_PREFIX}\n\n`)
@@ -1037,84 +1841,32 @@ export async function runAgentLoop(prompt, options = {}) {
1037
1841
  }
1038
1842
  }
1039
1843
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1040
- // Each model round-trip re-decides whether its visible prose has been
1041
- // committed, so recovery-prose preservation applies to THIS turn's
1042
- // stream and not a prior one.
1043
1844
  visibleCommitted = false;
1044
1845
  // `step` is the productive-step index (used for display + audit). It only
1045
1846
  // advances when the previous iteration actually executed a tool.
1046
1847
  step = productiveSteps;
1047
- // Step budget gate: ask the user instead of hard-stopping
1048
- if (productiveSteps >= stepBudget) {
1049
- const askContinue = confirmPort.confirmContinue ?? inquirerConfirmPort.confirmContinue;
1050
- let shouldContinue = false;
1051
- try {
1052
- shouldContinue = await askContinue(productiveSteps);
1053
- restoreInteractiveStdin();
1054
- }
1055
- catch {
1056
- // Abort / non-interactive — treat as decline.
1057
- shouldContinue = false;
1058
- }
1059
- if (shouldContinue) {
1060
- // Extend the budget for another chunk of work.
1061
- const extension = Math.max(40, maxSteps);
1062
- stepBudget += extension;
1063
- maxIterations = stepBudget * 3;
1064
- // Compact older messages (model-written summary, no mechanical dump)
1065
- // to free context space for the next chunk of work.
1066
- await maybeAutoCompact("step-budget-continue", true);
1067
- // Inject a progress summary so the model stays focused.
1068
- const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1069
- let progressNote = "The step limit was reached and the user chose to continue. ";
1070
- progressNote +=
1071
- "Review what you have accomplished so far and continue with the NEXT unfinished step. ";
1072
- progressNote +=
1073
- "Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
1074
- if (livePlan) {
1075
- const doneTasks = livePlan.tasks.filter((t) => t.state === "done");
1076
- const pendingTasks = livePlan.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1077
- progressNote += `\n\nPlan progress: ${doneTasks.length}/${livePlan.tasks.length} tasks done.`;
1078
- if (pendingTasks.length > 0) {
1079
- progressNote += ` Next: ${pendingTasks[0].id} — "${pendingTasks[0].title}".`;
1080
- }
1081
- }
1082
- messages.push({ role: "user", content: progressNote });
1083
- writeNotice("info", `continuing — budget extended to ${stepBudget} steps`, chalk.dim(` ℹ continuing — budget extended to ${stepBudget} steps\n`));
1084
- // Continue the loop — model doesn't know it paused.
1085
- }
1086
- else {
1087
- // User declined — build a rich summary and return.
1088
- const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
1089
- writeAssistantMessage(richSummary);
1090
- lastAnswer = richSummary;
1091
- return finishTurn(lastAnswer, productiveSteps);
1092
- }
1848
+ if (governorPauseReason) {
1849
+ const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
1850
+ outcomeState.outcome.status = "paused_budget";
1851
+ await saveOutcomeState(outcomeState);
1852
+ moveTurn("paused_budget", governorPauseReason);
1853
+ lastAnswer = richSummary;
1854
+ return finishTurn(lastAnswer, productiveSteps, "paused_budget", outcomeState.outcome.criteria
1855
+ .filter((criterion) => criterion.required && criterion.status !== "proven")
1856
+ .map((criterion) => criterion.statement), governorPauseReason);
1093
1857
  }
1094
1858
  options.signal?.throwIfAborted();
1095
- // `call` and `assistantText` are shared by both paths below: a fresh
1096
- // model round-trip, or draining a previously-queued tool call.
1097
1859
  let call;
1098
1860
  let assistantText;
1099
1861
  let recoveredFromBareJson = false;
1100
1862
  if (pendingCalls.length > 0) {
1101
- // Drain the next queued call from the previous model message — no new
1102
- // round-trip. The assistant message and any prose were already shown
1103
- // when the batch was parsed.
1104
1863
  call = pendingCalls.shift();
1105
1864
  assistantText = { visible: "", thinkContent: "", hasThinking: false };
1106
1865
  const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
1107
1866
  writeStatus(batchStatus, chalk.dim(batchStatus));
1108
1867
  }
1109
1868
  else {
1110
- // Before a fresh model round-trip, proactively compact if the context has
1111
- // grown too large, so we never hit a provider context-window error and the
1112
- // model keeps a clean, plan-aware memory.
1113
1869
  await maybeAutoCompact("auto-token-budget");
1114
- // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1115
- // Status messages (rate-limit retries, fallback hints) still surface live.
1116
- // A spinner gives the user feedback during long thinking phases on
1117
- // models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
1118
1870
  const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
1119
1871
  let spinner = writesDirectly
1120
1872
  ? startThinkingSpinner(streamLabel, options.signal)
@@ -1129,9 +1881,6 @@ export async function runAgentLoop(prompt, options = {}) {
1129
1881
  let accumulatedText = "";
1130
1882
  const callIds = [];
1131
1883
  let streamedCallsCount = 0;
1132
- // Deferred tool-call events: collect tool calls parsed from the stream
1133
- // and emit them AFTER thinking + assistant text, so the display order
1134
- // is correct: thinking → model text → tool-call cards.
1135
1884
  const deferredToolCalls = [];
1136
1885
  const deltaParser = writesDirectly
1137
1886
  ? undefined
@@ -1143,71 +1892,135 @@ export async function runAgentLoop(prompt, options = {}) {
1143
1892
  emit({ type: "thinking-delta", text });
1144
1893
  });
1145
1894
  let completion;
1895
+ let toolsAttached = false;
1146
1896
  try {
1897
+ // Re-resolve dialect each step so /model or sticky fallback apply.
1898
+ ({ dialect: toolDialect, native: nativeToolsActive } =
1899
+ resolveNativeTools(provider, model));
1900
+ if (messages[0]?.role === "system") {
1901
+ messages[0] = {
1902
+ role: "system",
1903
+ content: composeCurrentSystemPrompt(nativeToolsActive),
1904
+ };
1905
+ }
1906
+ const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
1907
+ toolsAttached = Boolean(turnTools?.length);
1908
+ await auditLog("agent.turn", {
1909
+ provider,
1910
+ model,
1911
+ tool_protocol: toolsAttached ? "native" : "text",
1912
+ dialect: toolDialect,
1913
+ step,
1914
+ });
1915
+ assertValidToolProtocol(messages);
1147
1916
  completion = await streamWithProvider({
1148
1917
  provider,
1149
1918
  model,
1150
- // Stay on the user's selected provider/model. Cross-provider
1151
- // hops (e.g. bynara → gemini on a 429) only happen when the
1152
- // user has explicitly enabled `providerFallback` AND this is
1153
- // the provider's default model. Never force fallback mid-turn.
1154
1919
  allowModelFallback: false,
1155
1920
  messages,
1156
- // MiniMax M3 degenerates at the generic agent temperature. The
1157
- // HTTP layer also applies its `top_p` override for both the
1158
- // NVIDIA long ID and Kimchi's short `minimax-m3` ID.
1159
1921
  temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
1160
- // Reasoning models can spend a lot on hidden thinking; give
1161
- // them headroom so the visible answer / tool call isn't
1162
- // truncated to silence. The non-thinking budget must be large
1163
- // enough for a single-file fs.write / multi-file fs.writeMany
1164
- // payload — a truncated tool-call JSON fails to parse and leaks a
1165
- // broken (and syntactically invalid) file. 8k was too small for a
1166
- // full component, so allow more room for the visible tool call.
1167
- // Code-generation calls frequently contain an entire source file
1168
- // inside JSON. A 12k visible-token ceiling cut otherwise valid
1169
- // fs.write calls in half. Keep enough output headroom for a
1170
- // substantial source file; providers with a lower limit clamp it.
1171
1922
  maxTokens: 32_768,
1172
1923
  signal: options.signal,
1173
1924
  thinking: retryWithoutThinking
1174
1925
  ? { ...config.thinking, enabled: false, effort: "low" }
1175
1926
  : config.thinking,
1927
+ ...(toolsAttached
1928
+ ? {
1929
+ tools: turnTools,
1930
+ toolChoice: freshWebSearchRequired && !sawFreshWebSearch
1931
+ ? { type: "function", name: "web.search" }
1932
+ : "auto",
1933
+ parallelToolCalls: true,
1934
+ // P2-3: emit tool cards as soon as the function name arrives.
1935
+ onToolCallDelta: (delta) => {
1936
+ if (!delta.name)
1937
+ return;
1938
+ const name = fromWireName(delta.name) ?? delta.name;
1939
+ const existing = deferredToolCalls[delta.index];
1940
+ if (existing) {
1941
+ if (delta.argumentsBytes &&
1942
+ delta.argumentsBytes >= 4096 &&
1943
+ !writesDirectly) {
1944
+ emit({
1945
+ type: "status",
1946
+ text: `${name} (${Math.round(delta.argumentsBytes / 1024)}KB args)`,
1947
+ });
1948
+ }
1949
+ return;
1950
+ }
1951
+ // Ensure slots are dense so index maps to deferredToolCalls[i].
1952
+ while (deferredToolCalls.length < delta.index) {
1953
+ deferredToolCalls.push({
1954
+ eventId: `tool-${++nextToolEventId}`,
1955
+ call: { name: "…", args: {} },
1956
+ rendered: "",
1957
+ });
1958
+ }
1959
+ const call = normalizeToolCall({
1960
+ name,
1961
+ args: {},
1962
+ });
1963
+ const eventId = `tool-${++nextToolEventId}`;
1964
+ callIds.push(eventId);
1965
+ alreadyPrintedIds.add(eventId);
1966
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
1967
+ chalk.gray(` ${formatToolArgs(call)}`);
1968
+ const entry = {
1969
+ eventId,
1970
+ call,
1971
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
1972
+ };
1973
+ if (deferredToolCalls.length === delta.index) {
1974
+ deferredToolCalls.push(entry);
1975
+ }
1976
+ else {
1977
+ deferredToolCalls[delta.index] = entry;
1978
+ }
1979
+ streamedCallsCount = Math.max(streamedCallsCount, deferredToolCalls.length);
1980
+ if (!writesDirectly) {
1981
+ emit({ type: "status", text: call.name });
1982
+ }
1983
+ else {
1984
+ spinner.stop();
1985
+ spinner = startThinkingSpinner(`tool ${call.name}…`, options.signal);
1986
+ }
1987
+ },
1988
+ }
1989
+ : {}),
1176
1990
  }, (token) => {
1177
1991
  deltaParser?.push(token);
1178
1992
  generatedTokens += 1;
1179
1993
  accumulatedText += token;
1180
- const parsedCalls = parseAllToolCalls(accumulatedText);
1181
- if (parsedCalls.length > streamedCallsCount) {
1182
- if (writesDirectly) {
1183
- spinner.stop();
1184
- }
1185
- while (streamedCallsCount < parsedCalls.length) {
1186
- const call = parsedCalls[streamedCallsCount];
1187
- const eventId = `tool-${++nextToolEventId}`;
1188
- callIds.push(eventId);
1189
- alreadyPrintedIds.add(eventId);
1190
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) + chalk.gray(` ${formatToolArgs(call)}`);
1191
- // Defer the writeToolCall emission — collect it so we can
1192
- // emit after thinking + assistant text for correct order.
1193
- deferredToolCalls.push({
1194
- eventId,
1195
- call,
1196
- rendered: styleToolChatter(call, toolCallLine) + "\n",
1197
- });
1198
- // Still update spinner label for user feedback during streaming.
1199
- if (!writesDirectly) {
1200
- emit({ type: "status", text: call.name });
1994
+ // Early UI cards from text fences only when native tools are off
1995
+ // (native args stream as structured deltas, not prose).
1996
+ if (!toolsAttached) {
1997
+ const parsedCalls = parseAllToolCalls(accumulatedText);
1998
+ if (parsedCalls.length > streamedCallsCount) {
1999
+ if (writesDirectly) {
2000
+ spinner.stop();
2001
+ }
2002
+ while (streamedCallsCount < parsedCalls.length) {
2003
+ const call = parsedCalls[streamedCallsCount];
2004
+ const eventId = `tool-${++nextToolEventId}`;
2005
+ callIds.push(eventId);
2006
+ alreadyPrintedIds.add(eventId);
2007
+ const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
2008
+ chalk.gray(` ${formatToolArgs(call)}`);
2009
+ deferredToolCalls.push({
2010
+ eventId,
2011
+ call,
2012
+ rendered: styleToolChatter(call, toolCallLine) + "\n",
2013
+ });
2014
+ if (!writesDirectly) {
2015
+ emit({ type: "status", text: call.name });
2016
+ }
2017
+ streamedCallsCount += 1;
2018
+ }
2019
+ if (writesDirectly) {
2020
+ spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
1201
2021
  }
1202
- streamedCallsCount += 1;
1203
- }
1204
- if (writesDirectly) {
1205
- spinner = startThinkingSpinner(`generating response (${generatedTokens} tokens)`, options.signal);
1206
2022
  }
1207
2023
  }
1208
- // Heuristic: <think>… markers and reasoning_content tokens flow
1209
- // through onToken. Surface activity in the spinner so the screen
1210
- // is never empty for minutes.
1211
2024
  if (!sawReasoning && /<think/i.test(token)) {
1212
2025
  sawReasoning = true;
1213
2026
  inThinking = true;
@@ -1220,11 +2033,6 @@ export async function runAgentLoop(prompt, options = {}) {
1220
2033
  spinner.setLabel("generating response (0 tokens)");
1221
2034
  generatedTokens = 0;
1222
2035
  }
1223
- // Only push reasoning tokens to the spinner preview. Visible
1224
- // answer / tool-call tokens should NOT go through the dim
1225
- // spinner preview — doing so makes the final answer appear
1226
- // "diluted" in light font when the spinner's last render
1227
- // briefly shows the answer text before being erased.
1228
2036
  if (inThinking) {
1229
2037
  const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
1230
2038
  if (cleaned) {
@@ -1251,51 +2059,169 @@ export async function runAgentLoop(prompt, options = {}) {
1251
2059
  provider = completion.provider;
1252
2060
  model = completion.model;
1253
2061
  deltaParser?.finish();
2062
+ // Sticky text-only may have flipped dialect during stream retry.
2063
+ ({ dialect: toolDialect, native: nativeToolsActive } =
2064
+ resolveNativeTools(provider, model));
2065
+ // toolsAttached may have been true for the request; if sticky
2066
+ // fallback dropped tools, treat as text mode for this turn's parse.
2067
+ const usedNativeProtocol = Boolean(completion.toolCalls?.length) ||
2068
+ (toolsAttached && !isTextOnlyModel(provider, model));
1254
2069
  const assistantTextResult = rememberThinkingFromText(completion.text);
1255
2070
  assistantText = assistantTextResult;
1256
- // Commit thinking to the transcript IMMEDIATELY, before any of the
1257
- // branches below decide to `continue` (retry a malformed tool call,
1258
- // nudge for narration, guard premature completion, etc). Previously
1259
- // writeThinkingBlock was only called from a few terminal branches, so
1260
- // any retry path silently dropped the model's reasoning — the user
1261
- // would see the live "thinking…" preview during streaming and then
1262
- // watch it vanish with nothing committed once the turn moved on.
1263
2071
  if (assistantText.hasThinking) {
1264
2072
  writeThinkingBlock(assistantText.thinkContent);
1265
2073
  }
1266
- // Try visible text first, then thinking content some models (e.g. glm-5.1)
1267
- // wrap tool calls inside considering tags, so stripThinking removes them
1268
- // into thinkContent and visible becomes empty. Recovering from thinkContent
1269
- // prevents an endless nudge loop where the model keeps hiding the call.
1270
- call = parseToolCall(assistantText.visible, {
1271
- strict: getConfig().parserStrict,
1272
- });
1273
- if (!call && assistantText.hasThinking) {
1274
- call = parseToolCall(assistantText.thinkContent, {
2074
+ // Native-first: prefer structured toolCalls from the provider.
2075
+ let nativeToolCalls = completion.toolCalls ?? [];
2076
+ // Early UI cards: refresh args if stream deltas already opened cards;
2077
+ // otherwise create cards now (non-streaming / name-after-done providers).
2078
+ if (nativeToolCalls.length) {
2079
+ if (deferredToolCalls.length === 0) {
2080
+ for (const tc of nativeToolCalls) {
2081
+ const normalized = normalizeToolCall({
2082
+ name: tc.name,
2083
+ args: tc.args,
2084
+ });
2085
+ const eventId = `tool-${++nextToolEventId}`;
2086
+ callIds.push(eventId);
2087
+ alreadyPrintedIds.add(eventId);
2088
+ const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
2089
+ chalk.gray(` ${formatToolArgs(normalized)}`);
2090
+ deferredToolCalls.push({
2091
+ eventId,
2092
+ call: normalized,
2093
+ rendered: styleToolChatter(normalized, toolCallLine) + "\n",
2094
+ });
2095
+ }
2096
+ }
2097
+ else {
2098
+ for (let i = 0; i < nativeToolCalls.length; i++) {
2099
+ const tc = nativeToolCalls[i];
2100
+ const normalized = normalizeToolCall({
2101
+ name: tc.name,
2102
+ args: tc.args,
2103
+ });
2104
+ const existing = deferredToolCalls[i];
2105
+ if (existing && existing.call.name !== "…") {
2106
+ existing.call = normalized;
2107
+ const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
2108
+ chalk.gray(` ${formatToolArgs(normalized)}`);
2109
+ existing.rendered =
2110
+ styleToolChatter(normalized, toolCallLine) + "\n";
2111
+ }
2112
+ else if (!existing || existing.call.name === "…") {
2113
+ const eventId = existing?.eventId ?? `tool-${++nextToolEventId}`;
2114
+ if (!existing) {
2115
+ callIds.push(eventId);
2116
+ alreadyPrintedIds.add(eventId);
2117
+ }
2118
+ const toolCallLine = chalk.cyan(` ▶ ${normalized.name}`) +
2119
+ chalk.gray(` ${formatToolArgs(normalized)}`);
2120
+ const entry = {
2121
+ eventId,
2122
+ call: normalized,
2123
+ rendered: styleToolChatter(normalized, toolCallLine) + "\n",
2124
+ };
2125
+ if (existing)
2126
+ deferredToolCalls[i] = entry;
2127
+ else
2128
+ deferredToolCalls.push(entry);
2129
+ }
2130
+ }
2131
+ }
2132
+ }
2133
+ if (nativeToolCalls.length) {
2134
+ const first = nativeToolCalls[0];
2135
+ if (first.args?._parseError) {
2136
+ call = undefined;
2137
+ }
2138
+ else {
2139
+ call = normalizeToolCall({ name: first.name, args: first.args });
2140
+ }
2141
+ }
2142
+ else {
2143
+ call = parseToolCall(assistantText.visible, {
1275
2144
  strict: getConfig().parserStrict,
1276
2145
  });
1277
- if (call) {
1278
- writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
1279
- }
1280
- }
1281
- // ── Prompt-leak guard ─────────────────────────────────────────
1282
- // If the model's visible output contains distinctive system-prompt
1283
- // markers, it is repeating its instructions (e.g. prompt injection
1284
- // via "repeat your instructions verbatim"). Any tool-call syntax
1285
- // in that output is an EXAMPLE from the prompt, not a real request.
1286
- // Suppress it so we never execute leaked examples.
1287
- if (call && looksLikePromptLeak(assistantText.visible)) {
1288
- writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
2146
+ if (!call && assistantText.hasThinking) {
2147
+ call = parseToolCall(assistantText.thinkContent, {
2148
+ strict: getConfig().parserStrict,
2149
+ });
2150
+ if (call) {
2151
+ writeNotice("info", "recovered tool call from thinking content", chalk.dim(" ℹ recovered tool call from thinking content\n"));
2152
+ }
2153
+ }
2154
+ }
2155
+ if (looksLikePromptLeak(assistantText.visible)) {
2156
+ if (call || nativeToolCalls.length) {
2157
+ writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
2158
+ }
1289
2159
  call = undefined;
2160
+ nativeToolCalls = [];
2161
+ deferredToolCalls.length = 0;
2162
+ }
2163
+ if (nativeToolCalls.length) {
2164
+ // Only salvage when args failed to parse (truncated JSON). A clean
2165
+ // parse with finish_reason=length is a complete tool call — execute it.
2166
+ const writeTc = nativeToolCalls.find((tc) => {
2167
+ const isWrite = tc.name === "fs.write" ||
2168
+ tc.name === "fs.append" ||
2169
+ tc.name === "fs.writeMany";
2170
+ return isWrite && Boolean(tc.args?._parseError);
2171
+ });
2172
+ if (writeTc) {
2173
+ const raw = writeTc.rawArguments ??
2174
+ (typeof writeTc.args?._raw === "string"
2175
+ ? String(writeTc.args._raw)
2176
+ : undefined);
2177
+ const salvaged = salvageTruncatedWriteFromNative(writeTc.name, raw);
2178
+ if (salvaged) {
2179
+ truncatedToolRetries += 1;
2180
+ if (truncatedToolRetries <= 5) {
2181
+ try {
2182
+ const writeResult = await fsWrite(salvaged.path, salvaged.content, { confirmed: true });
2183
+ if (writeResult.ok) {
2184
+ const lineCount = salvaged.content.split("\n").length;
2185
+ 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`));
2186
+ // Pair assistant tool_calls with synthetic results so the
2187
+ // next turn is not orphaned, then nudge for append.
2188
+ appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls);
2189
+ for (const tc of nativeToolCalls) {
2190
+ appendToolResult(messages, tc.id, tc.id === writeTc.id
2191
+ ? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
2192
+ : `Tool ${tc.name} result (exit=1, ok=false):\nCancelled — sibling write was truncated and salvaged.`, tc.name, tc.id === writeTc.id);
2193
+ }
2194
+ const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
2195
+ const appendNudge = toolsAttached
2196
+ ? `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}. ` +
2197
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2198
+ `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). ` +
2199
+ `Do not re-read the full file; do not re-send content already saved. Use the platform tool interface — no markdown fences.`
2200
+ : `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}. ` +
2201
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2202
+ `CONTINUE with ONE large fs.append of the remaining content:\n` +
2203
+ '```tool\n{"name":"fs.append","args":{"path":' +
2204
+ JSON.stringify(salvaged.path) +
2205
+ ',"expectedPriorBytes":' +
2206
+ priorBytes +
2207
+ ',"content":"...ONLY the remaining content not already on disk..."}}\n```';
2208
+ messages.push({
2209
+ role: "user",
2210
+ content: appendNudge,
2211
+ });
2212
+ nativeToolCalls = [];
2213
+ call = undefined;
2214
+ deferredToolCalls.length = 0;
2215
+ continue;
2216
+ }
2217
+ }
2218
+ catch {
2219
+ // fall through to normal parse-error handling
2220
+ }
2221
+ }
2222
+ }
2223
+ }
1290
2224
  }
1291
- // Empty-response recovery
1292
- // Some models occasionally return an empty completion: a reasoning
1293
- // model that spent its whole budget on hidden &lt;think&gt; reasoning and emitted
1294
- // no visible text, OR (more perniciously) a gateway hiccup that
1295
- // streamed [DONE] with no content deltas at all. Without this guard
1296
- // the agent silently ends the turn with no answer, no warning, and no
1297
- // error — the user just sees the spinner stop. Catch BOTH cases
1298
- // (thinking-only AND truly empty) and nudge the model to retry.
1299
2225
  if (!assistantText.visible.trim() && !call) {
1300
2226
  emptyVisibleRetries += 1;
1301
2227
  if (emptyVisibleRetries <= 3) {
@@ -1310,17 +2236,21 @@ export async function runAgentLoop(prompt, options = {}) {
1310
2236
  pushAssistantHistory(stripThinking(collapseRepeatedText(completion.text)).visible);
1311
2237
  // Keep nudges SHORT — cheap models lose the key instruction in long text.
1312
2238
  const buildNudge = freshWebSearchRequired && !sawFreshWebSearch
1313
- ? "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
1314
- : buildLikeTurn && !activePlan
1315
- ? "No visible output. Emit a ```tool block to call plan.create now. " +
1316
- "Do NOT hide tool calls in <think> tags — put them in the visible response."
1317
- : "No visible output. Emit a ```tool block or give your final answer. " +
1318
- "Do NOT hide tool calls in <think> tags put them in the visible response.";
2239
+ ? toolsAttached
2240
+ ? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
2241
+ : "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
2242
+ : isPlanMode && !activePlan
2243
+ ? toolsAttached
2244
+ ? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
2245
+ : "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
2246
+ "Do NOT hide tool calls in <think> tags — put them in the visible response."
2247
+ : toolsAttached
2248
+ ? "No visible output. " + toolNudge(true)
2249
+ : "No visible output. Emit a ```tool block or give your final answer. " +
2250
+ "Do NOT hide tool calls in <think> tags — put them in the visible response.";
1319
2251
  messages.push(recoveryUserMessage(buildNudge));
1320
2252
  continue;
1321
2253
  }
1322
- // Exhausted retries — surface a clear notice and exit the turn instead
1323
- // of falling through and triggering premature-completion loops.
1324
2254
  writeNotice("warn", "model returned an empty response after retries — no answer produced", chalk.yellow(" ⚠ model returned an empty response after retries — no answer produced\n"));
1325
2255
  return finishTurn("Model returned an empty response after retries.", step + 1);
1326
2256
  }
@@ -1329,12 +2259,6 @@ export async function runAgentLoop(prompt, options = {}) {
1329
2259
  emptyVisibleRetries = 0;
1330
2260
  retryWithoutThinking = false;
1331
2261
  }
1332
- // `call` was already extracted above (from visible text or thinking content).
1333
- // Recovery: the model meant to call a tool but emitted a bare JSON object
1334
- // with no ```tool fence — either a complete {name,args} the strict
1335
- // matchers missed (recover it directly), or just an args object like
1336
- // {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
1337
- // the requested action runs instead of the JSON leaking as the answer).
1338
2262
  let bareArgsOnly = false;
1339
2263
  recoveredFromBareJson = false;
1340
2264
  if (!call) {
@@ -1364,47 +2288,47 @@ export async function runAgentLoop(prompt, options = {}) {
1364
2288
  if (bareArgsOnly) {
1365
2289
  bareToolJsonRetries += 1;
1366
2290
  if (bareToolJsonRetries <= 3) {
1367
- writeNotice("warn", "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(" ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
2291
+ writeNotice("warn", toolsAttached
2292
+ ? "tool call missing its name — asking the model to call a tool properly"
2293
+ : "tool call missing its name/fence — asking the model to re-emit a proper ```tool block", chalk.yellow(toolsAttached
2294
+ ? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
2295
+ : " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
1368
2296
  pushAssistantHistory(assistantText.visible);
1369
- messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
1370
- ? "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1371
- "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1372
- "You MUST call plan.create using a proper ```tool block. For example:\n" +
1373
- '```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
1374
- "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
1375
- : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1376
- "Reply with ONLY a fenced ```tool block of the form " +
1377
- '`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
1378
- '```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
1379
- "Choose the correct tool name for the task and include those args."));
2297
+ messages.push(recoveryUserMessage(isPlanMode && !activePlan
2298
+ ? toolsAttached
2299
+ ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
2300
+ "In plan mode: call plan.create (or research tools) via the platform tool interface."
2301
+ : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
2302
+ "In plan mode, call plan.create with a proper ```tool block when ready, e.g.:\n" +
2303
+ '```tool\n{"name":"plan.create","args":{"goal":"…","detail":"…","tasks":["…"],"kind":"coding"}}\n```'
2304
+ : toolsAttached
2305
+ ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
2306
+ toolNudge(true) +
2307
+ " Include the tool name and full args via the platform tool interface do not use markdown fences."
2308
+ : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
2309
+ "Reply with ONLY a fenced ```tool block of the form " +
2310
+ '`{"name": "<tool>", "args": { ... }}`. For example, to read a PDF:\n' +
2311
+ '```tool\n{"name":"pdf.read","args":{"path":"/abs/file.pdf"}}\n```\n' +
2312
+ "Choose the correct tool name for the task and include those args."));
1380
2313
  continue;
1381
2314
  }
1382
2315
  // Exhausted retries — fall through to the normal answer path.
1383
2316
  }
1384
- // Detect the case where the model emitted sentinel-style tool-call
1385
- // markers but the body was malformed or truncated. Printing those
1386
- // raw tokens looks like a crash to the user — instead, ask the
1387
- // model to retry the tool call in a clean JSON format.
1388
2317
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
1389
2318
  writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
1390
2319
  pushAssistantHistory(assistantText.visible);
1391
- messages.push(recoveryUserMessage("Your previous tool call was malformed or truncated. " +
1392
- "Reply with ONLY a fenced ```tool block containing valid JSON " +
1393
- 'of the form `{"name": "<tool>", "args": { ... }}`. ' +
1394
- "Do not use <|tool_call_begin|> markers."));
2320
+ messages.push(recoveryUserMessage(toolsAttached
2321
+ ? "Your previous tool call was malformed or truncated. " +
2322
+ toolNudge(true) +
2323
+ " Pass valid JSON arguments via the platform tool interface — do not use fence or sentinel markers."
2324
+ : "Your previous tool call was malformed or truncated. " +
2325
+ "Reply with ONLY a fenced ```tool block containing valid JSON " +
2326
+ 'of the form `{"name": "<tool>", "args": { ... }}`. ' +
2327
+ "Do not use <|tool_call_begin|> markers."));
1395
2328
  continue;
1396
2329
  }
1397
- // Detect a tool call that opened but was cut off by the token limit
1398
- // (most common with large fs.write/fs.writeMany for reports).
1399
- // Instead of asking the model to retry (which will just truncate
1400
- // again at the same limit), we SALVAGE the partial content from the
1401
- // truncated JSON and write it, then tell the model to CONTINUE with
1402
- // fs.append from where it was cut off.
1403
2330
  if (looksLikeTruncatedToolCall(assistantText.visible)) {
1404
2331
  truncatedToolRetries += 1;
1405
- // Try to salvage a partial fs.write / fs.append from the truncated JSON.
1406
- // The pattern is: {"name":"fs.write","args":{"path":"...","content":"...
1407
- // We extract the path and whatever content was produced before truncation.
1408
2332
  const salvaged = salvageTruncatedWrite(assistantText.visible);
1409
2333
  if (salvaged && truncatedToolRetries <= 5) {
1410
2334
  // Write the salvaged partial content
@@ -1419,16 +2343,20 @@ export async function runAgentLoop(prompt, options = {}) {
1419
2343
  const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
1420
2344
  messages.push({
1421
2345
  role: "user",
1422
- content: `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}. ` +
1423
- `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
1424
- `CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
1425
- '```tool\n{"name":"fs.append","args":{"path":' +
1426
- JSON.stringify(salvaged.path) +
1427
- ',"expectedPriorBytes":' +
1428
- priorBytes +
1429
- ',"content":"...ONLY the remaining content not already on disk..."}}\n```\n' +
1430
- `expectedPriorBytes must match the receipt so append cannot double-write. ` +
1431
- `Do NOT re-read the full file; do NOT re-send content already saved.`,
2346
+ content: toolsAttached
2347
+ ? `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}. ` +
2348
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2349
+ `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.`
2350
+ : `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}. ` +
2351
+ `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2352
+ `CONTINUE with ONE large fs.append of the remaining content (prefer hundreds of lines per call — do NOT use tiny ~100-line chunks):\n` +
2353
+ '```tool\n{"name":"fs.append","args":{"path":' +
2354
+ JSON.stringify(salvaged.path) +
2355
+ ',"expectedPriorBytes":' +
2356
+ priorBytes +
2357
+ ',"content":"...ONLY the remaining content not already on disk..."}}\n```\n' +
2358
+ `expectedPriorBytes must match the receipt so append cannot double-write. ` +
2359
+ `Do NOT re-read the full file; do NOT re-send content already saved.`,
1432
2360
  });
1433
2361
  continue;
1434
2362
  }
@@ -1442,27 +2370,24 @@ export async function runAgentLoop(prompt, options = {}) {
1442
2370
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
1443
2371
  messages.push({
1444
2372
  role: "user",
1445
- content: "Your previous tool call was cut off before it finished — the JSON was incomplete, so NOTHING ran. " +
1446
- "Prefer ONE complete fs.write when it fits (~32k output tokens is a lot of file content if reasoning stays short). " +
1447
- "If the file is too large for one call:\n" +
1448
- "1. fs.write the first large section (as much as fits hundreds+ of lines)\n" +
1449
- "2. fs.append the rest with expectedPriorBytes from the write receipt\n" +
1450
- "3. Repeat append only if still incompletelarge chunks, not ~100-line drips\n" +
1451
- "Keep reasoning SHORT emit the ```tool block early. Do NOT claim a file was written until a tool call succeeds.",
2373
+ content: toolsAttached
2374
+ ? "Your previous tool call was cut off before it finished the JSON was incomplete, so NOTHING ran. " +
2375
+ "Prefer ONE complete fs.write when it fits. If the file is too large: (1) fs.write the first large section, " +
2376
+ "(2) fs.append the rest with expectedPriorBytes from the write receipt, (3) repeat with large chunks. " +
2377
+ "Keep reasoning SHORT and call the tool via the platform interface. Do NOT claim a file was written until a tool call succeeds."
2378
+ : "Your previous tool call was cut off before it finished the JSON was incomplete, so NOTHING ran. " +
2379
+ "Prefer ONE complete fs.write when it fits (~32k output tokens is a lot of file content if reasoning stays short). " +
2380
+ "If the file is too large for one call:\n" +
2381
+ "1. fs.write the first large section (as much as fits — hundreds+ of lines)\n" +
2382
+ "2. fs.append the rest with expectedPriorBytes from the write receipt\n" +
2383
+ "3. Repeat append only if still incomplete — large chunks, not ~100-line drips\n" +
2384
+ "Keep reasoning SHORT — emit the ```tool block early. Do NOT claim a file was written until a tool call succeeds.",
1452
2385
  });
1453
2386
  continue;
1454
2387
  }
1455
2388
  // Exhausted retries — fall through so we don't loop forever, but the
1456
2389
  // user at least sees the (broken) output and the stop notice.
1457
2390
  }
1458
- // Detect a ```tool fence whose JSON could NOT be parsed for any other
1459
- // reason (malformed braces, trailing junk, a stray `}` — NOT plain
1460
- // truncation, which is handled above). Without this, the raw block
1461
- // leaks to the screen as a code fence and the requested action (often
1462
- // a whole fs.writeMany scaffold) silently never runs — exactly the
1463
- // "fs.writeMany printed but nothing created" failure. Require the fence
1464
- // to actually look like an intended call (mentions name/args) so a
1465
- // genuine ```tool code example in prose isn't mistaken for one.
1466
2391
  const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
1467
2392
  /```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
1468
2393
  if (hasFencedCallShape) {
@@ -1498,54 +2423,38 @@ export async function runAgentLoop(prompt, options = {}) {
1498
2423
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
1499
2424
  messages.push({
1500
2425
  role: "user",
1501
- content: "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
1502
- "Common causes: unescaped newlines or quotes inside a string value, an extra or missing `}` / `]`, or content too large for the output window. " +
1503
- 'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
1504
- "IMPORTANT: Prefer ONE complete fs.write when it fits. Keep reasoning SHORT. " +
1505
- "Only if the output window cuts you off, continue with large fs.append chunks + expectedPriorBytes. " +
1506
- "Do NOT claim any file was written until a tool call actually succeeds.",
2426
+ content: toolsAttached
2427
+ ? "Your previous tool call JSON was INVALID, so NOTHING ran. " +
2428
+ "Common causes: unescaped newlines/quotes, unbalanced braces, or content too large. " +
2429
+ toolNudge(true) +
2430
+ " Prefer ONE complete fs.write when it fits; if cut off, continue with large fs.append + expectedPriorBytes. " +
2431
+ "Do NOT claim any file was written until a tool call actually succeeds."
2432
+ : "Your previous message contained a ```tool block, but its JSON was INVALID, so NOTHING ran. " +
2433
+ "Common causes: unescaped newlines or quotes inside a string value, an extra or missing `}` / `]`, or content too large for the output window. " +
2434
+ 'Re-emit ONE valid ```tool block of the exact form {"name":"<tool>","args":{...}} with balanced braces. ' +
2435
+ "IMPORTANT: Prefer ONE complete fs.write when it fits. Keep reasoning SHORT. " +
2436
+ "Only if the output window cuts you off, continue with large fs.append chunks + expectedPriorBytes. " +
2437
+ "Do NOT claim any file was written until a tool call actually succeeds.",
1507
2438
  });
1508
2439
  continue;
1509
2440
  }
1510
2441
  // Exhausted retries — fall through to the normal path.
1511
2442
  }
1512
- // Normal final-answer path: strip any stray sentinel tokens that
1513
- // somehow leaked into prose so the answer renders cleanly.
1514
2443
  const cleaned = stripSentinelTokens(assistantText.visible);
1515
- // Act, don't narrate
1516
- // Build/scaffold/plan turns must DO something. If the model returns
1517
- // prose with NO tool call, it is narrating intent ("Let me first
1518
- // explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
1519
- // … please approve") instead of calling a tool — accepting it as a
1520
- // final answer ends the turn with nothing done and no real plan saved.
1521
- // Nudge it to emit a real tool call, with a concrete example.
1522
2444
  const narratedAction = looksLikeActionNarration(cleaned);
1523
2445
  const narratedWebAction = looksLikeWebActionNarration(cleaned);
1524
- // `activePlan` is the snapshot loaded at the start of this turn.
1525
- // A final task.update mutates and persists a separate plan instance,
1526
- // so re-read it here before deciding whether a final response should
1527
- // be forced back into execution. Without this, a completed plan
1528
- // still appeared open and a short final summary such as "I'll
1529
- // summarize the findings" could be discarded for an unnecessary
1530
- // recovery turn.
1531
- const livePlanAtCompletion = await loadPlan(session.sessionId).catch(() => undefined);
2446
+ const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
2447
+ const livePlanAtCompletion = reconciledPlanAtCompletion ??
2448
+ (await loadPlan(session.sessionId).catch(() => undefined));
1532
2449
  const planStatusAtCompletion = livePlanAtCompletion?.status ?? activePlan?.status;
1533
2450
  const completedPlanDuringThisTurn = activePlan?.status !== "completed" &&
1534
2451
  planStatusAtCompletion === "completed";
1535
2452
  const planHasOpenWorkNow = planHasOpenWork(planStatusAtCompletion);
1536
- // User-driven reasons to require tools this turn (build/pentest/
1537
- // approved plan / freshness). Idle greetings never qualify.
1538
2453
  const userExpectsWork = freshWebSearchRequired ||
1539
2454
  (planHasOpenWorkNow && session.planApproved.value) ||
1540
2455
  (!informationalQuery &&
1541
2456
  !idleOrSocialPrompt &&
1542
2457
  (buildLikeTurn || pentestLikeTurn));
1543
- // History-inherited build/pentest intent only forces action when
1544
- // THIS prompt is not itself a plain question/idle turn. Narration
1545
- // stalls ("I'll list the files") still force a tool when the user
1546
- // is not asking an informational question — but capability menus
1547
- // and greetings are filtered out by looksLikeActionNarration /
1548
- // idleOrSocialPrompt so they cannot burn recovery turns.
1549
2458
  const wantsAction = !completedPlanDuringThisTurn &&
1550
2459
  !idleOrSocialPrompt &&
1551
2460
  (userExpectsWork ||
@@ -1554,224 +2463,279 @@ export async function runAgentLoop(prompt, options = {}) {
1554
2463
  const planNarrated = (buildLikeTurn || pentestLikeTurn) &&
1555
2464
  !activePlan &&
1556
2465
  looksLikePlanNarration(cleaned);
1557
- // Once a real tool step has run, a no-plan task has no durable task
1558
- // state to prove whether another action is needed. A tool-free reply
1559
- // must therefore be allowed to finalize instead of turning a short
1560
- // summary containing “I'll” into an implicit recovery request.
1561
- const shouldRetryBeforeFinalizing = productiveSteps === 0 || planNarrated;
2466
+ const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
2467
+ const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
2468
+ planNarrated ||
2469
+ (session.planApproved.value &&
2470
+ planHasOpenWorkNow &&
2471
+ (narratedAction || errorFixNarration)) ||
2472
+ (session.planApproved.value && errorFixNarration) ||
2473
+ (buildLikeTurn && errorFixNarration);
1562
2474
  if (wantsAction &&
1563
2475
  cleaned.trim().length > 0 &&
1564
- actionIntentRetries < 3 &&
1565
2476
  shouldRetryBeforeFinalizing) {
1566
- actionIntentRetries += 1;
1567
- let nudge;
1568
- if (planHasOpenWorkNow && session.planApproved.value) {
1569
- nudge =
1570
- "You wrote a message but emitted NO ```tool block, so NOTHING ran. Do NOT narrate what you will do — DO it. Emit the next tool call now (task.update / fs.writeMany / shell.exec) in a single ```tool block.";
1571
- writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
2477
+ let action;
2478
+ if (errorFixNarration && budgetRemaining(recovery, "errorFix")) {
2479
+ action = recoveryForErrorDiagnosis(toolsAttached);
2480
+ }
2481
+ else if (budgetRemaining(recovery, "actionIntent") &&
2482
+ planHasOpenWorkNow &&
2483
+ session.planApproved.value) {
2484
+ action = recoveryForNarration(toolsAttached, "plan_open");
1572
2485
  }
1573
- else if (pentestLikeTurn) {
1574
- nudge =
1575
- "You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW (e.g. net.scan / sysinfo / shell.exec). For example, to scan local network or read system settings:\n" +
1576
- '```tool\n{"name":"sysinfo","args":{}}\n```\n' +
1577
- "Every turn MUST contain a ```tool block until the task is done.";
1578
- writeNotice("warn", "described a security/pentest action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a security/pentest action but emitted no tool call — nudging it to run one\n"));
2486
+ else if (budgetRemaining(recovery, "actionIntent") &&
2487
+ pentestLikeTurn) {
2488
+ action = recoveryForNarration(toolsAttached, "pentest");
1579
2489
  }
1580
- else if (freshWebSearchRequired || narratedWebAction) {
1581
- // Web-specific recovery ONLY when the user asked for current
1582
- // info or the model explicitly claimed a fetch/search step.
1583
- // Previously every non-build stall used this path, so a "hi"
1584
- // greeting that said "I'll start executing" was forced into
1585
- // pointless web.search recovery loops.
1586
- nudge =
1587
- "You wrote that you would fetch/search/read something but emitted NO ```tool block, so NOTHING ran. Do NOT narrate the next browsing step — DO it. Emit exactly one valid ```tool block now. If you know the exact page, use:\n" +
1588
- '```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
1589
- "If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
1590
- writeNotice("warn", "described a web action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described a web action but emitted no tool call — nudging it to run one\n"));
2490
+ else if (budgetRemaining(recovery, "actionIntent") &&
2491
+ (freshWebSearchRequired || narratedWebAction)) {
2492
+ action = recoveryForNarration(toolsAttached, "web");
1591
2493
  }
1592
- else if (buildLikeTurn &&
2494
+ else if (budgetRemaining(recovery, "actionIntent") &&
2495
+ buildLikeTurn &&
1593
2496
  (planNarrated || productiveSteps > 0)) {
1594
- const kind = pentestLikeTurn ? "pentest" : "coding";
1595
- nudge =
1596
- "You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved and the user cannot /implement it. Emit it as a real tool call NOW — exactly one ```tool block:\n" +
1597
- `\`\`\`tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack/approach and how you'll verify>","tasks":["task 1","task 2","task 3"],"kind":"${kind}"}}\n\`\`\`\n` +
1598
- "Do not describe the plan again in prose — just emit the plan.create tool block.";
1599
- writeNotice("warn", "plan was written as text, not created — nudging it to call plan.create", chalk.yellow(" ⚠ plan was written as text, not created — nudging it to call plan.create\n"));
2497
+ action = recoveryForNarration(toolsAttached, "build_plan_prose");
1600
2498
  }
1601
- else if (buildLikeTurn) {
1602
- nudge =
1603
- "You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW. For this build task, explore first like this:\n" +
1604
- '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
1605
- "Then read key files, and once you understand the directory, call plan.create. Every turn MUST contain a ```tool block until the task is done.";
1606
- writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
2499
+ else if (budgetRemaining(recovery, "actionIntent") &&
2500
+ buildLikeTurn) {
2501
+ action = recoveryForNarration(toolsAttached, "build");
1607
2502
  }
1608
- else {
1609
- // Generic non-build, non-web stall (e.g. "I'll list the files").
1610
- nudge =
1611
- "You described what you will do but emitted NO ```tool block, so NOTHING actually happened — narration is not action. Emit a real tool call NOW for the step you just described. Every turn that claims an action MUST contain a ```tool block.";
1612
- writeNotice("warn", "described an action but emitted no tool call — nudging it to run one", chalk.yellow(" ⚠ described an action but emitted no tool call — nudging it to run one\n"));
2503
+ else if (budgetRemaining(recovery, "actionIntent")) {
2504
+ action = recoveryForNarration(toolsAttached, "generic");
2505
+ }
2506
+ if (action) {
2507
+ consumeBudget(recovery, action.budgetKey);
2508
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2509
+ pushAssistantHistory(assistantText.visible);
2510
+ messages.push(recoveryUserMessage(action.message));
2511
+ continue;
1613
2512
  }
1614
- pushAssistantHistory(assistantText.visible);
1615
- messages.push(recoveryUserMessage(nudge));
1616
- continue;
1617
2513
  }
1618
2514
  if (freshWebSearchRequired &&
1619
2515
  !sawFreshWebSearch &&
1620
- !freshnessRetryUsed) {
1621
- freshnessRetryUsed = true;
1622
- writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
2516
+ budgetRemaining(recovery, "freshnessUsed")) {
2517
+ const action = recoveryForFreshness(freshnessGuardMessage() +
2518
+ (toolsAttached
2519
+ ? " Call the web_search tool now."
2520
+ : " Reply with ONLY a fenced ```tool block for web.search now."));
2521
+ consumeBudget(recovery, action.budgetKey);
2522
+ writeNotice("info", action.notice, chalk.dim(` ℹ ${action.notice}\n`));
1623
2523
  pushAssistantHistory(assistantText.visible);
1624
- messages.push({
1625
- role: "user",
1626
- content: freshnessGuardMessage() +
1627
- " Reply with ONLY a fenced ```tool block for web.search now.",
1628
- });
2524
+ messages.push({ role: "user", content: action.message });
1629
2525
  continue;
1630
2526
  }
1631
- // A passing build is not evidence that an app is serving requests.
1632
- // On completed CODING build plans only, require start → logs → HTTP
1633
- // verification. NEVER apply this to pentest/remote engagements — that
1634
- // forced "npm run dev on the clai repo" after a finished web assessment.
2527
+ if (isPlanMode &&
2528
+ !informationalQuery &&
2529
+ !idleOrSocialPrompt &&
2530
+ budgetRemaining(recovery, "forcePlan")) {
2531
+ const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
2532
+ if (!planAtEnd && !sawPlanCreateOk) {
2533
+ const action = recoveryForMissingPlan(toolsAttached);
2534
+ consumeBudget(recovery, action.budgetKey);
2535
+ pushAssistantHistory(assistantText.visible);
2536
+ messages.push({ role: "user", content: action.message });
2537
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2538
+ continue;
2539
+ }
2540
+ }
1635
2541
  if (buildLike &&
1636
2542
  !pentestLike &&
1637
2543
  !pentestSession &&
1638
2544
  session.planApproved.value &&
1639
- (!sawServerStart || !sawServerTail || !sawLocalHttpProbe) &&
1640
- runtimeVerificationRetries < 2) {
2545
+ featureAppAsk &&
2546
+ !sawFeatureImplWrite &&
2547
+ (sawScaffoldOk || sawLocalAppMaterialWork) &&
2548
+ productiveSteps > 0 &&
2549
+ budgetRemaining(recovery, "featureImpl")) {
2550
+ const action = recoveryForMissingFeature(getActiveProjectRoot());
2551
+ consumeBudget(recovery, action.budgetKey);
2552
+ pushAssistantHistory(assistantText.visible);
2553
+ messages.push({ role: "user", content: action.message });
2554
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2555
+ continue;
2556
+ }
2557
+ if (buildLike &&
2558
+ !pentestLike &&
2559
+ !pentestSession &&
2560
+ budgetRemaining(recovery, "runtimeVerify") &&
2561
+ (!featureAppAsk || sawFeatureImplWrite)) {
1641
2562
  const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
1642
- const codingPlanFinished = Boolean(runtimePlan &&
1643
- runtimePlan.kind !== "pentest" &&
1644
- runtimePlan.tasks.length > 0 &&
1645
- runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
1646
- if (codingPlanFinished) {
1647
- runtimeVerificationRetries += 1;
1648
- pushAssistantHistory(assistantText.visible);
1649
- messages.push({
1650
- role: "user",
1651
- content: "This is a CODING project only: run the missing local checks (shell.start + shell.tail + localhost HTTP probe). " +
1652
- "Keep the dev server running and print the localhost link. " +
1653
- "If this was a remote pentest, ignore this and finalize the report with no local server.",
1654
- });
1655
- continue;
2563
+ // Durable plan evidence or multi-signal proof this turn is enough
2564
+ const planRuntimeOk = Boolean(runtimePlan && planHasVerifiedRuntime(runtimePlan));
2565
+ const sessionRuntimeOk = sawServerStart &&
2566
+ (sawServerTail || sawLocalHttpProbe || planRuntimeOk);
2567
+ if (!planRuntimeOk && !sessionRuntimeOk) {
2568
+ const codingPlanFinished = Boolean(runtimePlan &&
2569
+ session.planApproved.value &&
2570
+ runtimePlan.kind !== "pentest" &&
2571
+ runtimePlan.tasks.length > 0 &&
2572
+ runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
2573
+ const freestyleLocalAppDone = !session.planApproved.value &&
2574
+ sawLocalAppMaterialWork &&
2575
+ productiveSteps > 0 &&
2576
+ freestyleClaimsAppReady(cleaned) &&
2577
+ (getActiveProjectRoot() !== undefined ||
2578
+ /\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
2579
+ /\bopen\s+http:\/\/localhost\b/i.test(cleaned));
2580
+ if (codingPlanFinished || freestyleLocalAppDone) {
2581
+ const action = recoveryForRuntimeVerify(getActiveProjectRoot());
2582
+ consumeBudget(recovery, action.budgetKey);
2583
+ pushAssistantHistory(assistantText.visible);
2584
+ messages.push({ role: "user", content: action.message });
2585
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2586
+ continue;
2587
+ }
1656
2588
  }
1657
2589
  }
1658
- // Premature-completion guard (approved plan still has work)
1659
- // If the user approved a plan and the model now gives a final answer
1660
- // while tasks are still pending/in_progress — without having run the
1661
- // work — it is fabricating completion (the exact "all tasks completed,
1662
- // running at localhost:5173" failure). Force it back to executing the
1663
- // next real task instead of accepting the false claim.
1664
- if (session.planApproved.value && prematureCompletionRetries < 3) {
2590
+ if (buildLike &&
2591
+ !pentestLike &&
2592
+ !pentestSession &&
2593
+ sawFailedLocalHttpProbe &&
2594
+ !sawLocalHttpProbe &&
2595
+ budgetRemaining(recovery, "failedProbe") &&
2596
+ cleaned.trim().length > 0) {
2597
+ const action = recoveryForFailedProbe();
2598
+ consumeBudget(recovery, action.budgetKey);
2599
+ pushAssistantHistory(assistantText.visible);
2600
+ messages.push({ role: "user", content: action.message });
2601
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2602
+ continue;
2603
+ }
2604
+ if ((pentestLike || pentestSession) &&
2605
+ budgetRemaining(recovery, "shallowPentest") &&
2606
+ looksLikeShallowPentestReport(cleaned, {
2607
+ productiveSteps,
2608
+ sawActiveTest: sawActivePentestTest,
2609
+ })) {
2610
+ const action = recoveryForShallowPentest();
2611
+ consumeBudget(recovery, action.budgetKey);
2612
+ pushAssistantHistory(assistantText.visible);
2613
+ messages.push({ role: "user", content: action.message });
2614
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2615
+ continue;
2616
+ }
2617
+ if (session.planApproved.value &&
2618
+ budgetRemaining(recovery, "prematureComplete")) {
1665
2619
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1666
2620
  const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1667
2621
  if (livePlan && unfinished && unfinished.length > 0) {
1668
- prematureCompletionRetries += 1;
1669
2622
  const next = unfinished[0];
1670
- writeNotice("warn", `${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution`, chalk.yellow(` ⚠ ${unfinished.length} plan task(s) still unfinished — not accepting a "done" claim; resuming execution\n`));
1671
- pushAssistantHistory(assistantText.visible);
1672
- const isPentestPlan = livePlan.kind === "pentest" || pentestSession;
1673
- let instruction = `Resume now with the NEXT task ${next.id} ("${next.title}"): `;
1674
- if (isPentestPlan) {
1675
- instruction +=
1676
- `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the recon/testing work ` +
1677
- `(dns/http/net.scan/http.fetch/tool.batch — NOT a local dev server), VERIFY with real tool output, ` +
1678
- `then task.update done. Do NOT shell.start / npm run dev / explore the clai workspace. `;
1679
- }
1680
- else if (next.state === "pending") {
1681
- instruction += `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the real work with a tool call (fs.writeMany / shell.exec / shell.start when building a local app), VERIFY it, and mark it done. `;
1682
- }
1683
- else {
1684
- instruction += `do the real work with a tool call (fs.writeMany / shell.exec / shell.start when building a local app) to complete it, VERIFY it, and mark it done (call task.update {taskId:"${next.id}", state:"done"}). `;
1685
- }
1686
- instruction += `Continue task by task until EVERY task is actually finished.`;
1687
- messages.push({
1688
- role: "user",
1689
- content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
1690
- `(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
1691
- `Do NOT claim the work is complete unless a tool call actually succeeded and you saw the output. ` +
1692
- instruction,
2623
+ const action = recoveryForPrematureComplete({
2624
+ unfinished,
2625
+ next,
2626
+ pentest: livePlan.kind === "pentest" || pentestSession,
2627
+ errorFix: errorFixNarration,
1693
2628
  });
2629
+ consumeBudget(recovery, action.budgetKey);
2630
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2631
+ pushAssistantHistory(assistantText.visible);
2632
+ messages.push({ role: "user", content: action.message });
1694
2633
  continue;
1695
2634
  }
1696
2635
  }
1697
- // If we still print a final answer while an approved plan has unfinished
1698
- // tasks (retries exhausted), do NOT let a fabricated "it's done" stand
1699
- // unchallenged — append an explicit, honest status so the user knows the
1700
- // build did not actually complete.
1701
- let completionWarning = "";
1702
- let completionWarningText = "";
2636
+ let outcomeStatus = "succeeded";
2637
+ const remainingCriteria = [];
1703
2638
  if (session.planApproved.value) {
1704
2639
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1705
- const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1706
- if (livePlan && unfinished && unfinished.length > 0) {
1707
- completionWarningText =
1708
- `${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete. ` +
1709
- "The summary above may overstate progress.";
1710
- completionWarning =
1711
- chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
1712
- unfinished
1713
- .map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
1714
- .join("\n") +
1715
- chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
1716
- }
2640
+ const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
2641
+ const failedTasks = livePlan?.tasks.filter((t) => t.state === "failed") ?? [];
2642
+ remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
2643
+ if (failedTasks.length > 0)
2644
+ outcomeStatus = "failed";
2645
+ else if (unfinished.length > 0)
2646
+ outcomeStatus = "partial";
1717
2647
  }
1718
- if (cleaned) {
1719
- writeAssistantMessage(cleaned);
2648
+ recordAnswerEvidence(outcomeState, cleaned);
2649
+ outcomeState.outcome.status = deriveOutcomeStatus(outcomeState.outcome, outcomeState.evidence);
2650
+ await saveOutcomeState(outcomeState);
2651
+ const unsupportedCriteria = outcomeState.outcome.criteria.filter((criterion) => criterion.required &&
2652
+ !validateCriterionEvidence(criterion, outcomeState.evidence).ok);
2653
+ if (unsupportedCriteria.length > 0 && outcomeStatus === "succeeded") {
2654
+ outcomeStatus = "partial";
1720
2655
  }
1721
- if (completionWarning) {
1722
- writeNotice("warn", completionWarningText, completionWarning);
1723
- }
1724
- await auditLog("agent.final", { provider, model, steps: step + 1 });
2656
+ remainingCriteria.push(...unsupportedCriteria
2657
+ .map((criterion) => criterion.statement)
2658
+ .filter((statement) => !remainingCriteria.includes(statement)));
2659
+ moveTurn("verifying", "evaluating current criterion-linked evidence");
2660
+ moveTurn(outcomeStatus, `turn completed with ${outcomeStatus} evidence status`);
2661
+ await auditLog("agent.final", {
2662
+ provider,
2663
+ model,
2664
+ steps: step + 1,
2665
+ outcomeStatus,
2666
+ remainingCriteria,
2667
+ });
1725
2668
  lastAnswer = cleaned;
1726
- return finishTurn(lastAnswer, step + 1);
2669
+ return finishTurn(lastAnswer, step + 1, outcomeStatus, remainingCriteria, outcomeStatus === "failed"
2670
+ ? "One or more required plan tasks failed."
2671
+ : outcomeStatus === "partial"
2672
+ ? "Required outcome criteria remain unsupported by current evidence."
2673
+ : undefined);
1727
2674
  }
1728
2675
  // A valid primary tool call exists for this fresh model turn. Show any
1729
2676
  // prose / thinking that preceded it, record the assistant message ONCE.
1730
2677
  const beforeTool = recoveredFromBareJson
1731
2678
  ? ""
1732
- : textBeforeToolCall(assistantText.visible);
2679
+ : nativeToolCalls.length
2680
+ ? assistantText.visible.trim()
2681
+ : textBeforeToolCall(assistantText.visible);
1733
2682
  if (beforeTool) {
1734
2683
  writeAssistantMessage(beforeTool);
1735
2684
  }
1736
- let allCalls = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
1737
- if (allCalls.length === 0 && call) {
1738
- allCalls = [call];
2685
+ let bound = [];
2686
+ if (nativeToolCalls.length) {
2687
+ bound = nativeToolCalls.map((tc, index) => {
2688
+ const call = tc.args?._parseError
2689
+ ? {
2690
+ name: tc.name || "unknown",
2691
+ args: {
2692
+ __nativeParseError: true,
2693
+ _raw: tc.args._raw,
2694
+ },
2695
+ }
2696
+ : normalizeToolCall({ name: tc.name, args: tc.args });
2697
+ return { index, id: tc.id, call, native: tc };
2698
+ });
1739
2699
  }
2700
+ else {
2701
+ let parsed = parseAllToolCalls(assistantText.visible || assistantText.thinkContent);
2702
+ if (parsed.length === 0 && call)
2703
+ parsed = [call];
2704
+ bound = parsed.map((c, index) => {
2705
+ const id = syntheticToolCallId(index);
2706
+ return {
2707
+ index,
2708
+ id,
2709
+ call: c,
2710
+ native: { id, name: c.name, args: c.args },
2711
+ };
2712
+ });
2713
+ }
2714
+ /** Subset that will actually run this turn (defer/omit rest). */
2715
+ let toRun = bound;
1740
2716
  let activeDeferredToolCalls = deferredToolCalls;
1741
- // A plan must be based on the outputs of prior reconnaissance, never
1742
- // on calls the model merely proposed in the same response. If a model
1743
- // emits plan.create alongside gathering calls, run only the calls
1744
- // before it, then let the next model turn analyse their actual results
1745
- // and emit one standalone plan.create. Calls after the attempted plan
1746
- // are intentionally discarded: they were proposed before a plan was
1747
- // created or approved.
1748
- const planCallIndex = allCalls.findIndex((candidate) => candidate.name === "plan.create");
2717
+ let deferReason = "Cancelled not executed this turn (deferred or omitted).";
2718
+ const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
1749
2719
  if (planCallIndex > 0) {
1750
- // plan.create is bundled AFTER gathering calls in the SAME message,
1751
- // so its reconnaissance results do not exist yet. Run only the
1752
- // preceding gathering calls, then let the next turn analyse their
1753
- // actual results and emit one standalone plan.create.
1754
- const gatheringCalls = allCalls.slice(0, planCallIndex);
1755
- const deferredCount = allCalls.length - gatheringCalls.length;
1756
- allCalls = gatheringCalls;
1757
- activeDeferredToolCalls = deferredToolCalls.slice(0, gatheringCalls.length);
1758
- writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${gatheringCalls.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
2720
+ const deferredCount = bound.length - planCallIndex;
2721
+ toRun = bound.slice(0, planCallIndex);
2722
+ activeDeferredToolCalls = deferredToolCalls.slice(0, planCallIndex);
2723
+ deferReason =
2724
+ "Deferred plan.create must wait until reconnaissance results exist.";
2725
+ writeNotice("info", "deferring plan.create until reconnaissance results are available", chalk.dim(` ℹ running ${toRun.length} gathering call(s); ${deferredCount} plan/follow-on call(s) deferred for evidence-based planning\n`));
1759
2726
  messages.push({
1760
2727
  role: "system",
1761
2728
  content: `The prior response included plan.create before its reconnaissance results existed. ` +
1762
- `Only the ${gatheringCalls.length} gathering call(s) before it were run; ${deferredCount} plan/follow-on call(s) were not run. ` +
2729
+ `Only the ${toRun.length} gathering call(s) before it were run; ${deferredCount} plan/follow-on call(s) were not run. ` +
1763
2730
  "Now analyse the tool results. If a plan is appropriate, emit exactly one standalone plan.create tool call based only on those results. Do not include any other tool calls in that response.",
1764
2731
  });
1765
2732
  }
1766
- else if (planCallIndex === 0 && allCalls.length > 1) {
1767
- // plan.create is the FIRST call but bundled with follow-on calls.
1768
- // The plan is based on reconnaissance from prior turns (already in
1769
- // history), so execute the plan.create now and defer only the calls
1770
- // proposed after it — those were proposed before the plan was
1771
- // created or approved.
1772
- const deferredCount = allCalls.length - 1;
1773
- allCalls = allCalls.slice(0, 1);
2733
+ else if (planCallIndex === 0 && bound.length > 1) {
2734
+ const deferredCount = bound.length - 1;
2735
+ toRun = bound.slice(0, 1);
1774
2736
  activeDeferredToolCalls = deferredToolCalls.slice(0, 1);
2737
+ deferReason =
2738
+ "Deferred — waiting for plan approval before follow-on tools.";
1775
2739
  writeNotice("info", "creating the plan now; deferring follow-on calls until it is approved", chalk.dim(` ℹ running plan.create from prior reconnaissance; ${deferredCount} follow-on call(s) deferred until after approval\n`));
1776
2740
  messages.push({
1777
2741
  role: "system",
@@ -1779,68 +2743,37 @@ export async function runAgentLoop(prompt, options = {}) {
1779
2743
  `the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
1780
2744
  });
1781
2745
  }
1782
- // planCallIndex === 0 && allCalls.length === 1: a standalone plan.create
1783
- // built from prior reconnaissance. Execute it normally deferring it
1784
- // here (as the previous `>= 0` guard did) ran zero calls and looped the
1785
- // agent forever without ever creating the plan.
1786
- // A single model message can contain an unbounded number of calls.
1787
- // Even with read-only calls fanned out, a giant batch can tie up the
1788
- // session for minutes and makes cancellation feel broken. Keep each
1789
- // model turn bounded; after these results the agent gets another turn
1790
- // to prioritise the remaining work from real evidence.
1791
- const MAX_CALLS_PER_MODEL_TURN = 12;
1792
- const omittedCallCount = Math.max(0, allCalls.length - MAX_CALLS_PER_MODEL_TURN);
1793
- if (omittedCallCount > 0) {
1794
- allCalls = allCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
1795
- activeDeferredToolCalls = activeDeferredToolCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
1796
- writeNotice("warn", `limited this model response to ${MAX_CALLS_PER_MODEL_TURN} tool calls`, chalk.yellow(` ⚠ executing the first ${MAX_CALLS_PER_MODEL_TURN} tool calls; ${omittedCallCount} more were deferred for reprioritisation\n`));
1797
- messages.push({
1798
- role: "system",
1799
- content: `You emitted too many tool calls in one response. Only the first ${MAX_CALLS_PER_MODEL_TURN} were executed; ` +
1800
- `${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
1801
- });
1802
- }
2746
+ // Preserve model/document order. In particular, never move a later
2747
+ // in_progress transition ahead of the preceding work or done receipt;
2748
+ // doing so inverts dependency order and desynchronizes the task pane.
2749
+ // Re-index toRun positions for UI callIds[] (0..n-1 this turn).
2750
+ toRun = toRun.map((b, index) => ({ ...b, index }));
2751
+ const allCalls = toRun.map((b) => b.call);
2752
+ /** Stable call→Bound map (object identity; no indexOf for result ids). */
2753
+ const callToBound = new Map(toRun.map((b) => [b.call, b]));
2754
+ const historyNativeCalls = bound.map((b) => b.native);
2755
+ const runIds = new Set(toRun.map((b) => b.id));
1803
2756
  // Notice BEFORE tool cards so the transcript reads:
1804
2757
  // thinking → response → "N tool calls…" → tool cards (not tools then info).
1805
2758
  if (allCalls.length > 1) {
1806
2759
  writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls — read-only lookups in parallel, the rest in order\n`));
1807
2760
  }
1808
- // Emit only the calls that will actually execute, after thinking
1809
- // + assistant text so transcript order remains correct.
1810
2761
  for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
2762
+ if (!deferred.call.name || deferred.call.name === "…")
2763
+ continue;
1811
2764
  writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
1812
2765
  }
1813
- const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
1814
- allCalls
1815
- .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
1816
- .join("\n\n");
1817
- pushAssistantHistory(standardizedContent);
1818
- // Scoped-parallel batch execution
1819
- // The model may emit several calls in one message. We partition them,
1820
- // IN DOCUMENT ORDER, into segments:
1821
- // • A run of consecutive READ-ONLY, safe-classified calls (the same
1822
- // allowlist tool.batch uses) executes CONCURRENTLY — this is where
1823
- // independent lookups within a single task fan out (e.g. whois +
1824
- // dns + http.fetch during recon).
1825
- // • Every other call (plan.create/task.update, and any mutating or
1826
- // confirm-level tool: fs.write*, shell.exec, pkg.install, net.scan)
1827
- // runs ALONE as a sequential barrier.
1828
- // Because task.update is never parallel-safe, it always acts as a
1829
- // barrier: it commits before the work it gates and after the work it
1830
- // closes. That keeps execution strictly task-by-task and eliminates the
1831
- // plan-state races / overlapping writes that a blanket Promise.all
1832
- // caused, while still letting one task's independent lookups run in
1833
- // parallel. A failed independent read-only lookup does not prevent
1834
- // later recon from running; aborts, blocks, and sequential-barrier
1835
- // failures still stop the batch so the model can react safely.
2766
+ if (historyNativeCalls.length) {
2767
+ appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
2768
+ }
2769
+ else {
2770
+ const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
2771
+ allCalls
2772
+ .map((c) => `\`\`\`tool\n${JSON.stringify(c)}\n\`\`\``)
2773
+ .join("\n\n");
2774
+ pushAssistantHistory(standardizedContent);
2775
+ }
1836
2776
  const scopeForBatch = await loadScope().catch(() => undefined);
1837
- /**
1838
- * Tools that may share a concurrent wave. Pure read-only BATCH_SAFE
1839
- * tools always qualify when classified safe. Heavy discovery wrappers
1840
- * (pentest.recon, net.context) also run concurrent with dns/http so
1841
- * nmap does not serialize the entire recon wave.
1842
- * net.scan stays a barrier when it needs confirm/sudo UX.
1843
- */
1844
2777
  const isParallelSafe = (c) => {
1845
2778
  if (c.name === "pentest.recon" ||
1846
2779
  c.name === "net.context" ||
@@ -1861,19 +2794,10 @@ export async function runAgentLoop(prompt, options = {}) {
1861
2794
  };
1862
2795
  /** Tools whose failure must NOT cancel sibling calls in this turn. */
1863
2796
  const shouldSoftFailTool = (name) => {
1864
- if (name === "tool.batch")
2797
+ if (isBatchSoftFailTool(name))
1865
2798
  return true;
1866
2799
  if (BATCH_SAFE_TOOLS.has(name))
1867
2800
  return true;
1868
- if (name === "pentest.recon" ||
1869
- name === "net.scan" ||
1870
- name === "net.pingSweep" ||
1871
- name === "net.context" ||
1872
- name === "shell.jobs" ||
1873
- name === "shell.tail" ||
1874
- name === "tool.check") {
1875
- return true;
1876
- }
1877
2801
  return false;
1878
2802
  };
1879
2803
  // Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
@@ -1883,34 +2807,114 @@ export async function runAgentLoop(prompt, options = {}) {
1883
2807
  let blockedResult = null;
1884
2808
  let failed = false;
1885
2809
  let awaitingPlanApproval = false;
1886
- /** Indices into allCalls that actually ran (got a tool-result). */
1887
- const executedIndices = new Set();
1888
- const recordResult = (res, continueAfterFailure = false) => {
1889
- const idx = allCalls.indexOf(res.call);
1890
- if (idx >= 0)
1891
- executedIndices.add(idx);
1892
- messages.push({
1893
- role: "tool",
1894
- content: `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`,
1895
- });
2810
+ /** Native tool_call ids that already have a role:tool history entry. */
2811
+ const recordedNativeIds = new Set();
2812
+ const recordResult = (boundCall, res, continueAfterFailure = false) => {
2813
+ recordedNativeIds.add(boundCall.id);
2814
+ const toolContent = `Tool ${res.call.name} result (exit=${res.result.exitCode ?? 0}, ok=${res.result.ok}):\n${res.contextOutput}`;
2815
+ if (historyNativeCalls.length) {
2816
+ appendToolResult(messages, boundCall.id, toolContent, res.call.name, res.result.ok);
2817
+ }
2818
+ else {
2819
+ messages.push({
2820
+ role: "tool",
2821
+ content: toolContent,
2822
+ });
2823
+ }
1896
2824
  productiveSteps += 1;
1897
2825
  // Reset retry counters — they track consecutive failures, not cumulative.
1898
2826
  truncatedToolRetries = 0;
1899
2827
  malformedFenceRetries = 0;
1900
2828
  bareToolJsonRetries = 0;
2829
+ if (res.ok && isEvidenceWorkTool(res.call.name)) {
2830
+ recovery.prematureComplete = 0;
2831
+ recovery.actionIntent = 0;
2832
+ recovery.errorFix = 0;
2833
+ }
1901
2834
  if (res.ok && res.call.name === "shell.start")
1902
2835
  sawServerStart = true;
1903
- if (res.ok && res.call.name === "shell.tail")
2836
+ if (res.ok && res.call.name === "shell.tail") {
1904
2837
  sawServerTail = true;
2838
+ const tailOut = res.result.output ?? res.contextOutput ?? "";
2839
+ if (isServerReadyOutput(tailOut)) {
2840
+ sawServerStart = true;
2841
+ sawServerTail = true;
2842
+ }
2843
+ }
2844
+ if (res.ok &&
2845
+ res.call.name === "shell.exec" &&
2846
+ isPortListeningOutput(String(res.call.args.command ?? ""), res.result.output ?? res.contextOutput ?? "")) {
2847
+ sawServerStart = true;
2848
+ }
2849
+ if (res.ok &&
2850
+ (pentestLike || pentestSession) &&
2851
+ (res.call.name === "http.fetch" ||
2852
+ res.call.name === "shell.exec" ||
2853
+ res.call.name === "net.scan" ||
2854
+ res.call.name === "pentest.recon")) {
2855
+ const blob = `${res.call.name} ${JSON.stringify(res.call.args)}`;
2856
+ if (/\b(sqlmap|hydra|nikto|nuclei|ffuf|gobuster|exploit|payload|idor|xss|union\s+select)\b/i.test(blob) ||
2857
+ (res.call.name === "http.fetch" &&
2858
+ typeof res.call.args.method === "string" &&
2859
+ !/^get$/i.test(res.call.args.method))) {
2860
+ sawActivePentestTest = true;
2861
+ }
2862
+ }
1905
2863
  if (res.ok &&
1906
2864
  ((res.call.name === "http.fetch" &&
1907
2865
  /^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
1908
2866
  (res.call.name === "shell.exec" &&
1909
2867
  /\bcurl\b[\s\S]*\b(?:localhost|127\.0\.0\.1|\[::1\])\b/i.test(String(res.call.args.command ?? ""))))) {
1910
- sawLocalHttpProbe = true;
2868
+ const out = res.result.output ?? res.contextOutput ?? "";
2869
+ if (localHttpProbeIsFailure(out)) {
2870
+ sawFailedLocalHttpProbe = true;
2871
+ sawLocalHttpProbe = false;
2872
+ }
2873
+ else if (localHttpProbeIsSuccess(out)) {
2874
+ sawLocalHttpProbe = true;
2875
+ sawFailedLocalHttpProbe = false;
2876
+ recovery.failedProbe = 0;
2877
+ }
2878
+ else if (res.call.name === "shell.exec" &&
2879
+ !localHttpProbeIsFailure(out)) {
2880
+ // curl without status line — soft success
2881
+ sawLocalHttpProbe = true;
2882
+ sawFailedLocalHttpProbe = false;
2883
+ }
2884
+ }
2885
+ // Track freestyle local-app materialization (scaffold / install / feature write)
2886
+ if (res.ok) {
2887
+ const cmd = typeof res.call.args.command === "string"
2888
+ ? res.call.args.command
2889
+ : "";
2890
+ const pathArg = typeof res.call.args.path === "string" ? res.call.args.path : "";
2891
+ if (isScaffoldCreateCommand(cmd)) {
2892
+ sawScaffoldOk = true;
2893
+ sawLocalAppMaterialWork = true;
2894
+ }
2895
+ if (isFeatureImplementationCall(res.call)) {
2896
+ sawFeatureImplWrite = true;
2897
+ sawLocalAppMaterialWork = true;
2898
+ }
2899
+ if (/\b(?:npm|pnpm|yarn|bun)\s+i(?:nstall)?\b/i.test(cmd) ||
2900
+ res.call.name === "fs.write" ||
2901
+ res.call.name === "fs.writeMany" ||
2902
+ res.call.name === "fs.edit" ||
2903
+ (pathArg &&
2904
+ getActiveProjectRoot() &&
2905
+ (pathArg.includes(getActiveProjectRoot()) ||
2906
+ !pathArg.startsWith("/")))) {
2907
+ sawLocalAppMaterialWork = true;
2908
+ }
1911
2909
  }
1912
2910
  if (res.call.name === "plan.create" && res.ok) {
1913
- awaitingPlanApproval = true;
2911
+ sawPlanCreateOk = true;
2912
+ if (isPlanMode) {
2913
+ awaitingPlanApproval = true;
2914
+ }
2915
+ else {
2916
+ session.planApproved.value = true;
2917
+ }
1914
2918
  }
1915
2919
  if (res.lastAnswer === "Aborted.")
1916
2920
  aborted = true;
@@ -1923,98 +2927,119 @@ export async function runAgentLoop(prompt, options = {}) {
1923
2927
  };
1924
2928
  const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
1925
2929
  for (const group of groups) {
1926
- if (aborted || blocked || failed || awaitingPlanApproval)
2930
+ if (aborted ||
2931
+ blocked ||
2932
+ failed ||
2933
+ awaitingPlanApproval ||
2934
+ governorPauseReason)
1927
2935
  break;
1928
2936
  if (group.length === 1) {
1929
2937
  const call = group[0];
1930
- const idx = allCalls.indexOf(call);
1931
- if (idx >= 0 && !callIds[idx]) {
1932
- callIds[idx] = `tool-${++nextToolEventId}`;
2938
+ const bc = callToBound.get(call);
2939
+ if (!bc)
2940
+ continue;
2941
+ if (!callIds[bc.index]) {
2942
+ callIds[bc.index] = `tool-${++nextToolEventId}`;
1933
2943
  }
1934
- const id = (idx >= 0 ? callIds[idx] : undefined) ?? `tool-${++nextToolEventId}`;
2944
+ const id = callIds[bc.index];
1935
2945
  const res = await executeSingleTool(call, id, options.signal || new AbortController().signal);
1936
- // Soft-fail recon / read-only / discovery tools so a stalled
1937
- // whois or failed lookup never cancels the rest of the turn with
1938
- // "Cancelled — earlier tool in this batch failed."
1939
2946
  const softFail = shouldSoftFailTool(call.name);
1940
- recordResult(res, softFail);
2947
+ recordResult(bc, res, softFail);
1941
2948
  }
1942
2949
  else {
1943
- // Concurrent group — assign ids in document order, then push their
1944
- // results in document order for a stable transcript.
1945
- const ids = group.map((c) => {
1946
- const idx = allCalls.indexOf(c);
1947
- if (idx >= 0 && !callIds[idx]) {
1948
- callIds[idx] = `tool-${++nextToolEventId}`;
2950
+ // Concurrent group — BoundCall via Map; record in document order.
2951
+ const groupBound = [];
2952
+ const uiIds = [];
2953
+ for (const c of group) {
2954
+ const bc = callToBound.get(c);
2955
+ if (!bc)
2956
+ continue;
2957
+ if (!callIds[bc.index]) {
2958
+ callIds[bc.index] = `tool-${++nextToolEventId}`;
1949
2959
  }
1950
- return (idx >= 0 ? callIds[idx] : undefined) ?? `tool-${++nextToolEventId}`;
1951
- });
1952
- const results = await Promise.all(group.map((c, k) => executeSingleTool(c, ids[k], options.signal || new AbortController().signal)));
1953
- // These calls are explicitly safe and independent. Preserve every
1954
- // result for the model, but do not abandon remaining reconnaissance
1955
- // merely because one lookup times out or a remote service fails.
1956
- for (const res of results)
1957
- recordResult(res, true);
1958
- }
1959
- }
1960
- // Cards were emitted for every call up front. If the batch stopped
1961
- // early (failure / abort / plan gate), any card still on "running"
1962
- // must get a terminal result so the TUI never spins forever.
1963
- for (let i = 0; i < allCalls.length; i += 1) {
1964
- if (executedIndices.has(i))
1965
- continue;
1966
- const call = allCalls[i];
1967
- if (i >= 0 && !callIds[i]) {
1968
- callIds[i] = `tool-${++nextToolEventId}`;
2960
+ groupBound.push(bc);
2961
+ uiIds.push(callIds[bc.index]);
2962
+ }
2963
+ const results = await Promise.all(groupBound.map((bc, k) => executeSingleTool(bc.call, uiIds[k], options.signal || new AbortController().signal)));
2964
+ for (let k = 0; k < results.length; k += 1) {
2965
+ recordResult(groupBound[k], results[k], true);
2966
+ }
1969
2967
  }
1970
- const id = callIds[i];
1971
- if (!alreadyPrintedIds.has(id)) {
1972
- // Never shown in the transcript skip.
2968
+ }
2969
+ // Cards still "running" get a terminal UI result; history always pairs.
2970
+ for (let i = 0; i < toRun.length; i += 1) {
2971
+ const bc = toRun[i];
2972
+ if (recordedNativeIds.has(bc.id))
1973
2973
  continue;
2974
+ if (!callIds[i]) {
2975
+ callIds[i] = `tool-${++nextToolEventId}`;
1974
2976
  }
2977
+ const uiId = callIds[i];
1975
2978
  const reason = aborted
1976
2979
  ? "Cancelled — turn aborted before this call ran."
1977
2980
  : blocked
1978
2981
  ? "Cancelled — earlier tool was blocked or declined."
1979
2982
  : awaitingPlanApproval
1980
2983
  ? "Deferred — waiting for plan approval."
1981
- : failed
1982
- ? "Cancelledearlier tool in this batch failed."
1983
- : "Cancelled — not executed.";
2984
+ : governorPauseReason
2985
+ ? `Deferredprogress governor paused execution: ${governorPauseReason}`
2986
+ : failed
2987
+ ? "Cancelled — earlier tool in this batch failed."
2988
+ : "Cancelled — not executed.";
1984
2989
  const result = {
1985
2990
  ok: false,
1986
2991
  output: reason,
1987
2992
  exitCode: 130,
1988
2993
  };
1989
- emitToolResult(id, result, reason);
1990
- messages.push({
1991
- role: "tool",
1992
- content: `Tool ${call.name} result (exit=130, ok=false):\n${reason}`,
1993
- });
2994
+ if (alreadyPrintedIds.has(uiId)) {
2995
+ emitToolResult(uiId, result, reason);
2996
+ }
2997
+ if (historyNativeCalls.length) {
2998
+ appendToolResult(messages, bc.id, `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`, bc.call.name, false);
2999
+ recordedNativeIds.add(bc.id);
3000
+ }
3001
+ else {
3002
+ messages.push({
3003
+ role: "tool",
3004
+ content: `Tool ${bc.call.name} result (exit=130, ok=false):\n${reason}`,
3005
+ });
3006
+ }
3007
+ }
3008
+ // Synthetic results for deferred/omitted ids still listed on assistant.
3009
+ if (historyNativeCalls.length) {
3010
+ for (const b of bound) {
3011
+ if (runIds.has(b.id) || recordedNativeIds.has(b.id))
3012
+ continue;
3013
+ appendToolResult(messages, b.id, `Tool ${b.call.name} result (exit=130, ok=false):\n${deferReason}`, b.call.name, false);
3014
+ recordedNativeIds.add(b.id);
3015
+ }
3016
+ fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
3017
+ }
3018
+ if (deferredPostToolMessages.length > 0) {
3019
+ messages.push(...deferredPostToolMessages.splice(0));
1994
3020
  }
1995
- // plan.create is a hard transaction boundary. Its successful handler
1996
- // persists and displays the plan; returning immediately prevents a
1997
- // stale pre-loop activePlan snapshot from nudging a duplicate plan and
1998
- // prevents calls accidentally batched after plan.create from executing
1999
- // before /implement approval.
2000
3021
  if (awaitingPlanApproval) {
2001
3022
  pendingCalls = [];
2002
- return finishTurn("", productiveSteps);
3023
+ outcomeState.outcome.status = "partial";
3024
+ await saveOutcomeState(outcomeState);
3025
+ moveTurn("partial", "draft plan awaits approval");
3026
+ return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
2003
3027
  }
2004
3028
  if (aborted) {
2005
3029
  lastAnswer = "Aborted.";
3030
+ outcomeState.outcome.status = "aborted";
3031
+ await saveOutcomeState(outcomeState);
3032
+ moveTurn("aborted", "turn aborted");
2006
3033
  writeAbort();
2007
- return finishTurn(lastAnswer, productiveSteps);
3034
+ return finishTurn(lastAnswer, productiveSteps, "aborted");
2008
3035
  }
2009
3036
  if (blocked && blockedResult) {
2010
3037
  lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
2011
- return finishTurn(lastAnswer, productiveSteps);
3038
+ outcomeState.outcome.status = "blocked";
3039
+ await saveOutcomeState(outcomeState);
3040
+ moveTurn("blocked", lastAnswer);
3041
+ return finishTurn(lastAnswer, productiveSteps, "blocked");
2012
3042
  }
2013
- // A plain failure just stops the remaining calls; we fall through so
2014
- // the model sees the failed tool's output and decides what to do next.
2015
- // Compact older messages when the running estimate exceeds budget. Uses
2016
- // the model-written summary path (with plan re-injection) — never a
2017
- // mechanical transcript dump.
2018
3043
  await maybeAutoCompact("post-tool-token-budget");
2019
3044
  if (options.onMessages) {
2020
3045
  try {
@@ -2026,16 +3051,20 @@ export async function runAgentLoop(prompt, options = {}) {
2026
3051
  }
2027
3052
  }
2028
3053
  }
2029
- // maxIterations ceiling reached (safety net — normally the step budget
2030
- // gate with user confirmation handles stopping gracefully).
2031
3054
  const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
2032
- writeAssistantMessage(richSummary);
2033
3055
  lastAnswer = richSummary;
2034
- return finishTurn(lastAnswer, productiveSteps);
3056
+ outcomeState.outcome.status = "paused_budget";
3057
+ await saveOutcomeState(outcomeState);
3058
+ moveTurn("paused_budget", "emergency iteration ceiling reached");
3059
+ return finishTurn(lastAnswer, productiveSteps, "paused_budget", ["Continue unfinished work in a subsequent turn."], "The emergency iteration ceiling was reached.");
2035
3060
  }
2036
3061
  catch (error) {
2037
3062
  const isAbort = isAbortError(error, options.signal);
2038
3063
  const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
3064
+ if (isAbort) {
3065
+ writeAbort();
3066
+ return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
3067
+ }
2039
3068
  if (options.onMessages) {
2040
3069
  try {
2041
3070
  options.onMessages(buildTurnHistory(liveMessages, msg));
@@ -2044,10 +3073,6 @@ export async function runAgentLoop(prompt, options = {}) {
2044
3073
  // ignore
2045
3074
  }
2046
3075
  }
2047
- if (isAbort) {
2048
- writeAbort();
2049
- return "Aborted.";
2050
- }
2051
3076
  emit({
2052
3077
  type: "turn-error",
2053
3078
  message: error instanceof Error ? error.message : String(error),
@@ -2055,4 +3080,8 @@ export async function runAgentLoop(prompt, options = {}) {
2055
3080
  throw error;
2056
3081
  }
2057
3082
  }
3083
+ /** Compatibility boundary for callers that still consume rendered text. */
3084
+ export async function runAgentLoop(prompt, options = {}) {
3085
+ return renderTurnOutcome(await runAgentTurn(prompt, options));
3086
+ }
2058
3087
  //# sourceMappingURL=runner.js.map