@pentoshi/clai 3.6.0 → 3.7.3

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 (322) hide show
  1. package/dist/agent/compaction-summary.d.ts +17 -0
  2. package/dist/agent/compaction-summary.js +64 -0
  3. package/dist/agent/compaction-summary.js.map +1 -0
  4. package/dist/agent/confirm-port.d.ts +3 -1
  5. package/dist/agent/confirm-port.js +5 -1
  6. package/dist/agent/confirm-port.js.map +1 -1
  7. package/dist/agent/context-manager.js +45 -22
  8. package/dist/agent/context-manager.js.map +1 -1
  9. package/dist/agent/events.d.ts +6 -0
  10. package/dist/agent/evidence-governor.d.ts +37 -0
  11. package/dist/agent/evidence-governor.js +70 -0
  12. package/dist/agent/evidence-governor.js.map +1 -0
  13. package/dist/agent/loop-guard.d.ts +29 -11
  14. package/dist/agent/loop-guard.js +93 -39
  15. package/dist/agent/loop-guard.js.map +1 -1
  16. package/dist/agent/must-continue.d.ts +56 -0
  17. package/dist/agent/must-continue.js +240 -0
  18. package/dist/agent/must-continue.js.map +1 -0
  19. package/dist/agent/outcomes.d.ts +101 -0
  20. package/dist/agent/outcomes.js +359 -0
  21. package/dist/agent/outcomes.js.map +1 -0
  22. package/dist/agent/plan-decision.d.ts +33 -0
  23. package/dist/agent/plan-decision.js +104 -0
  24. package/dist/agent/plan-decision.js.map +1 -0
  25. package/dist/agent/plan-tool.d.ts +12 -0
  26. package/dist/agent/plan-tool.js +312 -104
  27. package/dist/agent/plan-tool.js.map +1 -1
  28. package/dist/agent/project-root.js +3 -0
  29. package/dist/agent/project-root.js.map +1 -1
  30. package/dist/agent/prompt-composer.d.ts +21 -0
  31. package/dist/agent/prompt-composer.js +46 -0
  32. package/dist/agent/prompt-composer.js.map +1 -0
  33. package/dist/agent/runner.d.ts +11 -1
  34. package/dist/agent/runner.js +1226 -971
  35. package/dist/agent/runner.js.map +1 -1
  36. package/dist/agent/scope-context.d.ts +3 -0
  37. package/dist/agent/scope-context.js +24 -0
  38. package/dist/agent/scope-context.js.map +1 -0
  39. package/dist/agent/scratch-write.d.ts +4 -0
  40. package/dist/agent/scratch-write.js +54 -0
  41. package/dist/agent/scratch-write.js.map +1 -0
  42. package/dist/agent/session-policy.d.ts +7 -0
  43. package/dist/agent/session-policy.js +61 -0
  44. package/dist/agent/session-policy.js.map +1 -1
  45. package/dist/agent/session-state.d.ts +41 -0
  46. package/dist/agent/session-state.js +107 -0
  47. package/dist/agent/session-state.js.map +1 -0
  48. package/dist/agent/step-budget.d.ts +14 -0
  49. package/dist/agent/step-budget.js +26 -0
  50. package/dist/agent/step-budget.js.map +1 -0
  51. package/dist/agent/task-analyzer.d.ts +12 -3
  52. package/dist/agent/task-analyzer.js +263 -23
  53. package/dist/agent/task-analyzer.js.map +1 -1
  54. package/dist/agent/task-evidence.d.ts +111 -29
  55. package/dist/agent/task-evidence.js +519 -91
  56. package/dist/agent/task-evidence.js.map +1 -1
  57. package/dist/agent/task-plan.d.ts +57 -0
  58. package/dist/agent/task-plan.js +147 -1
  59. package/dist/agent/task-plan.js.map +1 -1
  60. package/dist/agent/tool-call-parser.d.ts +4 -12
  61. package/dist/agent/tool-call-parser.js +34 -67
  62. package/dist/agent/tool-call-parser.js.map +1 -1
  63. package/dist/agent/tool-history.d.ts +7 -0
  64. package/dist/agent/tool-history.js +50 -0
  65. package/dist/agent/tool-history.js.map +1 -1
  66. package/dist/agent/tool-output-formatting.d.ts +13 -1
  67. package/dist/agent/tool-output-formatting.js +75 -22
  68. package/dist/agent/tool-output-formatting.js.map +1 -1
  69. package/dist/agent/turn-outcome.d.ts +11 -0
  70. package/dist/agent/turn-outcome.js +17 -0
  71. package/dist/agent/turn-outcome.js.map +1 -0
  72. package/dist/agent/turn-state.d.ts +11 -0
  73. package/dist/agent/turn-state.js +28 -0
  74. package/dist/agent/turn-state.js.map +1 -0
  75. package/dist/agent/workspace-orient.d.ts +11 -0
  76. package/dist/agent/workspace-orient.js +103 -12
  77. package/dist/agent/workspace-orient.js.map +1 -1
  78. package/dist/app/adapters/agent-event-adapter.d.ts +2 -13
  79. package/dist/app/adapters/agent-event-adapter.js +63 -25
  80. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  81. package/dist/app/adapters/current-agent-adapter.d.ts +0 -5
  82. package/dist/app/adapters/current-agent-adapter.js +4 -7
  83. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  84. package/dist/app/adapters/current-jobs-adapter.js +1 -0
  85. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  86. package/dist/app/adapters/current-terminal-adapter.d.ts +0 -7
  87. package/dist/app/adapters/current-terminal-adapter.js +0 -7
  88. package/dist/app/adapters/current-terminal-adapter.js.map +1 -1
  89. package/dist/app/adapters/current-updates-adapter.d.ts +0 -5
  90. package/dist/app/adapters/current-updates-adapter.js +0 -5
  91. package/dist/app/adapters/current-updates-adapter.js.map +1 -1
  92. package/dist/app/adapters/in-memory-clipboard-adapter.d.ts +0 -5
  93. package/dist/app/adapters/in-memory-clipboard-adapter.js +0 -5
  94. package/dist/app/adapters/in-memory-clipboard-adapter.js.map +1 -1
  95. package/dist/app/commands/command.d.ts +0 -5
  96. package/dist/app/commands/command.js.map +1 -1
  97. package/dist/app/commands/registry.d.ts +0 -14
  98. package/dist/app/commands/registry.js +0 -20
  99. package/dist/app/commands/registry.js.map +1 -1
  100. package/dist/app/controllers/disposable.d.ts +0 -5
  101. package/dist/app/controllers/disposable.js +0 -5
  102. package/dist/app/controllers/disposable.js.map +1 -1
  103. package/dist/app/controllers/job-controller.d.ts +1 -5
  104. package/dist/app/controllers/job-controller.js +0 -4
  105. package/dist/app/controllers/job-controller.js.map +1 -1
  106. package/dist/app/controllers/plan-controller.d.ts +5 -8
  107. package/dist/app/controllers/plan-controller.js +30 -12
  108. package/dist/app/controllers/plan-controller.js.map +1 -1
  109. package/dist/app/controllers/session-controller.d.ts +17 -32
  110. package/dist/app/controllers/session-controller.js +56 -35
  111. package/dist/app/controllers/session-controller.js.map +1 -1
  112. package/dist/app/controllers/turn-controller.d.ts +2 -6
  113. package/dist/app/controllers/turn-controller.js +8 -8
  114. package/dist/app/controllers/turn-controller.js.map +1 -1
  115. package/dist/app/events/app-event.d.ts +4 -7
  116. package/dist/app/events/app-event.js +0 -6
  117. package/dist/app/events/app-event.js.map +1 -1
  118. package/dist/app/events/event-buffer.d.ts +6 -17
  119. package/dist/app/events/event-buffer.js +27 -24
  120. package/dist/app/events/event-buffer.js.map +1 -1
  121. package/dist/app/events/sequencer.d.ts +0 -9
  122. package/dist/app/events/sequencer.js +0 -4
  123. package/dist/app/events/sequencer.js.map +1 -1
  124. package/dist/app/ports/agent-port.d.ts +13 -3
  125. package/dist/app/ports/clipboard-port.d.ts +0 -5
  126. package/dist/app/ports/confirm-port.d.ts +0 -7
  127. package/dist/app/ports/jobs-port.d.ts +2 -5
  128. package/dist/app/ports/persistence-port.d.ts +0 -4
  129. package/dist/app/ports/secret-port.d.ts +0 -5
  130. package/dist/app/ports/terminal-port.d.ts +0 -6
  131. package/dist/app/ports/updates-port.d.ts +0 -4
  132. package/dist/attachments/service.d.ts +29 -0
  133. package/dist/attachments/service.js +47 -0
  134. package/dist/attachments/service.js.map +1 -0
  135. package/dist/commands/providers.js +0 -3
  136. package/dist/commands/providers.js.map +1 -1
  137. package/dist/commands/search-providers.d.ts +0 -33
  138. package/dist/commands/search-providers.js +0 -33
  139. package/dist/commands/search-providers.js.map +1 -1
  140. package/dist/commands/update.js +1 -9
  141. package/dist/commands/update.js.map +1 -1
  142. package/dist/index.js +22 -42
  143. package/dist/index.js.map +1 -1
  144. package/dist/llm/adapters/ollama-tools.d.ts +0 -4
  145. package/dist/llm/adapters/ollama-tools.js +0 -4
  146. package/dist/llm/adapters/ollama-tools.js.map +1 -1
  147. package/dist/llm/capabilities.d.ts +10 -10
  148. package/dist/llm/capabilities.js +43 -17
  149. package/dist/llm/capabilities.js.map +1 -1
  150. package/dist/llm/groq.js +0 -6
  151. package/dist/llm/groq.js.map +1 -1
  152. package/dist/llm/http.d.ts +0 -17
  153. package/dist/llm/http.js +0 -45
  154. package/dist/llm/http.js.map +1 -1
  155. package/dist/llm/router.d.ts +0 -6
  156. package/dist/llm/router.js +0 -15
  157. package/dist/llm/router.js.map +1 -1
  158. package/dist/llm/tool-protocol.d.ts +0 -6
  159. package/dist/llm/tool-protocol.js +3 -7
  160. package/dist/llm/tool-protocol.js.map +1 -1
  161. package/dist/modes/agent.d.ts +2 -1
  162. package/dist/modes/agent.js +4 -2
  163. package/dist/modes/agent.js.map +1 -1
  164. package/dist/modes/ask.d.ts +0 -18
  165. package/dist/modes/ask.js +0 -49
  166. package/dist/modes/ask.js.map +1 -1
  167. package/dist/os/cwd.d.ts +0 -21
  168. package/dist/os/cwd.js +0 -24
  169. package/dist/os/cwd.js.map +1 -1
  170. package/dist/os/pkgmgr.js +0 -6
  171. package/dist/os/pkgmgr.js.map +1 -1
  172. package/dist/prompts/embedded.d.ts +1 -0
  173. package/dist/prompts/embedded.js +9 -0
  174. package/dist/prompts/embedded.js.map +1 -0
  175. package/dist/prompts/index.d.ts +6 -18
  176. package/dist/prompts/index.js +104 -293
  177. package/dist/prompts/index.js.map +1 -1
  178. package/dist/prompts/system.agent.md +228 -0
  179. package/dist/prompts/system.ask.md +56 -0
  180. package/dist/repl/prompt-line.js +0 -36
  181. package/dist/repl/prompt-line.js.map +1 -1
  182. package/dist/repl/slash-commands.d.ts +0 -9
  183. package/dist/repl/slash-commands.js +2 -23
  184. package/dist/repl/slash-commands.js.map +1 -1
  185. package/dist/repl.d.ts +5 -0
  186. package/dist/repl.js +169 -55
  187. package/dist/repl.js.map +1 -1
  188. package/dist/safety/classifier.js +17 -9
  189. package/dist/safety/classifier.js.map +1 -1
  190. package/dist/safety/engagement-policy.d.ts +46 -0
  191. package/dist/safety/engagement-policy.js +198 -0
  192. package/dist/safety/engagement-policy.js.map +1 -0
  193. package/dist/store/engagement.d.ts +115 -0
  194. package/dist/store/engagement.js +278 -0
  195. package/dist/store/engagement.js.map +1 -0
  196. package/dist/store/logs.d.ts +5 -0
  197. package/dist/store/logs.js +52 -2
  198. package/dist/store/logs.js.map +1 -1
  199. package/dist/store/plan.d.ts +57 -5
  200. package/dist/store/plan.js +182 -8
  201. package/dist/store/plan.js.map +1 -1
  202. package/dist/store/scope.d.ts +4 -1
  203. package/dist/store/scope.js +2 -2
  204. package/dist/store/scope.js.map +1 -1
  205. package/dist/tools/definitions.js +70 -1
  206. package/dist/tools/definitions.js.map +1 -1
  207. package/dist/tools/elevated-shell.d.ts +60 -0
  208. package/dist/tools/elevated-shell.js +233 -0
  209. package/dist/tools/elevated-shell.js.map +1 -0
  210. package/dist/tools/file-diff.d.ts +125 -0
  211. package/dist/tools/file-diff.js +495 -0
  212. package/dist/tools/file-diff.js.map +1 -0
  213. package/dist/tools/fs.d.ts +11 -13
  214. package/dist/tools/fs.js +269 -78
  215. package/dist/tools/fs.js.map +1 -1
  216. package/dist/tools/http.d.ts +15 -0
  217. package/dist/tools/http.js +202 -61
  218. package/dist/tools/http.js.map +1 -1
  219. package/dist/tools/jobs.d.ts +71 -10
  220. package/dist/tools/jobs.js +403 -124
  221. package/dist/tools/jobs.js.map +1 -1
  222. package/dist/tools/nmap-runner.d.ts +11 -0
  223. package/dist/tools/nmap-runner.js +115 -74
  224. package/dist/tools/nmap-runner.js.map +1 -1
  225. package/dist/tools/pentest-workflows.d.ts +8 -0
  226. package/dist/tools/pentest-workflows.js +71 -0
  227. package/dist/tools/pentest-workflows.js.map +1 -0
  228. package/dist/tools/registry.d.ts +9 -0
  229. package/dist/tools/registry.js +244 -57
  230. package/dist/tools/registry.js.map +1 -1
  231. package/dist/tools/shell.d.ts +3 -0
  232. package/dist/tools/shell.js +35 -13
  233. package/dist/tools/shell.js.map +1 -1
  234. package/dist/tools/tool-types.d.ts +11 -0
  235. package/dist/tools/web/fetch-core.d.ts +7 -0
  236. package/dist/tools/web/fetch-core.js +40 -1
  237. package/dist/tools/web/fetch-core.js.map +1 -1
  238. package/dist/tools/web/fetch.js +8 -1
  239. package/dist/tools/web/fetch.js.map +1 -1
  240. package/dist/tui-v2/app/App.js +34 -41
  241. package/dist/tui-v2/app/App.js.map +1 -1
  242. package/dist/tui-v2/app/command-handlers.js +20 -1
  243. package/dist/tui-v2/app/command-handlers.js.map +1 -1
  244. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  245. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  246. package/dist/tui-v2/app/commands/session-commands.js +5 -0
  247. package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
  248. package/dist/tui-v2/app/plan-lifecycle.d.ts +25 -3
  249. package/dist/tui-v2/app/plan-lifecycle.js +87 -24
  250. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  251. package/dist/tui-v2/bootstrap/patch-opentui-text.d.ts +6 -0
  252. package/dist/tui-v2/bootstrap/patch-opentui-text.js +53 -0
  253. package/dist/tui-v2/bootstrap/patch-opentui-text.js.map +1 -0
  254. package/dist/tui-v2/bootstrap/start-tui-v2.js +7 -0
  255. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  256. package/dist/tui-v2/components/jobs/jobs-panel.js +23 -14
  257. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  258. package/dist/tui-v2/components/modal/confirm-modal.d.ts +1 -1
  259. package/dist/tui-v2/components/modal/confirm-modal.js +18 -7
  260. package/dist/tui-v2/components/modal/confirm-modal.js.map +1 -1
  261. package/dist/tui-v2/components/overlay/overlay-host.js +1 -1
  262. package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
  263. package/dist/tui-v2/components/pager/pager.d.ts +6 -0
  264. package/dist/tui-v2/components/pager/pager.js +313 -67
  265. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  266. package/dist/tui-v2/components/status/status-line.d.ts +9 -0
  267. package/dist/tui-v2/components/status/status-line.js +17 -3
  268. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  269. package/dist/tui-v2/components/transcript/assistant-message.js +1 -1
  270. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  271. package/dist/tui-v2/components/transcript/compacted-row.js +1 -1
  272. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  273. package/dist/tui-v2/components/transcript/tool-card.d.ts +9 -1
  274. package/dist/tui-v2/components/transcript/tool-card.js +114 -9
  275. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  276. package/dist/tui-v2/components/transcript/transcript-row.js +2 -2
  277. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  278. package/dist/tui-v2/composer/composer-editor.js +16 -0
  279. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  280. package/dist/tui-v2/controllers/overlay-controller.d.ts +16 -2
  281. package/dist/tui-v2/controllers/overlay-controller.js +92 -12
  282. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  283. package/dist/tui-v2/rendering/artifact-pager-source.d.ts +19 -0
  284. package/dist/tui-v2/rendering/artifact-pager-source.js +103 -0
  285. package/dist/tui-v2/rendering/artifact-pager-source.js.map +1 -0
  286. package/dist/tui-v2/rendering/file-diff-view.d.ts +63 -0
  287. package/dist/tui-v2/rendering/file-diff-view.js +263 -0
  288. package/dist/tui-v2/rendering/file-diff-view.js.map +1 -0
  289. package/dist/tui-v2/rendering/open-tool-output.d.ts +9 -1
  290. package/dist/tui-v2/rendering/open-tool-output.js +65 -12
  291. package/dist/tui-v2/rendering/open-tool-output.js.map +1 -1
  292. package/dist/tui-v2/rendering/pager-chrome.d.ts +12 -0
  293. package/dist/tui-v2/rendering/pager-chrome.js +56 -0
  294. package/dist/tui-v2/rendering/pager-chrome.js.map +1 -0
  295. package/dist/tui-v2/rendering/plan-view.js +1 -1
  296. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  297. package/dist/tui-v2/rendering/syntax-highlight.d.ts +37 -0
  298. package/dist/tui-v2/rendering/syntax-highlight.js +1303 -0
  299. package/dist/tui-v2/rendering/syntax-highlight.js.map +1 -0
  300. package/dist/tui-v2/rendering/theme.d.ts +28 -0
  301. package/dist/tui-v2/rendering/theme.js +28 -0
  302. package/dist/tui-v2/rendering/theme.js.map +1 -1
  303. package/dist/tui-v2/rendering/tool-presenter.d.ts +5 -0
  304. package/dist/tui-v2/rendering/tool-presenter.js +35 -3
  305. package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
  306. package/dist/tui-v2/state/transcript-hydrate.js +5 -0
  307. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  308. package/dist/tui-v2/state/transcript-reducer.js +12 -2
  309. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  310. package/dist/tui-v2/state/transcript-store.d.ts +7 -0
  311. package/dist/tui-v2/state/transcript-store.js +46 -0
  312. package/dist/tui-v2/state/transcript-store.js.map +1 -1
  313. package/dist/tui-v2/state/transcript-types.d.ts +12 -0
  314. package/dist/tui-v2/state/transcript-types.js +9 -0
  315. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  316. package/dist/types.d.ts +17 -1
  317. package/dist/ui/mentions.d.ts +6 -0
  318. package/dist/ui/mentions.js +50 -12
  319. package/dist/ui/mentions.js.map +1 -1
  320. package/dist/ui/plan-pane.js +1 -1
  321. package/dist/ui/plan-pane.js.map +1 -1
  322. package/package.json +18 -12
@@ -1,125 +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";
5
4
  import { resolveToolDialect } from "../llm/capabilities.js";
6
5
  import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
7
6
  import { sanitizeAssistantText } from "../ui/ansi-box.js";
8
7
  import { randomUUID } from "node:crypto";
9
8
  import { jobManager } from "../tools/jobs.js";
10
- import { renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
9
+ import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
11
10
  import { getConfig } from "../store/config.js";
12
11
  import { groqInputTokenBudget } from "../llm/groq.js";
13
12
  import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
14
13
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
15
14
  import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
16
- import { appendAssistantWithTools, appendToolResult, fillMissingToolResults, } from "./tool-history.js";
17
- import { looksInteractiveStdin } from "../tools/shell.js";
15
+ import { appendAssistantWithTools, appendToolResult, assertValidToolProtocol, fillMissingToolResults, } from "./tool-history.js";
18
16
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
19
- 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";
20
18
  import { auditLog } from "../store/logs.js";
21
19
  import { loadProjectContext } from "../store/project.js";
22
- import { loadScope, isScopeActive, targetInScope } from "../store/scope.js";
20
+ import { loadScope, isScopeActive } from "../store/scope.js";
23
21
  import { ensureProviderConfigured } from "../commands/providers.js";
24
22
  import { createThinkingStreamParser, rememberThinkingFromText, renderThinkingSummary, stripThinking, } from "../ui/thinking.js";
25
23
  import { renderMarkdown, indentAndWrapText } from "../ui/markdown.js";
26
24
  import { startThinkingSpinner } from "../ui/spinner.js";
27
25
  import { safeCwd } from "../os/cwd.js";
28
- 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";
29
30
  import { LoopGuard } from "./loop-guard.js";
30
- import { loadPlan, savePlan, markTask, } from "../store/plan.js";
31
- import { pathInsideSandbox, fsWrite } from "../tools/fs.js";
32
- 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, pentestWorkflowDirective, pentestNoLocalServerDirective, shouldDimToolChatter, looksLikePromptLeak, } from "./tool-call-parser.js";
33
- 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";
34
35
  import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
35
36
  import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
36
- import { applyDestinationCwd, canMarkTaskDone, codingBuildRequiresPlan, incompleteFeatureBeforeServerMessage, isBuildPrePlanAllowedTool, isEvidenceWorkTool, isFeatureImplementationCall, isPlanPreflightTool, isReadOnlyVersionProbeCommand, isScaffoldCreateCommand, openTaskLedger, pickPendingTaskForToolCall, recordTaskWorkSuccess, resolveUserDestinationHint, toolStallBudgetMs, userAskedForFeatureApp, workOutOfScopeForTask, } from "./task-evidence.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, TOOL_ABORT_GRACE_MS, toolHardBudgetMs, 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";
37
44
  import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
38
- import { buildWorkspaceOrientation, guessProjectFolderName, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, } from "./workspace-orient.js";
45
+ import { buildWorkspaceOrientation, discoverImmediateProjectRoots, guessProjectFolderName, isBareParentDirectory, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, resolveScaffoldTargetPath, } from "./workspace-orient.js";
39
46
  import { inquirerConfirmPort, restoreInteractiveStdin, ensurePentestAuthorization, confirmToolExecution, } from "./confirm-port.js";
40
47
  import { buildRichStopSummary } from "./stop-summary.js";
41
- // Re-exported so existing imports of these names from "./runner.js" keep
42
- // working unchanged the parsing/classification engine now lives in
43
- // tool-call-parser.ts, and the session/plan/confirm/formatting helpers now
44
- // 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";
45
54
  export * from "./tool-call-parser.js";
46
55
  export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
47
56
  export function styleToolChatter(call, text) {
48
57
  return shouldDimToolChatter(call) ? chalk.dim(text) : text;
49
58
  }
50
- /**
51
- * Tool names that may write into the project tree. Writes restricted to the
52
- * per-project scratch directory (under tmpdir()/clai/<name>) are exempted
53
- * from the active-plan and plan-approved gates so the model can use scratch
54
- * space to build / inspect / stage work without first creating a plan.
55
- */
56
- const SCRATCH_WRITABLE_TOOLS = new Set([
57
- "fs.write",
58
- "fs.writeMany",
59
- "fs.edit",
60
- "fs.replaceLines",
61
- "fs.append",
62
- "fs.delete",
63
- ]);
64
- /**
65
- * Expand `~` the same way `src/tools/fs.ts` does so callers can compare an
66
- * already-expanded scratch path against paths supplied by the model.
67
- */
68
- function expandHomeLocal(path) {
69
- if (path === "~")
70
- return homedir();
71
- if (path.startsWith("~/") || path.startsWith("~\\")) {
72
- return resolve(homedir(), path.slice(2));
73
- }
74
- return path;
75
- }
76
- /**
77
- * Extract the target path(s) a write-shaped tool call would touch. Returns
78
- * an empty array when the call has no resolvable path (so the caller can
79
- * treat it as NOT scratch-only and fall through to the normal gates).
80
- */
81
- function scratchWriteTargetPaths(call) {
82
- if (call.name === "fs.writeMany") {
83
- const files = call.args.files;
84
- if (!Array.isArray(files))
85
- return [];
86
- const paths = [];
87
- for (const entry of files) {
88
- if (entry && typeof entry === "object" && "path" in entry) {
89
- const p = entry.path;
90
- if (typeof p === "string" && p.length > 0)
91
- paths.push(p);
92
- }
93
- }
94
- return paths;
95
- }
96
- const pathArg = call.args.path;
97
- if (typeof pathArg !== "string" || pathArg.length === 0)
98
- return [];
99
- return [pathArg];
100
- }
101
- /**
102
- * True iff every target path this call would write is inside the resolved
103
- * scratch directory. A path is considered inside when its `path.relative`
104
- * against the scratch root is empty (the scratch root itself) or does not
105
- * start with `..` (no parent traversal). Calls without a recognizable
106
- * target path return false so they fall through to the normal gates.
107
- */
108
- function isScratchOnlyWrite(call, scratchDir) {
109
- if (!SCRATCH_WRITABLE_TOOLS.has(call.name))
110
- return false;
111
- const paths = scratchWriteTargetPaths(call);
112
- if (paths.length === 0)
113
- return false;
114
- const resolvedScratch = resolve(scratchDir);
115
- return paths.every((raw) => {
116
- const expanded = expandHomeLocal(raw);
117
- const resolved = resolve(expanded);
118
- const rel = relative(resolvedScratch, resolved);
119
- return rel === "" || (!rel.startsWith("..") && rel !== "..");
120
- });
121
- }
122
- 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";
123
64
  const writesDirectly = !options.onEvent;
124
65
  const emit = (event) => options.onEvent?.(event);
125
66
  // Whether the CURRENT model iteration has already committed its visible
@@ -221,6 +162,9 @@ export async function runAgentLoop(prompt, options = {}) {
221
162
  if (artifactPath) {
222
163
  event.artifactPath = artifactPath;
223
164
  }
165
+ if (result.fileChanges && result.fileChanges.length > 0) {
166
+ event.fileChanges = result.fileChanges;
167
+ }
224
168
  emit(event);
225
169
  };
226
170
  /** Strip a known prefix from a string, returning the remainder unchanged. */
@@ -243,20 +187,36 @@ export async function runAgentLoop(prompt, options = {}) {
243
187
  // conversation back to the caller. Assigned once `messages` is built below;
244
188
  // all later mutations are in-place so this reference stays current.
245
189
  let liveMessages = [];
246
- 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);
247
200
  if (options.onMessages) {
248
201
  try {
249
- options.onMessages(buildTurnHistory(liveMessages, answer));
202
+ options.onMessages(buildTurnHistory(liveMessages, rendered));
250
203
  }
251
204
  catch {
252
205
  // Persisting history must never break the turn.
253
206
  }
254
207
  }
255
- emit({ type: "turn-end", finalAnswer: answer, steps });
256
- return answer;
208
+ options.onOutcome?.(outcome);
209
+ emit({ type: "turn-end", outcome, finalAnswer: rendered, steps });
210
+ return outcome;
257
211
  };
258
212
  try {
259
- emit({ type: "turn-start", prompt });
213
+ emit({
214
+ type: "turn-start",
215
+ prompt,
216
+ ...(options.displayPrompt !== undefined
217
+ ? { displayPrompt: options.displayPrompt }
218
+ : {}),
219
+ });
260
220
  const config = getConfig();
261
221
  const maxSteps = options.maxSteps ?? 70;
262
222
  const confirmPort = options.confirm ?? inquirerConfirmPort;
@@ -274,6 +234,7 @@ export async function runAgentLoop(prompt, options = {}) {
274
234
  // agent burns its turn searching the date instead of writing files.
275
235
  const buildLikeTurn = looksLikeBuildTask(prompt, options.history);
276
236
  const pentestLikeTurn = looksLikePentestTask(prompt, options.history);
237
+ const narrowNmapOperation = isNarrowExplicitNmapOperation(prompt);
277
238
  // A plain informational follow-up ("what do you know so far", "summarize
278
239
  // the findings") in a resumed/continuing build or pentest session must
279
240
  // NOT inherit that session's "must act" behavior — it should be answered
@@ -331,16 +292,33 @@ export async function runAgentLoop(prompt, options = {}) {
331
292
  session.planApproved.value = true;
332
293
  }
333
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
+ : [];
334
309
  // Sticky project root so relative fs paths never hit the agent package.
335
- // Only pin paths that already exist (or were previously validated) — never
336
- // invent Desktop/todo-app before the folder is real, and never pin bare Desktop.
337
- {
338
- const fromPrompt = extractProjectRootFromText(prompt);
339
- const fromPlan = extractProjectRootFromPlan(activePlan);
340
- const root = fromPlan ?? fromPrompt;
341
- if (root)
342
- setActiveProjectRootIfValid(root);
343
- // Do NOT setActiveProjectRoot(destinationHint) — bare Desktop is a parent only.
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;
318
+ }
319
+ }
320
+ if (!pinnedProject && discoveredProjects.length === 1) {
321
+ setActiveProjectRootIfValid(discoveredProjects[0]);
344
322
  }
345
323
  const buildSystemContent = (native) => {
346
324
  const sections = [
@@ -401,25 +379,38 @@ export async function runAgentLoop(prompt, options = {}) {
401
379
  if (activePlan) {
402
380
  systemSections.push(planContextMessage(activePlan, session.planApproved.value));
403
381
  }
404
- // For build/scaffold turns with no active plan yet, inject an explicit
405
- // workflow so the agent does NOT rush to write files in one shot. It must
406
- // explore the directory, read the relevant existing files to understand
407
- // what's already there, create a comprehensive multi-task plan, then
408
- // implement task by task until the goal is met. This mirrors how a careful
409
- // 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.
410
389
  if (buildLikeTurn &&
411
- !activePlan &&
390
+ !informationalQuery &&
391
+ !idleOrSocialPrompt &&
392
+ !isPlanMode) {
393
+ systemSections.push(buildWorkflowDirective());
394
+ }
395
+ if (isPlanMode &&
396
+ buildLikeTurn &&
412
397
  !informationalQuery &&
413
398
  !idleOrSocialPrompt) {
414
399
  systemSections.push(buildWorkflowDirective());
415
400
  }
416
- // 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
417
410
  // build: recon first, then a plan built from real findings, then
418
- // incremental task additions as new attack surface appears. The
419
- // directive is only injected before a plan exists; once a plan is in
420
- // place (or being refined), the ACTIVE PLAN block already carries the
421
- // current task state and recon-vs-active-tool guidance.
411
+ // incremental task additions as new attack surface appears.
422
412
  if (pentestLikeTurn &&
413
+ !narrowNmapOperation &&
423
414
  !activePlan &&
424
415
  !informationalQuery &&
425
416
  !idleOrSocialPrompt) {
@@ -435,19 +426,90 @@ export async function runAgentLoop(prompt, options = {}) {
435
426
  if (pentestSession && !idleOrSocialPrompt) {
436
427
  systemSections.push(pentestNoLocalServerDirective());
437
428
  }
438
- const renderedSystemPrompt = systemSections.join("\n\n");
439
- // Reserve most of a constrained model's input budget for the user message,
440
- // recent conversation, tool results, and provider framing. Dynamic project
441
- // context or a saved plan must not silently grow the compact base prompt
442
- // back above the model's TPM ceiling.
443
- const maxSystemTokens = inputTokenBudget
444
- ? Math.min(2_000, Math.floor(inputTokenBudget * 0.4))
445
- : undefined;
446
- const systemTruncationNote = "\n\n[Additional system context omitted to fit the provider input budget.]";
447
- const fullSystemPrompt = maxSystemTokens !== undefined &&
448
- estimateTokens(renderedSystemPrompt) > maxSystemTokens
449
- ? renderedSystemPrompt.slice(0, Math.max(0, Math.floor(maxSystemTokens * 3.3) - systemTruncationNote.length)) + systemTruncationNote
450
- : 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);
451
513
  const userMessage = { role: "user", content: prompt };
452
514
  if (options.images && options.images.length > 0) {
453
515
  userMessage.images = options.images;
@@ -458,6 +520,8 @@ export async function runAgentLoop(prompt, options = {}) {
458
520
  userMessage,
459
521
  ];
460
522
  liveMessages = messages;
523
+ /** Assigned after session flags exist — see below. */
524
+ let refreshSessionState = () => undefined;
461
525
  const recoveryUserMessage = (content) => {
462
526
  const message = { role: "user", content };
463
527
  if (options.images && options.images.length > 0) {
@@ -468,19 +532,6 @@ export async function runAgentLoop(prompt, options = {}) {
468
532
  }
469
533
  return message;
470
534
  };
471
- // Every provider must receive a syntactically valid assistant turn between
472
- // the original user prompt and a recovery nudge. In particular, Gemini
473
- // serializes an empty assistant message as an empty `model` text part,
474
- // which can cause every retry to return empty as well. Keep hidden thinking
475
- // out of history, but record a compact non-empty sentinel when there was no
476
- // visible output.
477
- //
478
- // The Ink reducer intentionally keeps streamed text transient until it
479
- // receives `assistant-message`, because a stream may turn out to be raw
480
- // tool JSON. Recovery paths used to add prose to model history and retry
481
- // without that event, so the user could watch a valid-looking response
482
- // vanish when the retry emitted its first tool call. Surface only prose
483
- // that is safe to render; malformed/bare tool payloads remain hidden.
484
535
  const recoveryProse = (content) => {
485
536
  const text = textBeforeToolCall(stripSentinelTokens(content)).trim();
486
537
  if (!text ||
@@ -491,10 +542,6 @@ export async function runAgentLoop(prompt, options = {}) {
491
542
  return text;
492
543
  };
493
544
  const pushAssistantHistory = (content) => {
494
- // Preserve genuine streamed prose before a recovery retry so the visible
495
- // text isn't wiped by the next tool-call/turn event. Skip when this
496
- // iteration already surfaced its prose (the normal tool path commits
497
- // `beforeTool` itself) so the same text is never rendered twice.
498
545
  const cleaned = sanitizeAssistantText(content);
499
546
  if (!visibleCommitted) {
500
547
  const prose = recoveryProse(cleaned);
@@ -508,109 +555,229 @@ export async function runAgentLoop(prompt, options = {}) {
508
555
  : "[No visible assistant response was produced.]",
509
556
  });
510
557
  };
511
- // Track recent tool calls to detect models stuck in a loop calling the
512
- // same tool with the same arguments over and over (e.g. pentest.recon
513
- // called 3× on the same target without summarizing).
514
558
  const loopGuard = new LoopGuard();
559
+ const engagementPolicy = new EngagementPolicyEngine();
515
560
  // Track consecutive thinking-only responses so we can nudge the model
516
561
  // to actually act instead of silently returning an empty answer.
517
562
  let emptyVisibleRetries = 0;
518
- // A model that spent an entire completion in hidden reasoning gets one
519
- // visible-output retry with provider thinking disabled. This is per-turn
520
- // only: a subsequent successful response restores the configured setting.
521
563
  let retryWithoutThinking = false;
522
564
  // Track tool calls truncated by the token limit so we can ask the model
523
565
  // to retry in smaller pieces instead of leaking broken JSON as an answer.
524
566
  let truncatedToolRetries = 0;
525
- // Track bare-args JSON tool calls (missing the {name,args} wrapper / fence)
526
- // so we can nudge the model to re-emit a proper fenced call a few times
527
- // before giving up, instead of leaking the JSON as a final answer.
528
567
  let bareToolJsonRetries = 0;
529
568
  // Track a ```tool fence that is present but whose JSON could not be parsed
530
569
  // (e.g. malformed extra/missing braces that are NOT simple truncation). We
531
570
  // retry instead of leaking the raw block as the final answer.
532
571
  let malformedFenceRetries = 0;
533
- // For volatile live-info prompts, make one corrective pass if a model
534
- // ignores the freshness guard and tries to answer from stale memory.
535
572
  let sawFreshWebSearch = false;
536
- let freshnessRetryUsed = false;
537
- // Guard against a model that declares an approved plan "complete" while
538
- // tasks are still pending and it never ran the work. We nudge it back to
539
- // executing the next task a bounded number of times before giving up.
540
- let prematureCompletionRetries = 0;
541
- let runtimeVerificationRetries = 0;
542
- let featureImplRetries = 0;
543
- let forcePlanRetries = 0;
544
- let errorFixNarrationRetries = 0;
545
- let failedProbeFixRetries = 0;
573
+ const recovery = createRecoveryBudgets();
546
574
  let sawServerStart = false;
547
575
  let sawPlanCreateOk = false;
548
576
  let sawServerTail = false;
549
577
  let sawLocalHttpProbe = false;
550
- /** Last localhost probe returned 4xx/5xx / connection refused — must fix. */
551
578
  let sawFailedLocalHttpProbe = false;
552
- /** Local app was scaffolded/installed/written this turn (plan optional). */
553
579
  let sawLocalAppMaterialWork = false;
554
- /** Official scaffolder succeeded this turn. */
555
580
  let sawScaffoldOk = false;
556
- /** Real product source written (not just scaffold defaults). */
557
581
  let sawFeatureImplWrite = false;
582
+ let sawActivePentestTest = false;
558
583
  const featureAppAsk = userAskedForFeatureApp(prompt);
559
- /** Successful work tools under the current in_progress plan task. */
560
584
  let taskWorkLedger = null;
561
- // Guard against a model that NARRATES intent ("let me explore the
562
- // directory…") but emits no tool call, so nothing runs and the turn ends
563
- // prematurely. On build/scaffold/plan turns where nothing has executed yet,
564
- // we nudge it to emit a real tool call instead of accepting the narration
565
- // as a final answer. Bounded so a model that truly can't emit the format
566
- // still terminates.
567
- let actionIntentRetries = 0;
568
- // Multi-tool execution queue
569
- // Models naturally emit several tool calls in one message — e.g. the
570
- // plan-execution rhythm "task.update in_progress → do the work →
571
- // task.update done", or a batch of fs.write calls. Rather than running
572
- // only the first and discarding the rest (which made models believe work
573
- // ran when it didn't, and broke plan execution), we parse ALL calls in a
574
- // message, run the first this iteration, and queue the rest here to run on
575
- // subsequent iterations WITHOUT another model round-trip. The queue is
576
- // cleared whenever a call fails, is blocked, or needs the model to react,
577
- // so the model always sees errors and stays in control.
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);
578
730
  let pendingCalls = [];
579
- // Step budget
580
- // The budget governs how many *productive* steps (a tool execution or a
581
- // final answer) the agent may take. Recovery iterations — nudging a model
582
- // that only produced thinking, asking it to re-emit a malformed tool call,
583
- // a freshness retry, or a loop-guard summary — do NOT consume this budget;
584
- // they get a separate hard ceiling so a wedged model can't spin forever.
585
- //
586
- // Complexity is a coarse signal from prompt length, but short follow-up
587
- // prompts ("do it", "build fully on your own", "app is not complete") in
588
- // the middle of a multi-file build must NOT be capped like a one-shot
589
- // lookup — that was the reason a React scaffold stopped half-built after
590
- // 10 steps. We bump the budget when the prompt (or recent history) looks
591
- // like a build/scaffold or a continuation of one.
731
+ let narrowNmapDispatchCount = 0;
732
+ const deferredPostToolMessages = [];
592
733
  const analysis = analyzeTask(prompt);
593
734
  const hasHistory = (options.history?.length ?? 0) > 0;
594
735
  const buildLike = buildLikeTurn;
595
736
  const pentestLike = looksLikePentestTask(prompt, options.history);
596
- let stepBudget = analysis.complexity === "simple"
597
- ? 20
598
- : analysis.complexity === "standard"
599
- ? 40
600
- : maxSteps;
601
- if (buildLike || pentestLike) {
602
- // Scaffolding / multi-file work / pentest tasks need room.
603
- // Continuation prompts ("do it") inherit this too.
604
- stepBudget = Math.max(stepBudget, maxSteps);
605
- }
606
- else if (hasHistory) {
607
- // A follow-up to an ongoing task should never be capped tighter than a
608
- // standard one-shot, even if it's only a couple of words.
609
- stepBudget = Math.max(stepBudget, 40);
610
- }
611
- // Hard ceiling on total loop iterations (productive + recovery) so a model
612
- // stuck emitting only thinking or malformed calls can't loop indefinitely.
613
- 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));
614
781
  let productiveSteps = 0;
615
782
  let step = -1;
616
783
  let nextToolEventId = 0;
@@ -629,10 +796,12 @@ export async function runAgentLoop(prompt, options = {}) {
629
796
  },
630
797
  };
631
798
  async function executeSingleTool(rawCall, toolEventId, parentSignal) {
632
- // Resolved once per call so the scratch-only exemption can compare the
633
- // model-supplied paths against the canonical per-project scratch root.
634
799
  const scratchDir = scratchDirFor(safeCwd());
635
800
  let call = normalizeToolCall(rawCall);
801
+ let dispatchedTaskId;
802
+ let engagementLease;
803
+ let engagementGraph;
804
+ let engagementRecord;
636
805
  if (call.args?.__nativeParseError) {
637
806
  const raw = String(call.args._raw ?? "").slice(0, 200);
638
807
  const reason = "Tool call arguments were not valid JSON (truncated or malformed). " +
@@ -649,14 +818,41 @@ export async function runAgentLoop(prompt, options = {}) {
649
818
  const result = { ok: true, output: recoveryText };
650
819
  return { ok: true, call, result, contextOutput: recoveryText };
651
820
  }
652
- 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
+ });
653
853
  if (loopCheck.block) {
654
- const isWrite = call.name === "fs.write" ||
655
- call.name === "fs.writeMany" ||
656
- call.name === "fs.edit" ||
657
- call.name === "fs.replaceLines" ||
658
- call.name === "fs.append";
659
- 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.`;
660
856
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
661
857
  const result = { ok: false, output: reason, exitCode: 1 };
662
858
  return {
@@ -664,7 +860,6 @@ export async function runAgentLoop(prompt, options = {}) {
664
860
  call,
665
861
  result,
666
862
  contextOutput: reason,
667
- blockOrCancel: true,
668
863
  };
669
864
  }
670
865
  if (loopCheck.reason) {
@@ -684,7 +879,32 @@ export async function runAgentLoop(prompt, options = {}) {
684
879
  const live = await loadPlan(session.sessionId).catch(() => undefined);
685
880
  const resolved = (live ? resolvePlanTaskId(live, taskIdRaw) : undefined) ??
686
881
  taskIdRaw;
687
- const gate = canMarkTaskDone(taskWorkLedger, resolved);
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
+ };
688
908
  if (!gate.ok) {
689
909
  writeNotice("warn", gate.reason, chalk.yellow(` ⚠ ${gate.reason}\n`));
690
910
  if (!alreadyPrintedIds.has(toolEventId)) {
@@ -712,6 +932,7 @@ export async function runAgentLoop(prompt, options = {}) {
712
932
  const planResult = await handlePlanTool(call, session, {
713
933
  loopGuard,
714
934
  step,
935
+ autoApprove: !isPlanMode,
715
936
  });
716
937
  if (planResult.handled) {
717
938
  loopGuard.recordAttempt(step, call.name, call.args, planResult.ok, 0);
@@ -726,9 +947,29 @@ export async function runAgentLoop(prompt, options = {}) {
726
947
  ? resolvePlanTaskId(planResult.plan, taskIdRaw)
727
948
  : undefined) ?? taskIdRaw;
728
949
  if (stateRaw === "in_progress" && resolved) {
729
- taskWorkLedger = openTaskLedger(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
+ }
730
963
  }
731
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
+ }
732
973
  taskWorkLedger = null;
733
974
  }
734
975
  else if ((stateRaw === "failed" || stateRaw === "skipped") &&
@@ -767,27 +1008,22 @@ export async function runAgentLoop(prompt, options = {}) {
767
1008
  decision,
768
1009
  scope: isScopeActive(scope) ? (scope.name ?? "(unnamed)") : "(none)",
769
1010
  });
770
- // Coding builds: no freestyle scaffold/write until plan.create exists.
771
- // Explore (fs.list/read, tool.check) + plan.create only; then wait for /implement.
772
1011
  const livePlanForPreGate = await loadPlan(session.sessionId).catch(() => undefined);
773
- const codingNeedsPlan = buildLikeTurn &&
774
- codingBuildRequiresPlan(prompt, {
775
- informational: informationalQuery,
776
- idle: idleOrSocialPrompt,
777
- pentest: pentestLikeTurn,
778
- });
779
- if (codingNeedsPlan &&
780
- !livePlanForPreGate &&
781
- !sawPlanCreateOk &&
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 &&
782
1017
  !isScratchOnlyWrite(call, scratchDir)) {
783
1018
  const cmd = typeof call.args.command === "string" ? call.args.command : "";
784
- const allowedPrePlan = isBuildPrePlanAllowedTool(call.name) ||
785
- isPreApprovalAllowedTool(call.name) ||
786
- (call.name === "shell.exec" && isReadOnlyVersionProbeCommand(cmd));
787
- if (!allowedPrePlan) {
788
- const reason = `plan required first — ${call.name} is blocked on coding builds until plan.create. ` +
789
- `Explore with fs.list / fs.read / tool.check if needed, then call plan.create ` +
790
- `(kind=coding, 4–8 tasks including feature work + final run/verify). Stop and wait for /implement.`;
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.`;
791
1027
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
792
1028
  if (!alreadyPrintedIds.has(toolEventId)) {
793
1029
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
@@ -813,7 +1049,7 @@ export async function runAgentLoop(prompt, options = {}) {
813
1049
  const planNow = livePlanForPreGate ??
814
1050
  (await loadPlan(session.sessionId).catch(() => undefined));
815
1051
  if (planNow && !session.planApproved.value) {
816
- const reason = `plan awaiting approval — ${call.name} is blocked until you /implement (or /discard)`;
1052
+ const reason = `plan awaiting approval — ${call.name} is blocked until the plan is accepted (/implement or Accept)`;
817
1053
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
818
1054
  const result = { ok: false, output: reason, exitCode: 1 };
819
1055
  return {
@@ -825,18 +1061,6 @@ export async function runAgentLoop(prompt, options = {}) {
825
1061
  };
826
1062
  }
827
1063
  }
828
- // Task-scoped execution gate
829
- // Once a plan is approved, every non-plan tool call must run while
830
- // exactly one task is "in_progress". This stops a model from batching
831
- // tool calls for many/all tasks in one turn and only touching task
832
- // state at the very end (or never) — the failure mode where a model
833
- // claimed most tasks "done" in prose without ever recording it in the
834
- // plan. Multiple tool calls per task are still fine; they just must be
835
- // bracketed by task.update in_progress → (work) → task.update done.
836
- //
837
- // GPT-OSS etc. often mark tN done then immediately fs.list/read for tN+1
838
- // without opening the next task. Auto-start the first pending task so
839
- // work continues without a wasted blocked turn (still recorded in plan).
840
1064
  if (session.planApproved.value) {
841
1065
  const livePlanForGate = await loadPlan(session.sessionId).catch(() => undefined);
842
1066
  if (livePlanForGate) {
@@ -845,13 +1069,17 @@ export async function runAgentLoop(prompt, options = {}) {
845
1069
  if (unfinished && !inProgress) {
846
1070
  // tool.check / fs.list preflight: allow without auto-opening a task
847
1071
  // (auto-start on preflight made models skip task.update and confused scope).
848
- if (isPlanPreflightTool(call.name)) {
849
- // fall through — no task ledger yet
1072
+ const skipTaskGate = isPlanPreflightTool(call.name) ||
1073
+ (livePlanForGate.kind === "pentest" &&
1074
+ isReadOnlyReconTool(call.name));
1075
+ if (skipTaskGate) {
1076
+ // fall through
850
1077
  }
851
1078
  else {
852
- const pending = livePlanForGate.tasks.filter((t) => t.state === "pending");
853
- // Match tool task (npm install must not auto-start "localStorage")
854
- const nextPending = pickPendingTaskForToolCall(pending, call, livePlanForGate.tasks.map((t) => t.title));
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];
855
1083
  if (nextPending) {
856
1084
  markTask(livePlanForGate, nextPending.id, "in_progress");
857
1085
  if (livePlanForGate.status === "draft" ||
@@ -859,78 +1087,19 @@ export async function runAgentLoop(prompt, options = {}) {
859
1087
  livePlanForGate.status = "in_progress";
860
1088
  }
861
1089
  await savePlan(livePlanForGate).catch(() => undefined);
862
- taskWorkLedger = openTaskLedger(nextPending.id);
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
+ }
863
1096
  writePlanUpdate(livePlanForGate, renderPlanForTerminal(livePlanForGate) + "\n");
864
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`));
865
1098
  }
866
- else {
867
- const reason = `${call.name} blocked — no matching pending task is in_progress for this tool. ` +
868
- `Call task.update in_progress on the correct task (e.g. install vs implement vs run/verify), then retry.`;
869
- writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
870
- const result = { ok: false, output: reason, exitCode: 1 };
871
- return {
872
- ok: false,
873
- call,
874
- result,
875
- contextOutput: reason,
876
- };
877
- }
878
1099
  }
879
1100
  }
880
1101
  }
881
1102
  }
882
- // Keep work inside the open task (no early server start during install).
883
- if (session.planApproved.value) {
884
- const liveForScope = await loadPlan(session.sessionId).catch(() => undefined);
885
- const openTask = liveForScope?.tasks.find((t) => t.state === "in_progress");
886
- if (openTask) {
887
- const scopeMsg = workOutOfScopeForTask(openTask.title, call, liveForScope?.tasks
888
- ? { planTaskTitles: liveForScope.tasks.map((t) => t.title) }
889
- : undefined);
890
- if (scopeMsg) {
891
- writeNotice("warn", scopeMsg, chalk.yellow(` ⚠ ${scopeMsg}\n`));
892
- if (!alreadyPrintedIds.has(toolEventId)) {
893
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
894
- chalk.gray(` ${formatToolArgs(call)}`);
895
- writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
896
- alreadyPrintedIds.add(toolEventId);
897
- }
898
- const result = { ok: false, output: scopeMsg, exitCode: 1 };
899
- emitToolResult(toolEventId, result, scopeMsg);
900
- writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
901
- return {
902
- ok: false,
903
- call,
904
- result,
905
- contextOutput: scopeMsg,
906
- };
907
- }
908
- }
909
- }
910
- // Freestyle or any path: block server start until product feature exists.
911
- {
912
- const featureBlock = incompleteFeatureBeforeServerMessage(prompt, sawFeatureImplWrite, call);
913
- if (featureBlock) {
914
- writeNotice("warn", featureBlock, chalk.yellow(` ⚠ ${featureBlock}\n`));
915
- if (!alreadyPrintedIds.has(toolEventId)) {
916
- const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
917
- chalk.gray(` ${formatToolArgs(call)}`);
918
- writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
919
- alreadyPrintedIds.add(toolEventId);
920
- }
921
- const result = { ok: false, output: featureBlock, exitCode: 1 };
922
- emitToolResult(toolEventId, result, featureBlock);
923
- writeToolOutput(toolEventId, "failed\n", chalk.red(" ✗") + "\n");
924
- return {
925
- ok: false,
926
- call,
927
- result,
928
- contextOutput: featureBlock,
929
- };
930
- }
931
- }
932
- // Prefer user Desktop (etc.) as cwd when model omitted it.
933
- // Also prefer sticky project root for install/run when set.
934
1103
  call = applyDestinationCwd(call, destinationHint ?? getActiveProjectRoot());
935
1104
  // Soft preflight: refuse scaffold into an existing non-empty project
936
1105
  // (avoids endless "Operation cancelled" retries across all stacks).
@@ -940,21 +1109,29 @@ export async function runAgentLoop(prompt, options = {}) {
940
1109
  const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
941
1110
  const conflict = scaffoldTargetConflictMessage(call.args.command, cwdArg);
942
1111
  if (conflict) {
943
- writeNotice("warn", conflict, chalk.yellow(` ⚠ ${conflict}\n`));
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`));
944
1121
  if (!alreadyPrintedIds.has(toolEventId)) {
945
1122
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
946
1123
  chalk.gray(` ${formatToolArgs(call)}`);
947
1124
  writeToolCall(toolEventId, call, styleToolChatter(call, toolCallLine) + "\n");
948
1125
  alreadyPrintedIds.add(toolEventId);
949
1126
  }
950
- const result = { ok: false, output: conflict, exitCode: 1 };
951
- emitToolResult(toolEventId, result, conflict);
952
- writeToolOutput(toolEventId, "failed\n", chalk.red(" ") + "\n");
1127
+ const result = { ok: true, output: message, exitCode: 0 };
1128
+ emitToolResult(toolEventId, result, message);
1129
+ writeToolOutput(toolEventId, "ok\n", chalk.green(" ") + "\n");
953
1130
  return {
954
- ok: false,
1131
+ ok: true,
955
1132
  call,
956
1133
  result,
957
- contextOutput: conflict,
1134
+ contextOutput: message,
958
1135
  };
959
1136
  }
960
1137
  }
@@ -967,20 +1144,49 @@ export async function runAgentLoop(prompt, options = {}) {
967
1144
  alreadyPrintedIds.add(toolEventId);
968
1145
  }
969
1146
  const scopeTarget = scopeTargetForToolCall(call);
970
- if (scopeTarget &&
971
- (!isScopeActive(scope) || !targetInScope(scopeTarget, scope))) {
972
- 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
+ }
973
1185
  }
974
1186
  if (decision.level === "block") {
975
1187
  writeToolBlocked(toolEventId, call.name, decision.reason, chalk.red(` ✗ blocked: ${decision.reason}`) + "\n");
976
1188
  const message = `Blocked: ${call.name} — ${decision.reason}`;
977
1189
  const result = { ok: false, output: message, exitCode: 1 };
978
- // Safety classifier blocks are recoverable model mistakes: feed the
979
- // failed result back to the model and let it choose a safer next step
980
- // instead of ending the entire agent turn. Hard workflow gates
981
- // (plan not approved, task not in_progress, auth declined, aborts)
982
- // still use blockOrCancel above/below because continuing would violate
983
- // user/session control rather than merely correcting a bad command.
984
1190
  return {
985
1191
  ok: false,
986
1192
  call,
@@ -1014,29 +1220,43 @@ export async function runAgentLoop(prompt, options = {}) {
1014
1220
  if (needsPentestAuth) {
1015
1221
  pentestJustConfirmed = true;
1016
1222
  }
1017
- let forceManualConfirm = call.name === "fs.delete";
1018
- if (call.name.startsWith("fs.") &&
1019
- !isPreApprovalAllowedTool(call.name)) {
1020
- const pathArg = typeof call.args.path === "string" ? call.args.path : undefined;
1021
- if (pathArg) {
1022
- const expandHomeLocal = (p) => p.startsWith("~/") || p.startsWith("~\\")
1023
- ? join(homedir(), p.slice(2))
1024
- : p === "~"
1025
- ? homedir()
1026
- : p;
1027
- const resolved = resolve(expandHomeLocal(pathArg));
1028
- const mode = call.name === "fs.read" ||
1029
- call.name === "fs.list" ||
1030
- call.name === "fs.search"
1031
- ? "read"
1032
- : "write";
1033
- if (!pathInsideSandbox(resolved, mode)) {
1034
- 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;
1035
1254
  }
1036
1255
  }
1037
1256
  }
1038
- if (decision.level === "confirm" && !pentestJustConfirmed) {
1039
- 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);
1040
1260
  restoreInteractiveStdin();
1041
1261
  if (!ok) {
1042
1262
  const lastAnswer = "Cancelled.";
@@ -1057,24 +1277,40 @@ export async function runAgentLoop(prompt, options = {}) {
1057
1277
  releasePrompt();
1058
1278
  }
1059
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
+ }
1060
1303
  options.onToolStart?.(call);
1061
1304
  // Card was "queued" since writeToolCall; flip to running only when work starts.
1062
1305
  emit({ type: "tool-start", id: toolEventId });
1063
1306
  writeStatus(call.name, chalk.dim(` → ${call.name}\n`));
1064
- const interactiveCommand = (call.name === "shell.exec" &&
1065
- typeof call.args.command === "string" &&
1066
- looksInteractiveStdin(call.args.command)) ||
1067
- call.name === "net.scan" ||
1068
- call.name === "pentest.recon";
1069
- if (interactiveCommand && process.stdin.isTTY) {
1070
- 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"));
1071
- }
1307
+ // Elevation uses the secure secret modal (TUI) or is refused — never
1308
+ // a raw TTY "Password:" that freezes the UI. No misleading notice.
1072
1309
  const toolAc = new AbortController();
1073
1310
  const onParentAbort = () => toolAc.abort();
1074
1311
  parentSignal.addEventListener("abort", onParentAbort);
1075
1312
  let result;
1076
1313
  let liveBytes = 0;
1077
- const shouldDimLive = !interactiveCommand;
1078
1314
  // Stream every live byte — never drop mid-run. After the tool finishes we
1079
1315
  // still replace the spool with the authoritative full `result.output`.
1080
1316
  const printLive = (chunk) => {
@@ -1087,27 +1323,42 @@ export async function runAgentLoop(prompt, options = {}) {
1087
1323
  liveBytes += chunk.length;
1088
1324
  const indented = chunk.replace(/\r/g, "").replace(/\n(?!$)/g, "\n ");
1089
1325
  const body = indented.startsWith("\n") ? indented : ` ${indented}`;
1090
- writeToolOutput(toolEventId, chunk, shouldDimLive ? chalk.dim(body) : body);
1326
+ writeToolOutput(toolEventId, chunk, chalk.dim(body));
1091
1327
  };
1092
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
+ });
1093
1337
  const backgroundJob = {
1094
1338
  id: jobId,
1095
1339
  command: `${call.name} ${formatToolArgs(call)}`,
1340
+ commandDisplay: `${call.name} ${formatToolArgs(call)}`,
1096
1341
  cwd: safeCwd(),
1097
1342
  status: "running",
1098
1343
  startedAt: new Date().toISOString(),
1099
1344
  artifactPath: "",
1345
+ stdoutArtifact: "",
1346
+ stderrArtifact: "",
1347
+ artifacts: { stdout: emptyJobArtifact(), stderr: emptyJobArtifact() },
1348
+ redactionProfile: "provider-secrets-v1",
1349
+ ownerSessionId: session.sessionId,
1100
1350
  };
1101
1351
  jobManager.registerJob(jobId, backgroundJob, toolAc);
1102
- // Long-lived commands should use shell.start/background jobs. Reset this
1103
- // watchdog whenever a blocking tool emits output so only a genuinely
1104
- // stalled operation is cancelled. Scaffold/install can go quiet for many
1105
- // minutes while downloading packages — use a much larger budget there
1106
- // (otherwise create-next-app is SIGINT'd mid-install → exit 130 + partial tree).
1107
1352
  const TOOL_STALL_ABORT_MS = toolStallBudgetMs(call);
1353
+ const TOOL_HARD_BUDGET_MS = toolHardBudgetMs(call);
1108
1354
  const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
1355
+ const hardSecs = Math.round(TOOL_HARD_BUDGET_MS / 1000);
1109
1356
  let stallTimer;
1357
+ let hardTimer;
1358
+ let graceTimer;
1110
1359
  let stalledByWatchdog = false;
1360
+ let hardTimedOut = false;
1361
+ let forceSettled = false;
1111
1362
  const resetStallTimer = () => {
1112
1363
  if (stallTimer)
1113
1364
  clearTimeout(stallTimer);
@@ -1118,10 +1369,17 @@ export async function runAgentLoop(prompt, options = {}) {
1118
1369
  toolAc.abort();
1119
1370
  }
1120
1371
  }, TOOL_STALL_ABORT_MS);
1372
+ // Node: do not keep the process alive solely for the stall timer.
1373
+ stallTimer.unref?.();
1121
1374
  };
1122
1375
  resetStallTimer();
1123
- try {
1124
- result = await runToolCall(call, {
1376
+ /**
1377
+ * Force-settle a hung tool promise after abort or hard budget.
1378
+ * Some transports ignore AbortSignal; without this race the agent
1379
+ * could freeze for minutes after "cancelling stalled tool".
1380
+ */
1381
+ const runToolWithForcedSettle = () => {
1382
+ const work = runToolCall(call, {
1125
1383
  signal: toolAc.signal,
1126
1384
  requestSecret: options.requestSecret,
1127
1385
  onOutput: (chunk) => {
@@ -1132,7 +1390,120 @@ export async function runAgentLoop(prompt, options = {}) {
1132
1390
  },
1133
1391
  confirmed: true,
1134
1392
  userPrompt: prompt,
1393
+ ...(engagementAction && scope
1394
+ ? {
1395
+ engagementAuthorization: {
1396
+ target: engagementDecision?.normalizedTarget || engagementAction.target,
1397
+ ...(scope.expiresAt ? { expiresAt: scope.expiresAt } : {}),
1398
+ },
1399
+ authorizeNetworkHop: async (url, resolvedAddresses) => {
1400
+ const hop = actionFromUrl({
1401
+ url,
1402
+ method: engagementAction.method,
1403
+ phase: engagementAction.phase,
1404
+ capability: engagementAction.capability,
1405
+ resolvedAddresses,
1406
+ });
1407
+ const hopDecision = evaluateEngagementAction(scope, hop);
1408
+ await auditLog("engagement.policy.hop", {
1409
+ ...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
1410
+ ...(engagementRecord ? { actionId: engagementRecord.id } : {}),
1411
+ url,
1412
+ resolvedAddresses,
1413
+ allowed: hopDecision.allowed,
1414
+ reason: hopDecision.reason,
1415
+ });
1416
+ return { allowed: hopDecision.allowed, reason: hopDecision.reason };
1417
+ },
1418
+ }
1419
+ : {}),
1420
+ });
1421
+ return new Promise((resolve, reject) => {
1422
+ let settled = false;
1423
+ const finishOk = (r) => {
1424
+ if (settled)
1425
+ return;
1426
+ settled = true;
1427
+ if (graceTimer)
1428
+ clearTimeout(graceTimer);
1429
+ resolve(r);
1430
+ };
1431
+ const finishErr = (err) => {
1432
+ if (settled)
1433
+ return;
1434
+ settled = true;
1435
+ if (graceTimer)
1436
+ clearTimeout(graceTimer);
1437
+ reject(err);
1438
+ };
1439
+ const forceCancelResult = () => {
1440
+ forceSettled = true;
1441
+ if (stalledByWatchdog) {
1442
+ return {
1443
+ ok: false,
1444
+ output: `Tool timed out after ${stallSecs}s without output (force-cancelled).`,
1445
+ exitCode: 124,
1446
+ };
1447
+ }
1448
+ if (hardTimedOut) {
1449
+ return {
1450
+ ok: false,
1451
+ output: `Tool hard-timeout after ${hardSecs}s — cancelled.`,
1452
+ exitCode: 124,
1453
+ };
1454
+ }
1455
+ return {
1456
+ ok: false,
1457
+ output: "Tool aborted before it could complete (force-cancelled).",
1458
+ exitCode: 130,
1459
+ };
1460
+ };
1461
+ const armGraceForceSettle = () => {
1462
+ if (settled || graceTimer)
1463
+ return;
1464
+ graceTimer = setTimeout(() => {
1465
+ if (settled)
1466
+ return;
1467
+ writeNotice("warn", `${call.name} did not stop after cancel — force-settling`, chalk.yellow(` ⏳ ${call.name} ignore cancel — force-settling hung tool\n`));
1468
+ finishOk(forceCancelResult());
1469
+ }, TOOL_ABORT_GRACE_MS);
1470
+ graceTimer.unref?.();
1471
+ };
1472
+ work.then(finishOk, finishErr);
1473
+ // Hard wall-clock: abort + force-settle after budget.
1474
+ hardTimer = setTimeout(() => {
1475
+ if (settled)
1476
+ return;
1477
+ hardTimedOut = true;
1478
+ writeNotice("warn", `${call.name} exceeded ${hardSecs}s hard budget — cancelling`, chalk.yellow(` ⏳ ${call.name} hard-timeout (${hardSecs}s) — cancelling\n`));
1479
+ if (!toolAc.signal.aborted)
1480
+ toolAc.abort();
1481
+ armGraceForceSettle();
1482
+ }, TOOL_HARD_BUDGET_MS);
1483
+ hardTimer.unref?.();
1484
+ // After any abort (stall, user Esc/Ctrl+C, parent), force-settle
1485
+ // if the tool promise does not resolve within the grace window.
1486
+ const onToolAbort = () => armGraceForceSettle();
1487
+ if (toolAc.signal.aborted)
1488
+ onToolAbort();
1489
+ else
1490
+ toolAc.signal.addEventListener("abort", onToolAbort, { once: true });
1135
1491
  });
1492
+ };
1493
+ try {
1494
+ result = await runToolWithForcedSettle();
1495
+ // User Esc/Ctrl+C: force-settle may resolve with a cancel result
1496
+ // instead of throwing — still end the turn as aborted.
1497
+ if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
1498
+ writeAbort();
1499
+ return {
1500
+ ok: false,
1501
+ call,
1502
+ result: { ok: false, output: "Aborted." },
1503
+ contextOutput: "Aborted.",
1504
+ lastAnswer: "Aborted.",
1505
+ };
1506
+ }
1136
1507
  if (liveBytes > 0) {
1137
1508
  writeToolOutput(toolEventId, "\n", "\n");
1138
1509
  }
@@ -1140,12 +1511,8 @@ export async function runAgentLoop(prompt, options = {}) {
1140
1511
  }
1141
1512
  catch (toolError) {
1142
1513
  jobManager.updateJobStatus(jobId, "failed", 1);
1143
- if (isAbortError(toolError, toolAc.signal)) {
1144
- // Only the parent signal represents a user/session cancellation.
1145
- // A watchdog abort is a local tool timeout; treating it as a global
1146
- // abort used to end the entire agent turn and strand sibling recon
1147
- // calls in an incomplete state.
1148
- if (parentSignal.aborted) {
1514
+ if (isAbortError(toolError, toolAc.signal) || forceSettled) {
1515
+ if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
1149
1516
  writeAbort();
1150
1517
  return {
1151
1518
  ok: false,
@@ -1159,8 +1526,10 @@ export async function runAgentLoop(prompt, options = {}) {
1159
1526
  ok: false,
1160
1527
  output: stalledByWatchdog
1161
1528
  ? `Tool timed out after ${TOOL_STALL_ABORT_MS / 1_000}s without output.`
1162
- : "Tool aborted before it could complete.",
1163
- exitCode: stalledByWatchdog ? 124 : 130,
1529
+ : hardTimedOut
1530
+ ? `Tool hard-timeout after ${hardSecs}s — cancelled.`
1531
+ : "Tool aborted before it could complete.",
1532
+ exitCode: stalledByWatchdog || hardTimedOut ? 124 : 130,
1164
1533
  };
1165
1534
  }
1166
1535
  else {
@@ -1171,54 +1540,88 @@ export async function runAgentLoop(prompt, options = {}) {
1171
1540
  finally {
1172
1541
  if (stallTimer)
1173
1542
  clearTimeout(stallTimer);
1543
+ if (hardTimer)
1544
+ clearTimeout(hardTimer);
1545
+ if (graceTimer)
1546
+ clearTimeout(graceTimer);
1547
+ engagementLease?.release();
1174
1548
  parentSignal.removeEventListener("abort", onParentAbort);
1175
1549
  }
1176
- // After a REAL successful scaffold, pin project root. Cancelled / empty
1177
- // targets must NOT pin a root or count as success (exit 0 + "cancelled").
1178
- // Must run before emit/evidence so the model sees failure, not a false ok.
1179
- // If the process was aborted mid-install but a usable tree is already on
1180
- // disk, pin the root and tell the model to CONTINUE (do not re-scaffold).
1181
1550
  if ((call.name === "shell.exec" || call.name === "shell.start") &&
1182
1551
  typeof call.args.command === "string" &&
1183
1552
  isScaffoldCreateCommand(call.args.command)) {
1184
1553
  const cmd = call.args.command;
1185
1554
  const cwdArg = typeof call.args.cwd === "string" ? call.args.cwd : undefined;
1186
- const fromScaffold = extractProjectRootFromScaffold(cmd, cwdArg);
1187
- const cancelled = isScaffoldCancelledOutput(result.output ?? "");
1188
- const materialized = scaffoldLooksMaterialized(fromScaffold);
1555
+ const out = result.output ?? "";
1556
+ // Prefer path reported by the scaffolder (handles quoted-cd mis-parse leftovers).
1557
+ const fromOutput = out.match(/Scaffolding project in\s+([^\n]+?)\s*\.{0,3}\s*$/im)?.[1]?.trim().replace(/['"]/g, "");
1558
+ const fromScaffold = (fromOutput && fromOutput.startsWith("/")
1559
+ ? fromOutput
1560
+ : undefined) ??
1561
+ extractProjectRootFromScaffold(cmd, cwdArg);
1562
+ const cancelled = isScaffoldCancelledOutput(out);
1563
+ let materialized = scaffoldLooksMaterialized(fromScaffold);
1564
+ // One re-check: create-vite can report success before FS snapshot is visible.
1565
+ if (!materialized && fromScaffold) {
1566
+ materialized = scaffoldLooksMaterialized(fromScaffold);
1567
+ }
1189
1568
  const abortedMid = !result.ok &&
1190
1569
  (result.exitCode === 124 ||
1191
1570
  result.exitCode === 130 ||
1192
- /timed out|aborted|Command aborted/i.test(result.output ?? ""));
1193
- if (result.ok && (cancelled || !materialized)) {
1571
+ /timed out|aborted|Command aborted/i.test(out));
1572
+ const resumableMaterialized = Boolean(fromScaffold && materialized && (cancelled || abortedMid || !result.ok));
1573
+ if (resumableMaterialized && fromScaffold) {
1574
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1575
+ await persistProjectRootOnPlan(fromScaffold);
1576
+ result = {
1577
+ ...result,
1578
+ ok: true,
1579
+ exitCode: 0,
1580
+ output: out +
1581
+ (out.endsWith("\n") ? "" : "\n") +
1582
+ `The scaffold reported ${cancelled ? "cancellation/refusal" : "interruption"}, but a usable project tree already exists at ${fromScaffold} ` +
1583
+ `(package/manifest present). Treat this as resumable: do NOT re-run the scaffolder. ` +
1584
+ `Inspect the existing files, finish any missing install, implement the requested feature, then run/verify.`,
1585
+ };
1586
+ writeNotice("info", `project root → ${fromScaffold} (existing materialized scaffold — continue)`, chalk.dim(` ℹ existing scaffold at ${fromScaffold} — continue, do not re-create\n`));
1587
+ }
1588
+ else if (result.ok && cancelled && !materialized) {
1194
1589
  result = {
1195
1590
  ok: false,
1196
- output: (result.output ?? "") +
1197
- (result.output?.endsWith("\n") ? "" : "\n") +
1198
- `Scaffold FAILED: ${cancelled ? "tool reported cancel/refuse" : "target project tree was not created"}. ` +
1199
- (fromScaffold
1200
- ? `Expected project at ${fromScaffold}. `
1201
- : "") +
1591
+ output: out +
1592
+ (out.endsWith("\n") ? "" : "\n") +
1593
+ `Scaffold FAILED: tool reported cancel/refuse. ` +
1594
+ (fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
1202
1595
  `If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
1203
1596
  exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
1204
1597
  };
1205
1598
  }
1599
+ else if (result.ok && !materialized) {
1600
+ // Soft warn only when we truly see no tree — do not flip ok if output
1601
+ // clearly scaffolded (path may still resolve on next tool).
1602
+ const claimedScaffold = /Scaffolding project in\b/i.test(out);
1603
+ if (!claimedScaffold) {
1604
+ result = {
1605
+ ok: false,
1606
+ output: out +
1607
+ (out.endsWith("\n") ? "" : "\n") +
1608
+ `Scaffold FAILED: target project tree was not created. ` +
1609
+ (fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
1610
+ `If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
1611
+ exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
1612
+ };
1613
+ }
1614
+ else if (fromScaffold) {
1615
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1616
+ await persistProjectRootOnPlan(fromScaffold);
1617
+ writeNotice("info", `project root → ${fromScaffold} (scaffold output claimed success — continue)`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1618
+ }
1619
+ }
1206
1620
  else if (result.ok && fromScaffold && materialized) {
1207
1621
  setActiveProjectRootIfValid(fromScaffold, { force: true });
1622
+ await persistProjectRootOnPlan(fromScaffold);
1208
1623
  writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1209
1624
  }
1210
- else if (abortedMid && fromScaffold && materialized) {
1211
- setActiveProjectRootIfValid(fromScaffold, { force: true });
1212
- result = {
1213
- ...result,
1214
- output: (result.output ?? "") +
1215
- (result.output?.endsWith("\n") ? "" : "\n") +
1216
- `Scaffold command was interrupted, but a project tree already exists at ${fromScaffold} ` +
1217
- `(package/manifest present). Do NOT re-run the scaffolder. CONTINUE: finish any missing install ` +
1218
- `(\`npm install\` / stack equivalent), implement the requested feature, then run/verify.`,
1219
- };
1220
- writeNotice("info", `project root → ${fromScaffold} (partial scaffold — continue)`, chalk.dim(` ℹ partial scaffold at ${fromScaffold} — continue, do not re-create\n`));
1221
- }
1222
1625
  }
1223
1626
  const output = result.output.trim();
1224
1627
  // Always keep a full on-disk copy of tool output (any size) so the
@@ -1245,21 +1648,177 @@ export async function runAgentLoop(prompt, options = {}) {
1245
1648
  exitCode: result.exitCode,
1246
1649
  output: result.output.slice(0, 4_000),
1247
1650
  });
1651
+ if (engagementGraph && engagementRecord) {
1652
+ if (result.backgroundJob) {
1653
+ const checkpointInput = {
1654
+ jobId: result.backgroundJob.id,
1655
+ status: result.backgroundJob.status,
1656
+ artifactPath: result.backgroundJob.artifactPath,
1657
+ offset: result.backgroundJob.nextOffset ?? 0,
1658
+ observation: result.output.slice(0, 16_000),
1659
+ };
1660
+ const reconciled = reconcileEngagementJob(engagementGraph, checkpointInput);
1661
+ if (!reconciled || reconciled.actionId !== engagementRecord.id) {
1662
+ recordEngagementCheckpoint(engagementGraph, {
1663
+ actionId: engagementRecord.id,
1664
+ ...checkpointInput,
1665
+ });
1666
+ }
1667
+ }
1668
+ else {
1669
+ finishEngagementAction(engagementGraph, engagementRecord.id, {
1670
+ ok: result.ok,
1671
+ observation: result.output.slice(0, 16_000),
1672
+ ...(savedOutputPath ? { artifactPath: savedOutputPath } : {}),
1673
+ scannerLead: call.name === "net.scan" || call.name.startsWith("pentest."),
1674
+ });
1675
+ }
1676
+ await saveEngagement(engagementGraph);
1677
+ }
1678
+ const newEvidence = recordToolEvidence(outcomeState, {
1679
+ tool: call.name,
1680
+ callId: toolEventId,
1681
+ ok: result.ok,
1682
+ output: result.output,
1683
+ ...(savedOutputPath ? { artifact: savedOutputPath } : {}),
1684
+ ...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
1685
+ args: call.args,
1686
+ });
1687
+ let hypothesisDelta = 0;
1688
+ if (!result.ok) {
1689
+ const before = outcomeState.failedHypotheses.length;
1690
+ recordFailedHypothesis(outcomeState, {
1691
+ signature: `${call.name}:${result.exitCode ?? 1}`,
1692
+ premise: `${call.name} with ${JSON.stringify(call.args).slice(0, 1_000)}`,
1693
+ });
1694
+ hypothesisDelta = outcomeState.failedHypotheses.length - before;
1695
+ retryDependenciesChanged = false;
1696
+ retryEnvironmentChanged = false;
1697
+ moveTurn("exploring", `${call.name} failed; revise the premise`);
1698
+ }
1699
+ else {
1700
+ const mutatesDependencies = /^(?:fs\.(?:write|writeMany|edit|replaceLines|append|delete)|pkg\.install)$/.test(call.name) ||
1701
+ ((call.name === "shell.exec" || call.name === "shell.start") &&
1702
+ /\b(?:install|mkdir|create|generate|build)\b/i.test(String(call.args.command ?? "")));
1703
+ retryDependenciesChanged ||= mutatesDependencies;
1704
+ retryEnvironmentChanged ||=
1705
+ call.name === "pkg.install" ||
1706
+ ((call.name === "shell.exec" || call.name === "shell.start") &&
1707
+ isPackageInstallCommand(String(call.args.command ?? "")));
1708
+ }
1709
+ const governed = governProgress(governorState, "activity", {
1710
+ evidenceDelta: newEvidence.length,
1711
+ hypothesisDelta,
1712
+ repetitionScore: loopGuard.getAttemptCount(call.name, call.args) > 1 ? 1 : 0,
1713
+ policy: {
1714
+ resourceEnvelope: Math.max(12, maxSteps),
1715
+ emergencyCeiling: Math.max(70, maxSteps * 3),
1716
+ reflectionAfterNoDelta: 3,
1717
+ pauseAfterNoDelta: 6,
1718
+ repetitionThreshold: 0.8,
1719
+ },
1720
+ });
1721
+ governorState = governed.state;
1722
+ if (governed.recommendation === "reflect") {
1723
+ deferredPostToolMessages.push({
1724
+ role: "system",
1725
+ content: `PROGRESS GOVERNOR: ${governed.reason}. Reassess the current premise and choose the next action that can produce criterion-linked evidence.`,
1726
+ });
1727
+ }
1728
+ else if (governed.recommendation === "paused_budget") {
1729
+ governorPauseReason = governed.reason;
1730
+ }
1731
+ await saveOutcomeState(outcomeState);
1248
1732
  loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
1249
1733
  // Evidence for verify-before-done: only successful real work counts.
1250
1734
  if (result.ok && isEvidenceWorkTool(call.name)) {
1251
1735
  const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
1252
- const openId = liveAfter?.tasks.find((t) => t.state === "in_progress")?.id;
1253
- taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, openId ?? taskWorkLedger?.taskId, call.name);
1736
+ // Never credit whichever task happens to be open after execution: a
1737
+ // later task.update in the same batch may already have changed it.
1738
+ const creditId = dispatchedTaskId;
1739
+ const cmd = typeof call.args.command === "string" ? call.args.command : "";
1740
+ const signals = {};
1741
+ if (isFeatureImplementationCall(call))
1742
+ signals.featureWrite = true;
1743
+ if (call.name === "fs.write" ||
1744
+ call.name === "fs.writeMany" ||
1745
+ call.name === "fs.edit" ||
1746
+ call.name === "fs.replaceLines" ||
1747
+ call.name === "fs.append") {
1748
+ signals.sourceWrite = true;
1749
+ }
1750
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1751
+ isPackageInstallCommand(cmd)) {
1752
+ signals.installOk = true;
1753
+ }
1754
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1755
+ isScaffoldCreateCommand(cmd)) {
1756
+ signals.scaffoldOk = true;
1757
+ }
1758
+ if (isDevServerCall(call))
1759
+ signals.devServerStart = true;
1760
+ const out = result.output ?? "";
1761
+ if ((call.name === "shell.tail" || call.name === "shell.start") &&
1762
+ isServerReadyOutput(out)) {
1763
+ signals.serverReady = true;
1764
+ }
1765
+ if (call.name === "shell.exec" &&
1766
+ isPortListeningOutput(cmd, out)) {
1767
+ signals.portListening = true;
1768
+ }
1769
+ if (localHttpProbeIsSuccess(out) ||
1770
+ (sawLocalHttpProbe && !sawFailedLocalHttpProbe)) {
1771
+ // Prefer explicit success parse on this result
1772
+ if (/\b(localhost|127\.0\.0\.1)\b/i.test(`${call.name} ${cmd} ${JSON.stringify(call.args)}`)) {
1773
+ if (localHttpProbeIsSuccess(out)) {
1774
+ signals.localHttpProbeOk = true;
1775
+ }
1776
+ }
1777
+ }
1778
+ // Remote/pentest evidence — never conflate with local app runtime
1779
+ if (isRemoteReconToolCall(call))
1780
+ signals.remoteReconOk = true;
1781
+ if (isRemoteActiveTestCall(call))
1782
+ signals.remoteActiveTestOk = true;
1783
+ // Always bank the success for later absorb (preflight / no open task).
1784
+ sessionLooseWork.push({
1785
+ toolName: call.name,
1786
+ ...(Object.keys(signals).length > 0 ? { signals } : {}),
1787
+ });
1788
+ taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, creditId, call.name, signals);
1789
+ // If nothing was open, still try to attach to the next ready explore
1790
+ // task so "Check Node/npm" can complete without thrash.
1791
+ if ((!creditId || !taskWorkLedger || taskWorkLedger.taskId !== creditId) && liveAfter) {
1792
+ const ready = readyPlanTasks(liveAfter)[0];
1793
+ if (ready) {
1794
+ const absorbed = absorbLooseWorkIntoLedger(ledgerFromTaskEvidence(ready.id, ready.evidence), ready.id, ready.title, [{ toolName: call.name, signals }], { planKind: liveAfter.kind });
1795
+ if (absorbed && absorbed.successWorkCount > 0) {
1796
+ const task = liveAfter.tasks.find((t) => t.id === ready.id);
1797
+ if (task) {
1798
+ task.evidence = taskEvidenceFromLedger(absorbed);
1799
+ if (!taskWorkLedger ||
1800
+ taskWorkLedger.taskId !== ready.id ||
1801
+ taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
1802
+ taskWorkLedger = absorbed;
1803
+ }
1804
+ await savePlan(liveAfter).catch(() => undefined);
1805
+ }
1806
+ }
1807
+ }
1808
+ }
1809
+ if (liveAfter && creditId && taskWorkLedger?.taskId === creditId) {
1810
+ const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
1811
+ if (task) {
1812
+ task.evidence = taskEvidenceFromLedger(taskWorkLedger);
1813
+ await savePlan(liveAfter).catch(() => undefined);
1814
+ }
1815
+ }
1816
+ refreshSessionState(liveAfter);
1254
1817
  }
1255
- // Inject approach evaluation when consecutive failures are detected.
1256
- // Lets the MODEL decide (with full context) whether to continue a
1257
- // legitimately long approach, switch, or stop — instead of a
1258
- // hardcoded kill threshold.
1259
1818
  if (!result.ok) {
1260
1819
  const reflection = loopGuard.getFailureReflection();
1261
1820
  if (reflection) {
1262
- messages.push({ role: "system", content: reflection });
1821
+ deferredPostToolMessages.push({ role: "system", content: reflection });
1263
1822
  const failCount = loopGuard.consecutiveFailureCount();
1264
1823
  writeNotice("warn", `${failCount} consecutive failures — model evaluating approach`, chalk.yellow(` ⚠ ${failCount} consecutive failures — evaluating approach\n`));
1265
1824
  }
@@ -1298,9 +1857,6 @@ export async function runAgentLoop(prompt, options = {}) {
1298
1857
  artifactPath: savedOutputPath,
1299
1858
  summary: contextOutput,
1300
1859
  });
1301
- // Classic REPL only: short path notice. Never push long
1302
- // "Ctrl+O or /output last … (path)" strings into the TUI status
1303
- // footer — they collide with activity and garble the chrome.
1304
1860
  if (writesDirectly && savedOutputPath) {
1305
1861
  const short = chalk.dim(` saved ${savedOutputPath}\n`);
1306
1862
  process.stdout.write(short);
@@ -1312,15 +1868,6 @@ export async function runAgentLoop(prompt, options = {}) {
1312
1868
  }
1313
1869
  return { ok: result.ok, call, result, contextOutput };
1314
1870
  }
1315
- // Automatic context compaction
1316
- // As a long turn accumulates tool outputs and reasoning, the context can
1317
- // grow past what the model can hold. We proactively summarize the older
1318
- // turns into a single continuation memory (the SAME model-written summary
1319
- // the /compact command uses — never a mechanical transcript dump) and then
1320
- // re-inject the ACTIVE PLAN so the agent never loses track of the plan,
1321
- // what is done, and what remains. The estimate is chars/4; the budget is
1322
- // deliberately conservative so we compact a little early rather than hit a
1323
- // provider context-window error mid-task.
1324
1871
  const AUTO_COMPACT_KEEP_RECENT = 6;
1325
1872
  let lastCompactionMsgCount = 0;
1326
1873
  const summarizeForCompaction = async (summaryPrompt) => {
@@ -1328,10 +1875,7 @@ export async function runAgentLoop(prompt, options = {}) {
1328
1875
  provider,
1329
1876
  model,
1330
1877
  messages: [
1331
- {
1332
- role: "system",
1333
- content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
1334
- },
1878
+ { role: "system", content: COMPACTION_SYSTEM_PROMPT },
1335
1879
  { role: "user", content: summaryPrompt },
1336
1880
  ],
1337
1881
  temperature: 0.1,
@@ -1367,6 +1911,8 @@ export async function runAgentLoop(prompt, options = {}) {
1367
1911
  content: planContextMessage(livePlan, session.planApproved.value),
1368
1912
  });
1369
1913
  }
1914
+ // Re-inject live SESSION STATE after compaction (older flags survive).
1915
+ refreshSessionState(livePlan);
1370
1916
  lastCompactionMsgCount = messages.length;
1371
1917
  // Final count the model actually receives (may include re-injected plan).
1372
1918
  const afterTokens = estimateMessagesTokens(messages);
@@ -1375,10 +1921,6 @@ export async function runAgentLoop(prompt, options = {}) {
1375
1921
  estimatedTokens: afterTokens,
1376
1922
  reason,
1377
1923
  });
1378
- // Extract the inserted compaction memory so we can surface the
1379
- // summary itself (not just token-count stats). The summary lives in
1380
- // the first system message whose content begins with
1381
- // COMPACTION_MEMORY_PREFIX.
1382
1924
  const insertedSummary = messages.find((m) => m.role === "system" &&
1383
1925
  m.content.startsWith(COMPACTION_MEMORY_PREFIX))?.content ?? "";
1384
1926
  const summaryText = insertedSummary.startsWith(`${COMPACTION_MEMORY_PREFIX}\n\n`)
@@ -1404,84 +1946,32 @@ export async function runAgentLoop(prompt, options = {}) {
1404
1946
  }
1405
1947
  }
1406
1948
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
1407
- // Each model round-trip re-decides whether its visible prose has been
1408
- // committed, so recovery-prose preservation applies to THIS turn's
1409
- // stream and not a prior one.
1410
1949
  visibleCommitted = false;
1411
1950
  // `step` is the productive-step index (used for display + audit). It only
1412
1951
  // advances when the previous iteration actually executed a tool.
1413
1952
  step = productiveSteps;
1414
- // Step budget gate: ask the user instead of hard-stopping
1415
- if (productiveSteps >= stepBudget) {
1416
- const askContinue = confirmPort.confirmContinue ?? inquirerConfirmPort.confirmContinue;
1417
- let shouldContinue = false;
1418
- try {
1419
- shouldContinue = await askContinue(productiveSteps);
1420
- restoreInteractiveStdin();
1421
- }
1422
- catch {
1423
- // Abort / non-interactive — treat as decline.
1424
- shouldContinue = false;
1425
- }
1426
- if (shouldContinue) {
1427
- // Extend the budget for another chunk of work.
1428
- const extension = Math.max(40, maxSteps);
1429
- stepBudget += extension;
1430
- maxIterations = stepBudget * 3;
1431
- // Compact older messages (model-written summary, no mechanical dump)
1432
- // to free context space for the next chunk of work.
1433
- await maybeAutoCompact("step-budget-continue", true);
1434
- // Inject a progress summary so the model stays focused.
1435
- const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1436
- let progressNote = "The step limit was reached and the user chose to continue. ";
1437
- progressNote +=
1438
- "Review what you have accomplished so far and continue with the NEXT unfinished step. ";
1439
- progressNote +=
1440
- "Do NOT repeat work already done. Do NOT re-fetch pages or re-run scans whose results you already have.";
1441
- if (livePlan) {
1442
- const doneTasks = livePlan.tasks.filter((t) => t.state === "done");
1443
- const pendingTasks = livePlan.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
1444
- progressNote += `\n\nPlan progress: ${doneTasks.length}/${livePlan.tasks.length} tasks done.`;
1445
- if (pendingTasks.length > 0) {
1446
- progressNote += ` Next: ${pendingTasks[0].id} — "${pendingTasks[0].title}".`;
1447
- }
1448
- }
1449
- messages.push({ role: "user", content: progressNote });
1450
- writeNotice("info", `continuing — budget extended to ${stepBudget} steps`, chalk.dim(` ℹ continuing — budget extended to ${stepBudget} steps\n`));
1451
- // Continue the loop — model doesn't know it paused.
1452
- }
1453
- else {
1454
- // User declined — build a rich summary and return.
1455
- const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
1456
- writeAssistantMessage(richSummary);
1457
- lastAnswer = richSummary;
1458
- return finishTurn(lastAnswer, productiveSteps);
1459
- }
1953
+ if (governorPauseReason) {
1954
+ const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
1955
+ outcomeState.outcome.status = "paused_budget";
1956
+ await saveOutcomeState(outcomeState);
1957
+ moveTurn("paused_budget", governorPauseReason);
1958
+ lastAnswer = richSummary;
1959
+ return finishTurn(lastAnswer, productiveSteps, "paused_budget", outcomeState.outcome.criteria
1960
+ .filter((criterion) => criterion.required && criterion.status !== "proven")
1961
+ .map((criterion) => criterion.statement), governorPauseReason);
1460
1962
  }
1461
1963
  options.signal?.throwIfAborted();
1462
- // `call` and `assistantText` are shared by both paths below: a fresh
1463
- // model round-trip, or draining a previously-queued tool call.
1464
1964
  let call;
1465
1965
  let assistantText;
1466
1966
  let recoveredFromBareJson = false;
1467
1967
  if (pendingCalls.length > 0) {
1468
- // Drain the next queued call from the previous model message — no new
1469
- // round-trip. The assistant message and any prose were already shown
1470
- // when the batch was parsed.
1471
1968
  call = pendingCalls.shift();
1472
1969
  assistantText = { visible: "", thinkContent: "", hasThinking: false };
1473
1970
  const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
1474
1971
  writeStatus(batchStatus, chalk.dim(batchStatus));
1475
1972
  }
1476
1973
  else {
1477
- // Before a fresh model round-trip, proactively compact if the context has
1478
- // grown too large, so we never hit a provider context-window error and the
1479
- // model keeps a clean, plan-aware memory.
1480
1974
  await maybeAutoCompact("auto-token-budget");
1481
- // Buffer LLM output so tool JSON and hidden thinking are not printed raw.
1482
- // Status messages (rate-limit retries, fallback hints) still surface live.
1483
- // A spinner gives the user feedback during long thinking phases on
1484
- // models like glm-5.1 / deepseek-v4-flash that stream reasoning first.
1485
1975
  const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
1486
1976
  let spinner = writesDirectly
1487
1977
  ? startThinkingSpinner(streamLabel, options.signal)
@@ -1496,9 +1986,6 @@ export async function runAgentLoop(prompt, options = {}) {
1496
1986
  let accumulatedText = "";
1497
1987
  const callIds = [];
1498
1988
  let streamedCallsCount = 0;
1499
- // Deferred tool-call events: collect tool calls parsed from the stream
1500
- // and emit them AFTER thinking + assistant text, so the display order
1501
- // is correct: thinking → model text → tool-call cards.
1502
1989
  const deferredToolCalls = [];
1503
1990
  const deltaParser = writesDirectly
1504
1991
  ? undefined
@@ -1518,7 +2005,7 @@ export async function runAgentLoop(prompt, options = {}) {
1518
2005
  if (messages[0]?.role === "system") {
1519
2006
  messages[0] = {
1520
2007
  role: "system",
1521
- content: buildSystemContent(nativeToolsActive),
2008
+ content: composeCurrentSystemPrompt(nativeToolsActive),
1522
2009
  };
1523
2010
  }
1524
2011
  const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
@@ -1530,30 +2017,13 @@ export async function runAgentLoop(prompt, options = {}) {
1530
2017
  dialect: toolDialect,
1531
2018
  step,
1532
2019
  });
2020
+ assertValidToolProtocol(messages);
1533
2021
  completion = await streamWithProvider({
1534
2022
  provider,
1535
2023
  model,
1536
- // Stay on the user's selected provider/model. Cross-provider
1537
- // hops (e.g. bynara → gemini on a 429) only happen when the
1538
- // user has explicitly enabled `providerFallback` AND this is
1539
- // the provider's default model. Never force fallback mid-turn.
1540
2024
  allowModelFallback: false,
1541
2025
  messages,
1542
- // MiniMax M3 degenerates at the generic agent temperature. The
1543
- // HTTP layer also applies its `top_p` override for both the
1544
- // NVIDIA long ID and Kimchi's short `minimax-m3` ID.
1545
2026
  temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
1546
- // Reasoning models can spend a lot on hidden thinking; give
1547
- // them headroom so the visible answer / tool call isn't
1548
- // truncated to silence. The non-thinking budget must be large
1549
- // enough for a single-file fs.write / multi-file fs.writeMany
1550
- // payload — a truncated tool-call JSON fails to parse and leaks a
1551
- // broken (and syntactically invalid) file. 8k was too small for a
1552
- // full component, so allow more room for the visible tool call.
1553
- // Code-generation calls frequently contain an entire source file
1554
- // inside JSON. A 12k visible-token ceiling cut otherwise valid
1555
- // fs.write calls in half. Keep enough output headroom for a
1556
- // substantial source file; providers with a lower limit clamp it.
1557
2027
  maxTokens: 32_768,
1558
2028
  signal: options.signal,
1559
2029
  thinking: retryWithoutThinking
@@ -1656,9 +2126,6 @@ export async function runAgentLoop(prompt, options = {}) {
1656
2126
  }
1657
2127
  }
1658
2128
  }
1659
- // Heuristic: <think>… markers and reasoning_content tokens flow
1660
- // through onToken. Surface activity in the spinner so the screen
1661
- // is never empty for minutes.
1662
2129
  if (!sawReasoning && /<think/i.test(token)) {
1663
2130
  sawReasoning = true;
1664
2131
  inThinking = true;
@@ -1671,11 +2138,6 @@ export async function runAgentLoop(prompt, options = {}) {
1671
2138
  spinner.setLabel("generating response (0 tokens)");
1672
2139
  generatedTokens = 0;
1673
2140
  }
1674
- // Only push reasoning tokens to the spinner preview. Visible
1675
- // answer / tool-call tokens should NOT go through the dim
1676
- // spinner preview — doing so makes the final answer appear
1677
- // "diluted" in light font when the spinner's last render
1678
- // briefly shows the answer text before being erased.
1679
2141
  if (inThinking) {
1680
2142
  const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
1681
2143
  if (cleaned) {
@@ -1711,13 +2173,6 @@ export async function runAgentLoop(prompt, options = {}) {
1711
2173
  (toolsAttached && !isTextOnlyModel(provider, model));
1712
2174
  const assistantTextResult = rememberThinkingFromText(completion.text);
1713
2175
  assistantText = assistantTextResult;
1714
- // Commit thinking to the transcript IMMEDIATELY, before any of the
1715
- // branches below decide to `continue` (retry a malformed tool call,
1716
- // nudge for narration, guard premature completion, etc). Previously
1717
- // writeThinkingBlock was only called from a few terminal branches, so
1718
- // any retry path silently dropped the model's reasoning — the user
1719
- // would see the live "thinking…" preview during streaming and then
1720
- // watch it vanish with nothing committed once the turn moved on.
1721
2176
  if (assistantText.hasThinking) {
1722
2177
  writeThinkingBlock(assistantText.thinkContent);
1723
2178
  }
@@ -1780,11 +2235,6 @@ export async function runAgentLoop(prompt, options = {}) {
1780
2235
  }
1781
2236
  }
1782
2237
  }
1783
- // Try visible text first, then thinking content — some models (e.g. glm-5.1)
1784
- // wrap tool calls inside considering tags, so stripThinking removes them
1785
- // into thinkContent and visible becomes empty. Recovering from thinkContent
1786
- // prevents an endless nudge loop where the model keeps hiding the call.
1787
- // When native toolCalls exist, skip text parse as primary (no double-exec).
1788
2238
  if (nativeToolCalls.length) {
1789
2239
  const first = nativeToolCalls[0];
1790
2240
  if (first.args?._parseError) {
@@ -1807,12 +2257,6 @@ export async function runAgentLoop(prompt, options = {}) {
1807
2257
  }
1808
2258
  }
1809
2259
  }
1810
- // ── Prompt-leak guard ─────────────────────────────────────────
1811
- // If the model's visible output contains distinctive system-prompt
1812
- // markers, it is repeating its instructions (e.g. prompt injection
1813
- // via "repeat your instructions verbatim"). Any tool-call syntax
1814
- // (text fences OR native toolCalls) is an EXAMPLE from the prompt,
1815
- // not a real request. Suppress it so we never execute leaked examples.
1816
2260
  if (looksLikePromptLeak(assistantText.visible)) {
1817
2261
  if (call || nativeToolCalls.length) {
1818
2262
  writeNotice("warn", "suppressed tool call from apparent prompt leak", chalk.yellow(" ⚠ suppressed tool call — model appears to be repeating its system prompt\n"));
@@ -1821,10 +2265,6 @@ export async function runAgentLoop(prompt, options = {}) {
1821
2265
  nativeToolCalls = [];
1822
2266
  deferredToolCalls.length = 0;
1823
2267
  }
1824
- // ── Native truncated write salvage ────────────────────────────
1825
- // Large fs.write content lives in tool_calls arguments, not fences.
1826
- // When finish_reason is length or args failed to parse, salvage
1827
- // partial content and continue with append (native wording).
1828
2268
  if (nativeToolCalls.length) {
1829
2269
  // Only salvage when args failed to parse (truncated JSON). A clean
1830
2270
  // parse with finish_reason=length is a complete tool call — execute it.
@@ -1887,14 +2327,6 @@ export async function runAgentLoop(prompt, options = {}) {
1887
2327
  }
1888
2328
  }
1889
2329
  }
1890
- // Empty-response recovery
1891
- // Some models occasionally return an empty completion: a reasoning
1892
- // model that spent its whole budget on hidden &lt;think&gt; reasoning and emitted
1893
- // no visible text, OR (more perniciously) a gateway hiccup that
1894
- // streamed [DONE] with no content deltas at all. Without this guard
1895
- // the agent silently ends the turn with no answer, no warning, and no
1896
- // error — the user just sees the spinner stop. Catch BOTH cases
1897
- // (thinking-only AND truly empty) and nudge the model to retry.
1898
2330
  if (!assistantText.visible.trim() && !call) {
1899
2331
  emptyVisibleRetries += 1;
1900
2332
  if (emptyVisibleRetries <= 3) {
@@ -1912,10 +2344,10 @@ export async function runAgentLoop(prompt, options = {}) {
1912
2344
  ? toolsAttached
1913
2345
  ? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
1914
2346
  : "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."
1915
- : buildLikeTurn && !activePlan
2347
+ : isPlanMode && !activePlan
1916
2348
  ? toolsAttached
1917
- ? "No visible output. Call plan.create now (do not only describe the plan)."
1918
- : "No visible output. Emit a ```tool block to call plan.create now. " +
2349
+ ? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
2350
+ : "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
1919
2351
  "Do NOT hide tool calls in <think> tags — put them in the visible response."
1920
2352
  : toolsAttached
1921
2353
  ? "No visible output. " + toolNudge(true)
@@ -1924,8 +2356,6 @@ export async function runAgentLoop(prompt, options = {}) {
1924
2356
  messages.push(recoveryUserMessage(buildNudge));
1925
2357
  continue;
1926
2358
  }
1927
- // Exhausted retries — surface a clear notice and exit the turn instead
1928
- // of falling through and triggering premature-completion loops.
1929
2359
  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"));
1930
2360
  return finishTurn("Model returned an empty response after retries.", step + 1);
1931
2361
  }
@@ -1934,12 +2364,6 @@ export async function runAgentLoop(prompt, options = {}) {
1934
2364
  emptyVisibleRetries = 0;
1935
2365
  retryWithoutThinking = false;
1936
2366
  }
1937
- // `call` was already extracted above (from visible text or thinking content).
1938
- // Recovery: the model meant to call a tool but emitted a bare JSON object
1939
- // with no ```tool fence — either a complete {name,args} the strict
1940
- // matchers missed (recover it directly), or just an args object like
1941
- // {"path":"file.pdf"} with the wrapper dropped (nudge a retry below so
1942
- // the requested action runs instead of the JSON leaking as the answer).
1943
2367
  let bareArgsOnly = false;
1944
2368
  recoveredFromBareJson = false;
1945
2369
  if (!call) {
@@ -1975,16 +2399,13 @@ export async function runAgentLoop(prompt, options = {}) {
1975
2399
  ? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
1976
2400
  : " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
1977
2401
  pushAssistantHistory(assistantText.visible);
1978
- messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
2402
+ messages.push(recoveryUserMessage(isPlanMode && !activePlan
1979
2403
  ? toolsAttached
1980
2404
  ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
1981
- "This is a BUILD/SCAFFOLD task with NO plan yet. Call plan.create now via the platform tool interface. " +
1982
- "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
2405
+ "In plan mode: call plan.create (or research tools) via the platform tool interface."
1983
2406
  : "Your previous message was a bare JSON args object with no tool name and no ```tool fence, so NOTHING ran. " +
1984
- "This is a BUILD/SCAFFOLD task with NO plan yet. " +
1985
- "You MUST call plan.create using a proper ```tool block. For example:\n" +
1986
- '```tool\n{"name":"plan.create","args":{"goal":"scaffold todo app","detail":"...","tasks":["...","..."],"kind":"coding"}}\n```\n' +
1987
- "Do NOT use fs.write, fs.writeMany, shell.exec, or pkg.install yet."
2407
+ "In plan mode, call plan.create with a proper ```tool block when ready, e.g.:\n" +
2408
+ '```tool\n{"name":"plan.create","args":{"goal":"…","detail":"…","tasks":["…"],"kind":"coding"}}\n```'
1988
2409
  : toolsAttached
1989
2410
  ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
1990
2411
  toolNudge(true) +
@@ -1998,10 +2419,6 @@ export async function runAgentLoop(prompt, options = {}) {
1998
2419
  }
1999
2420
  // Exhausted retries — fall through to the normal answer path.
2000
2421
  }
2001
- // Detect the case where the model emitted sentinel-style tool-call
2002
- // markers but the body was malformed or truncated. Printing those
2003
- // raw tokens looks like a crash to the user — instead, ask the
2004
- // model to retry the tool call in a clean JSON format.
2005
2422
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
2006
2423
  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"));
2007
2424
  pushAssistantHistory(assistantText.visible);
@@ -2015,17 +2432,8 @@ export async function runAgentLoop(prompt, options = {}) {
2015
2432
  "Do not use <|tool_call_begin|> markers."));
2016
2433
  continue;
2017
2434
  }
2018
- // Detect a tool call that opened but was cut off by the token limit
2019
- // (most common with large fs.write/fs.writeMany for reports).
2020
- // Instead of asking the model to retry (which will just truncate
2021
- // again at the same limit), we SALVAGE the partial content from the
2022
- // truncated JSON and write it, then tell the model to CONTINUE with
2023
- // fs.append from where it was cut off.
2024
2435
  if (looksLikeTruncatedToolCall(assistantText.visible)) {
2025
2436
  truncatedToolRetries += 1;
2026
- // Try to salvage a partial fs.write / fs.append from the truncated JSON.
2027
- // The pattern is: {"name":"fs.write","args":{"path":"...","content":"...
2028
- // We extract the path and whatever content was produced before truncation.
2029
2437
  const salvaged = salvageTruncatedWrite(assistantText.visible);
2030
2438
  if (salvaged && truncatedToolRetries <= 5) {
2031
2439
  // Write the salvaged partial content
@@ -2085,14 +2493,6 @@ export async function runAgentLoop(prompt, options = {}) {
2085
2493
  // Exhausted retries — fall through so we don't loop forever, but the
2086
2494
  // user at least sees the (broken) output and the stop notice.
2087
2495
  }
2088
- // Detect a ```tool fence whose JSON could NOT be parsed for any other
2089
- // reason (malformed braces, trailing junk, a stray `}` — NOT plain
2090
- // truncation, which is handled above). Without this, the raw block
2091
- // leaks to the screen as a code fence and the requested action (often
2092
- // a whole fs.writeMany scaffold) silently never runs — exactly the
2093
- // "fs.writeMany printed but nothing created" failure. Require the fence
2094
- // to actually look like an intended call (mentions name/args) so a
2095
- // genuine ```tool code example in prose isn't mistaken for one.
2096
2496
  const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
2097
2497
  /```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
2098
2498
  if (hasFencedCallShape) {
@@ -2145,43 +2545,21 @@ export async function runAgentLoop(prompt, options = {}) {
2145
2545
  }
2146
2546
  // Exhausted retries — fall through to the normal path.
2147
2547
  }
2148
- // Normal final-answer path: strip any stray sentinel tokens that
2149
- // somehow leaked into prose so the answer renders cleanly.
2150
2548
  const cleaned = stripSentinelTokens(assistantText.visible);
2151
- // Act, don't narrate
2152
- // Build/scaffold/plan turns must DO something. If the model returns
2153
- // prose with NO tool call, it is narrating intent ("Let me first
2154
- // explore the directory…") or writing a PLAN as prose ("Goal: … Tasks:
2155
- // … please approve") instead of calling a tool — accepting it as a
2156
- // final answer ends the turn with nothing done and no real plan saved.
2157
- // Nudge it to emit a real tool call, with a concrete example.
2158
2549
  const narratedAction = looksLikeActionNarration(cleaned);
2159
2550
  const narratedWebAction = looksLikeWebActionNarration(cleaned);
2160
- // `activePlan` is the snapshot loaded at the start of this turn.
2161
- // A final task.update mutates and persists a separate plan instance,
2162
- // so re-read it here before deciding whether a final response should
2163
- // be forced back into execution. Without this, a completed plan
2164
- // still appeared open and a short final summary such as "I'll
2165
- // summarize the findings" could be discarded for an unnecessary
2166
- // recovery turn.
2167
- const livePlanAtCompletion = await loadPlan(session.sessionId).catch(() => undefined);
2551
+ const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
2552
+ const livePlanAtCompletion = reconciledPlanAtCompletion ??
2553
+ (await loadPlan(session.sessionId).catch(() => undefined));
2168
2554
  const planStatusAtCompletion = livePlanAtCompletion?.status ?? activePlan?.status;
2169
2555
  const completedPlanDuringThisTurn = activePlan?.status !== "completed" &&
2170
2556
  planStatusAtCompletion === "completed";
2171
2557
  const planHasOpenWorkNow = planHasOpenWork(planStatusAtCompletion);
2172
- // User-driven reasons to require tools this turn (build/pentest/
2173
- // approved plan / freshness). Idle greetings never qualify.
2174
2558
  const userExpectsWork = freshWebSearchRequired ||
2175
2559
  (planHasOpenWorkNow && session.planApproved.value) ||
2176
2560
  (!informationalQuery &&
2177
2561
  !idleOrSocialPrompt &&
2178
2562
  (buildLikeTurn || pentestLikeTurn));
2179
- // History-inherited build/pentest intent only forces action when
2180
- // THIS prompt is not itself a plain question/idle turn. Narration
2181
- // stalls ("I'll list the files") still force a tool when the user
2182
- // is not asking an informational question — but capability menus
2183
- // and greetings are filtered out by looksLikeActionNarration /
2184
- // idleOrSocialPrompt so they cannot burn recovery turns.
2185
2563
  const wantsAction = !completedPlanDuringThisTurn &&
2186
2564
  !idleOrSocialPrompt &&
2187
2565
  (userExpectsWork ||
@@ -2191,12 +2569,6 @@ export async function runAgentLoop(prompt, options = {}) {
2191
2569
  !activePlan &&
2192
2570
  looksLikePlanNarration(cleaned);
2193
2571
  const errorFixNarration = looksLikeErrorDiagnosisWithFixIntent(cleaned);
2194
- // Once a real tool step has run, a no-plan task has no durable task
2195
- // state to prove whether another action is needed. A tool-free reply
2196
- // must therefore be allowed to finalize instead of turning a short
2197
- // summary containing “I'll” into an implicit recovery request —
2198
- // EXCEPT when an approved plan still has work, or the model just
2199
- // diagnosed an error and said it would fix it without calling a tool.
2200
2572
  const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
2201
2573
  planNarrated ||
2202
2574
  (session.planApproved.value &&
@@ -2206,126 +2578,71 @@ export async function runAgentLoop(prompt, options = {}) {
2206
2578
  (buildLikeTurn && errorFixNarration);
2207
2579
  if (wantsAction &&
2208
2580
  cleaned.trim().length > 0 &&
2209
- actionIntentRetries < 3 &&
2210
2581
  shouldRetryBeforeFinalizing) {
2211
- actionIntentRetries += 1;
2212
- let nudge;
2213
- if (errorFixNarration && errorFixNarrationRetries < 3) {
2214
- errorFixNarrationRetries += 1;
2215
- nudge = toolsAttached
2216
- ? "You diagnosed an error and described the fix but called NO tool, so NOTHING was fixed. " +
2217
- "Apply the fix NOW with a real tool (fs.edit / fs.write / shell.exec), then re-verify. " +
2218
- "Do not stop after identifying the error."
2219
- : "You diagnosed an error and described the fix but emitted NO ```tool block, so NOTHING was fixed. " +
2220
- "Apply the fix NOW, e.g.:\n" +
2221
- '```tool\n{"name":"fs.edit","args":{"path":"<file>","oldText":"...","newText":"..."}}\n```\n' +
2222
- "Then re-run the failing check. Do not stop after identifying the error.";
2223
- writeNotice("warn", "error diagnosed but not fixed — forcing tool call", chalk.yellow(" ⚠ diagnosed a failure but did not call a tool — applying the fix now\n"));
2582
+ let action;
2583
+ if (errorFixNarration && budgetRemaining(recovery, "errorFix")) {
2584
+ action = recoveryForErrorDiagnosis(toolsAttached);
2224
2585
  }
2225
- else if (planHasOpenWorkNow && session.planApproved.value) {
2226
- nudge = toolsAttached
2227
- ? "You wrote a message but called NO tool, so NOTHING ran. Do NOT narrate — call the next tool now (task.update / fs.writeMany / shell.exec) via the platform tool interface."
2228
- : "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.";
2229
- 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"));
2586
+ else if (budgetRemaining(recovery, "actionIntent") &&
2587
+ planHasOpenWorkNow &&
2588
+ session.planApproved.value) {
2589
+ action = recoveryForNarration(toolsAttached, "plan_open");
2230
2590
  }
2231
- else if (pentestLikeTurn) {
2232
- nudge = toolsAttached
2233
- ? "You described what you will do but called NO tool, so NOTHING happened. Call a real tool NOW (e.g. net.scan / sysinfo / shell.exec) via the platform interface. Every turn that claims action must include a tool call until the task is done."
2234
- : "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" +
2235
- '```tool\n{"name":"sysinfo","args":{}}\n```\n' +
2236
- "Every turn MUST contain a ```tool block until the task is done.";
2237
- 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"));
2591
+ else if (budgetRemaining(recovery, "actionIntent") &&
2592
+ pentestLikeTurn) {
2593
+ action = recoveryForNarration(toolsAttached, "pentest");
2238
2594
  }
2239
- else if (freshWebSearchRequired || narratedWebAction) {
2240
- // Web-specific recovery ONLY when the user asked for current
2241
- // info or the model explicitly claimed a fetch/search step.
2242
- nudge = toolsAttached
2243
- ? "You wrote that you would fetch/search/read something but called NO tool, so NOTHING ran. Call web.search or web.fetch now via the platform interface. After the tool output, answer from the results."
2244
- : "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" +
2245
- '```tool\n{"name":"web.fetch","args":{"url":"https://example.com/page","responseMode":"readable"}}\n```\n' +
2246
- "If you do not know the exact page URL, use web.search first. After the tool output, answer from the fetched page content.";
2247
- 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"));
2595
+ else if (budgetRemaining(recovery, "actionIntent") &&
2596
+ (freshWebSearchRequired || narratedWebAction)) {
2597
+ action = recoveryForNarration(toolsAttached, "web");
2248
2598
  }
2249
- else if (buildLikeTurn &&
2599
+ else if (budgetRemaining(recovery, "actionIntent") &&
2600
+ buildLikeTurn &&
2250
2601
  (planNarrated || productiveSteps > 0)) {
2251
- const kind = pentestLikeTurn ? "pentest" : "coding";
2252
- nudge = toolsAttached
2253
- ? `You wrote the plan as PROSE but did NOT call plan.create, so no plan was saved. Call plan.create now via the platform tool interface with goal, detail, tasks, and kind="${kind}". Do not only describe the plan.`
2254
- : "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" +
2255
- `\`\`\`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` +
2256
- "Do not describe the plan again in prose — just emit the plan.create tool block.";
2257
- 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"));
2602
+ action = recoveryForNarration(toolsAttached, "build_plan_prose");
2258
2603
  }
2259
- else if (buildLikeTurn) {
2260
- nudge = toolsAttached
2261
- ? "You described what you will do but called NO tool, so NOTHING happened. Call a tool NOW (e.g. fs.list on \".\"), then plan.create once you understand the directory. Use the platform tool interface — no markdown fences."
2262
- : "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" +
2263
- '```tool\n{"name":"fs.list","args":{"path":"."}}\n```\n' +
2264
- "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.";
2265
- 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"));
2604
+ else if (budgetRemaining(recovery, "actionIntent") &&
2605
+ buildLikeTurn) {
2606
+ action = recoveryForNarration(toolsAttached, "build");
2266
2607
  }
2267
- else {
2268
- // Generic non-build, non-web stall (e.g. "I'll list the files").
2269
- nudge = toolsAttached
2270
- ? "You described what you will do but called NO tool, so NOTHING happened. " +
2271
- toolNudge(true) +
2272
- " Every turn that claims an action must include a real tool call."
2273
- : "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.";
2274
- 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"));
2608
+ else if (budgetRemaining(recovery, "actionIntent")) {
2609
+ action = recoveryForNarration(toolsAttached, "generic");
2610
+ }
2611
+ if (action) {
2612
+ consumeBudget(recovery, action.budgetKey);
2613
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2614
+ pushAssistantHistory(assistantText.visible);
2615
+ messages.push(recoveryUserMessage(action.message));
2616
+ continue;
2275
2617
  }
2276
- pushAssistantHistory(assistantText.visible);
2277
- messages.push(recoveryUserMessage(nudge));
2278
- continue;
2279
2618
  }
2280
2619
  if (freshWebSearchRequired &&
2281
2620
  !sawFreshWebSearch &&
2282
- !freshnessRetryUsed) {
2283
- freshnessRetryUsed = true;
2284
- writeNotice("info", "current-info question detected — searching the web before answering", chalk.dim(" ℹ current-info question detected — searching the web before answering\n"));
2621
+ budgetRemaining(recovery, "freshnessUsed")) {
2622
+ const action = recoveryForFreshness(freshnessGuardMessage() +
2623
+ (toolsAttached
2624
+ ? " Call the web_search tool now."
2625
+ : " Reply with ONLY a fenced ```tool block for web.search now."));
2626
+ consumeBudget(recovery, action.budgetKey);
2627
+ writeNotice("info", action.notice, chalk.dim(` ℹ ${action.notice}\n`));
2285
2628
  pushAssistantHistory(assistantText.visible);
2286
- messages.push({
2287
- role: "user",
2288
- content: freshnessGuardMessage() +
2289
- (toolsAttached
2290
- ? " Call the web_search tool now."
2291
- : " Reply with ONLY a fenced ```tool block for web.search now."),
2292
- });
2629
+ messages.push({ role: "user", content: action.message });
2293
2630
  continue;
2294
2631
  }
2295
- // Coding builds must produce a durable plan before freestyle "done".
2296
- // (Explore-only turns without plan.create must not end as a final answer.)
2297
- if (buildLike &&
2298
- !pentestLike &&
2299
- !pentestSession &&
2300
- codingBuildRequiresPlan(prompt, {
2301
- informational: informationalQuery,
2302
- idle: idleOrSocialPrompt,
2303
- pentest: false,
2304
- }) &&
2305
- forcePlanRetries < 2) {
2632
+ if (isPlanMode &&
2633
+ !informationalQuery &&
2634
+ !idleOrSocialPrompt &&
2635
+ budgetRemaining(recovery, "forcePlan")) {
2306
2636
  const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
2307
2637
  if (!planAtEnd && !sawPlanCreateOk) {
2308
- forcePlanRetries += 1;
2638
+ const action = recoveryForMissingPlan(toolsAttached);
2639
+ consumeBudget(recovery, action.budgetKey);
2309
2640
  pushAssistantHistory(assistantText.visible);
2310
- const kind = "coding";
2311
- messages.push({
2312
- role: "user",
2313
- content: toolsAttached
2314
- ? `This is a coding BUILD with NO plan saved yet. Call plan.create NOW via the platform tool interface ` +
2315
- `(goal, detail with stack + what exists on disk, 4–8 tasks, kind="${kind}"). ` +
2316
- `Include feature implementation tasks and a final run/verify task. Do NOT scaffold or write app files until the user /implement-s the plan. ` +
2317
- `Read-only explore (fs.list/read, tool.check) is fine before plan.create.`
2318
- : `This is a coding BUILD with NO plan saved yet. Emit exactly one plan.create tool block NOW:\n` +
2319
- `\`\`\`tool\n{"name":"plan.create","args":{"goal":"<short goal>","detail":"<stack, what exists, how you'll verify>","tasks":["explore/confirm destination","scaffold or continue project","implement requested feature","install deps","run/verify with shell.start + probe"],"kind":"${kind}"}}\n\`\`\`\n` +
2320
- `Do NOT scaffold or write app files until /implement. Explore read-only first if needed.`,
2321
- });
2322
- writeNotice("warn", "coding build missing plan.create — forcing plan", chalk.yellow(" ⚠ no plan yet — call plan.create before scaffolding or finishing\n"));
2641
+ messages.push({ role: "user", content: action.message });
2642
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2323
2643
  continue;
2324
2644
  }
2325
2645
  }
2326
- // Scaffold-only is NOT the product. If the user asked for a todo/blog/…
2327
- // app and the model only ran create-*, force feature implementation first.
2328
- // Do this BEFORE run/verify so we never push shell.start on blank starter.
2329
2646
  if (buildLike &&
2330
2647
  !pentestLike &&
2331
2648
  !pentestSession &&
@@ -2334,166 +2651,131 @@ export async function runAgentLoop(prompt, options = {}) {
2334
2651
  !sawFeatureImplWrite &&
2335
2652
  (sawScaffoldOk || sawLocalAppMaterialWork) &&
2336
2653
  productiveSteps > 0 &&
2337
- featureImplRetries < 2) {
2338
- featureImplRetries += 1;
2654
+ budgetRemaining(recovery, "featureImpl")) {
2655
+ const action = recoveryForMissingFeature(getActiveProjectRoot());
2656
+ consumeBudget(recovery, action.budgetKey);
2339
2657
  pushAssistantHistory(assistantText.visible);
2340
- const rootHint = getActiveProjectRoot()
2341
- ? ` Write under "${getActiveProjectRoot()}" with absolute paths.`
2342
- : "";
2343
- messages.push({
2344
- role: "user",
2345
- content: "INCOMPLETE: the user asked for a working FEATURE app (e.g. todo/blog/dashboard), not a blank framework starter. " +
2346
- "Scaffold alone (create-next-app / create-vite / cargo new / …) is a FAILURE. " +
2347
- "NOW implement the requested feature: read the entry page/component, replace starter boilerplate with real add/list/toggle/delete (or whatever they asked), " +
2348
- "using fs.write / fs.writeMany. Do NOT shell.start and do NOT only tell the user how to run the app until that feature code exists." +
2349
- rootHint,
2350
- });
2351
- writeNotice("warn", "feature not implemented — scaffold alone is not the deliverable", chalk.yellow(" ⚠ scaffold-only is incomplete — implement the requested feature before run/verify\n"));
2658
+ messages.push({ role: "user", content: action.message });
2659
+ writeNotice("warn", action.notice, chalk.yellow(` ${action.notice}\n`));
2352
2660
  continue;
2353
2661
  }
2354
- // A passing build is not evidence that an app is serving requests.
2355
- // Require start → logs → HTTP for local app builds:
2356
- // (A) completed coding plan, OR
2357
- // (B) freestyle build that implemented the product (if asked) then
2358
- // only told the user "run npm run dev yourself".
2359
- // NEVER apply this to pentest/remote engagements.
2360
2662
  if (buildLike &&
2361
2663
  !pentestLike &&
2362
2664
  !pentestSession &&
2363
- (!sawServerStart || !sawServerTail || !sawLocalHttpProbe) &&
2364
- runtimeVerificationRetries < 2 &&
2365
- // Feature apps must implement first (handled above); only verify live after that
2665
+ budgetRemaining(recovery, "runtimeVerify") &&
2366
2666
  (!featureAppAsk || sawFeatureImplWrite)) {
2367
2667
  const runtimePlan = await loadPlan(session.sessionId).catch(() => undefined);
2368
- const codingPlanFinished = Boolean(runtimePlan &&
2369
- session.planApproved.value &&
2370
- runtimePlan.kind !== "pentest" &&
2371
- runtimePlan.tasks.length > 0 &&
2372
- runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
2373
- const freestyleLocalAppDone = !session.planApproved.value &&
2374
- sawLocalAppMaterialWork &&
2375
- productiveSteps > 0 &&
2376
- // Final prose hands "how to run" to the user, or claims done without starting
2377
- (/\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
2378
- /\b(?:cargo\s+run|flask\s+run|uvicorn|rails\s+s|python\s+-m\s+http\.server)\b/i.test(cleaned) ||
2379
- /\bopen\s+http:\/\/localhost\b/i.test(cleaned) ||
2380
- /\bhow to run\b/i.test(cleaned) ||
2381
- (/\b(?:created|built|ready|complete)\b/i.test(cleaned) &&
2382
- getActiveProjectRoot() !== undefined));
2383
- if (codingPlanFinished || freestyleLocalAppDone) {
2384
- runtimeVerificationRetries += 1;
2385
- pushAssistantHistory(assistantText.visible);
2386
- const rootHint = getActiveProjectRoot()
2387
- ? ` Use cwd "${getActiveProjectRoot()}".`
2388
- : "";
2389
- messages.push({
2390
- role: "user",
2391
- content: "This is a LOCAL APP build: you must NOT stop after writing files or only telling the user how to run it. " +
2392
- "Run the missing checks NOW: shell.start the app/dev server, shell.tail until ready, one localhost HTTP probe " +
2393
- "(curl or http.fetch with iOwnThis:true), LEAVE the server running, and report URL + port + job id." +
2394
- rootHint +
2395
- " Do not only paste `npm run dev` instructions. " +
2396
- "If this was a remote pentest, ignore this and finalize the report with no local server.",
2397
- });
2398
- writeNotice("warn", "local app missing shell.start/probe — forcing run/verify", chalk.yellow(" ⚠ local app not verified live — start server, tail, probe localhost, leave running\n"));
2399
- continue;
2668
+ // Durable plan evidence or multi-signal proof this turn is enough
2669
+ const planRuntimeOk = Boolean(runtimePlan && planHasVerifiedRuntime(runtimePlan));
2670
+ const sessionRuntimeOk = sawServerStart &&
2671
+ (sawServerTail || sawLocalHttpProbe || planRuntimeOk);
2672
+ if (!planRuntimeOk && !sessionRuntimeOk) {
2673
+ const codingPlanFinished = Boolean(runtimePlan &&
2674
+ session.planApproved.value &&
2675
+ runtimePlan.kind !== "pentest" &&
2676
+ runtimePlan.tasks.length > 0 &&
2677
+ runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
2678
+ const freestyleLocalAppDone = !session.planApproved.value &&
2679
+ sawLocalAppMaterialWork &&
2680
+ productiveSteps > 0 &&
2681
+ freestyleClaimsAppReady(cleaned) &&
2682
+ (getActiveProjectRoot() !== undefined ||
2683
+ /\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
2684
+ /\bopen\s+http:\/\/localhost\b/i.test(cleaned));
2685
+ if (codingPlanFinished || freestyleLocalAppDone) {
2686
+ const action = recoveryForRuntimeVerify(getActiveProjectRoot());
2687
+ consumeBudget(recovery, action.budgetKey);
2688
+ pushAssistantHistory(assistantText.visible);
2689
+ messages.push({ role: "user", content: action.message });
2690
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2691
+ continue;
2692
+ }
2400
2693
  }
2401
2694
  }
2402
- // Failed localhost probe (e.g. HTTP 500): model must FIX, not stop.
2403
2695
  if (buildLike &&
2404
2696
  !pentestLike &&
2405
2697
  !pentestSession &&
2406
2698
  sawFailedLocalHttpProbe &&
2407
2699
  !sawLocalHttpProbe &&
2408
- failedProbeFixRetries < 3 &&
2700
+ budgetRemaining(recovery, "failedProbe") &&
2409
2701
  cleaned.trim().length > 0) {
2410
- failedProbeFixRetries += 1;
2702
+ const action = recoveryForFailedProbe();
2703
+ consumeBudget(recovery, action.budgetKey);
2411
2704
  pushAssistantHistory(assistantText.visible);
2412
- messages.push({
2413
- role: "user",
2414
- content: "The local HTTP probe FAILED (4xx/5xx or connection refused) — the app is NOT working yet. " +
2415
- "Do NOT stop. Diagnose from the error (e.g. missing \"use client\", syntax error, wrong port), " +
2416
- "apply a real fix with fs.edit/fs.write, restart/re-probe if needed, and only then mark the verify task done. " +
2417
- "Identifying the error without calling a tool is a failure.",
2418
- });
2419
- writeNotice("warn", "localhost probe failed — forcing fix, not stopping", chalk.yellow(" ⚠ HTTP probe failed — fix the app and re-verify; do not stop at diagnosis\n"));
2705
+ messages.push({ role: "user", content: action.message });
2706
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2707
+ continue;
2708
+ }
2709
+ if ((pentestLike || pentestSession) &&
2710
+ budgetRemaining(recovery, "shallowPentest") &&
2711
+ looksLikeShallowPentestReport(cleaned, {
2712
+ productiveSteps,
2713
+ sawActiveTest: sawActivePentestTest,
2714
+ })) {
2715
+ const action = recoveryForShallowPentest();
2716
+ consumeBudget(recovery, action.budgetKey);
2717
+ pushAssistantHistory(assistantText.visible);
2718
+ messages.push({ role: "user", content: action.message });
2719
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2420
2720
  continue;
2421
2721
  }
2422
- // Premature-completion guard (approved plan still has work)
2423
- // If the user approved a plan and the model now gives a final answer
2424
- // while tasks are still pending/in_progress — without having run the
2425
- // work — it is fabricating completion (the exact "all tasks completed,
2426
- // running at localhost:5173" failure). Force it back to executing the
2427
- // next real task instead of accepting the false claim.
2428
- // Budget: 6 retries (resets when real work succeeds) so long builds
2429
- // with mid-stream "done" claims do not exhaust and stop mid-error.
2430
- if (session.planApproved.value && prematureCompletionRetries < 6) {
2722
+ if (session.planApproved.value &&
2723
+ budgetRemaining(recovery, "prematureComplete")) {
2431
2724
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2432
2725
  const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
2433
2726
  if (livePlan && unfinished && unfinished.length > 0) {
2434
- prematureCompletionRetries += 1;
2435
2727
  const next = unfinished[0];
2436
- 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`));
2437
- pushAssistantHistory(assistantText.visible);
2438
- const isPentestPlan = livePlan.kind === "pentest" || pentestSession;
2439
- let instruction = `Resume now with the NEXT task ${next.id} ("${next.title}"): `;
2440
- if (errorFixNarration) {
2441
- instruction =
2442
- `You identified a failure and must FIX it with a tool call first (fs.edit/fs.write), then continue task ${next.id} ("${next.title}"): `;
2443
- }
2444
- if (isPentestPlan) {
2445
- instruction +=
2446
- `call task.update {taskId:"${next.id}", state:"in_progress"}, then do the recon/testing work ` +
2447
- `(dns/http/net.scan/http.fetch/tool.batch — NOT a local dev server), VERIFY with real tool output, ` +
2448
- `then task.update done. Do NOT shell.start / npm run dev / explore the clai workspace. `;
2449
- }
2450
- else if (next.state === "pending") {
2451
- 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. `;
2452
- }
2453
- else {
2454
- 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"}). `;
2455
- }
2456
- instruction += `Continue task by task until EVERY task is actually finished. Do not stop after only diagnosing an error.`;
2457
- messages.push({
2458
- role: "user",
2459
- content: `You have NOT finished the approved plan: ${unfinished.length} task(s) remain ` +
2460
- `(${unfinished.map((t) => `[${t.id}] ${t.title}`).join("; ")}). ` +
2461
- `Do NOT claim the work is complete unless a tool call actually succeeded and you saw the output. ` +
2462
- instruction,
2728
+ const action = recoveryForPrematureComplete({
2729
+ unfinished,
2730
+ next,
2731
+ pentest: livePlan.kind === "pentest" || pentestSession,
2732
+ errorFix: errorFixNarration,
2463
2733
  });
2734
+ consumeBudget(recovery, action.budgetKey);
2735
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2736
+ pushAssistantHistory(assistantText.visible);
2737
+ messages.push({ role: "user", content: action.message });
2464
2738
  continue;
2465
2739
  }
2466
2740
  }
2467
- // If we still print a final answer while an approved plan has unfinished
2468
- // tasks (retries exhausted), do NOT let a fabricated "it's done" stand
2469
- // unchallenged — append an explicit, honest status so the user knows the
2470
- // build did not actually complete.
2471
- let completionWarning = "";
2472
- let completionWarningText = "";
2741
+ let outcomeStatus = "succeeded";
2742
+ const remainingCriteria = [];
2473
2743
  if (session.planApproved.value) {
2474
2744
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2475
- const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
2476
- if (livePlan && unfinished && unfinished.length > 0) {
2477
- completionWarningText =
2478
- `${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete. ` +
2479
- "The summary above may overstate progress.";
2480
- completionWarning =
2481
- chalk.yellow(`\n ⚠ ${unfinished.length} of ${livePlan.tasks.length} plan task(s) are NOT actually complete:\n`) +
2482
- unfinished
2483
- .map((t) => chalk.yellow(` • [${t.id}] ${t.title}`))
2484
- .join("\n") +
2485
- chalk.dim("\n The summary above may overstate progress. Re-run with /implement, or ask clai to finish the remaining tasks.\n");
2486
- }
2487
- }
2488
- if (cleaned) {
2489
- writeAssistantMessage(cleaned);
2745
+ const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
2746
+ const failedTasks = livePlan?.tasks.filter((t) => t.state === "failed") ?? [];
2747
+ remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
2748
+ if (failedTasks.length > 0)
2749
+ outcomeStatus = "failed";
2750
+ else if (unfinished.length > 0)
2751
+ outcomeStatus = "partial";
2490
2752
  }
2491
- if (completionWarning) {
2492
- writeNotice("warn", completionWarningText, completionWarning);
2753
+ recordAnswerEvidence(outcomeState, cleaned);
2754
+ outcomeState.outcome.status = deriveOutcomeStatus(outcomeState.outcome, outcomeState.evidence);
2755
+ await saveOutcomeState(outcomeState);
2756
+ const unsupportedCriteria = outcomeState.outcome.criteria.filter((criterion) => criterion.required &&
2757
+ !validateCriterionEvidence(criterion, outcomeState.evidence).ok);
2758
+ if (unsupportedCriteria.length > 0 && outcomeStatus === "succeeded") {
2759
+ outcomeStatus = "partial";
2493
2760
  }
2494
- await auditLog("agent.final", { provider, model, steps: step + 1 });
2761
+ remainingCriteria.push(...unsupportedCriteria
2762
+ .map((criterion) => criterion.statement)
2763
+ .filter((statement) => !remainingCriteria.includes(statement)));
2764
+ moveTurn("verifying", "evaluating current criterion-linked evidence");
2765
+ moveTurn(outcomeStatus, `turn completed with ${outcomeStatus} evidence status`);
2766
+ await auditLog("agent.final", {
2767
+ provider,
2768
+ model,
2769
+ steps: step + 1,
2770
+ outcomeStatus,
2771
+ remainingCriteria,
2772
+ });
2495
2773
  lastAnswer = cleaned;
2496
- return finishTurn(lastAnswer, step + 1);
2774
+ return finishTurn(lastAnswer, step + 1, outcomeStatus, remainingCriteria, outcomeStatus === "failed"
2775
+ ? "One or more required plan tasks failed."
2776
+ : outcomeStatus === "partial"
2777
+ ? "Required outcome criteria remain unsupported by current evidence."
2778
+ : undefined);
2497
2779
  }
2498
2780
  // A valid primary tool call exists for this fresh model turn. Show any
2499
2781
  // prose / thinking that preceded it, record the assistant message ONCE.
@@ -2538,11 +2820,6 @@ export async function runAgentLoop(prompt, options = {}) {
2538
2820
  let toRun = bound;
2539
2821
  let activeDeferredToolCalls = deferredToolCalls;
2540
2822
  let deferReason = "Cancelled — not executed this turn (deferred or omitted).";
2541
- // A plan must be based on the outputs of prior reconnaissance, never
2542
- // on calls the model merely proposed in the same response. If a model
2543
- // emits plan.create alongside gathering calls, run only the calls
2544
- // before it, then let the next model turn analyse their actual results
2545
- // and emit one standalone plan.create.
2546
2823
  const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
2547
2824
  if (planCallIndex > 0) {
2548
2825
  const deferredCount = bound.length - planCallIndex;
@@ -2571,32 +2848,9 @@ export async function runAgentLoop(prompt, options = {}) {
2571
2848
  `the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
2572
2849
  });
2573
2850
  }
2574
- const MAX_CALLS_PER_MODEL_TURN = 12;
2575
- const omittedCallCount = Math.max(0, toRun.length - MAX_CALLS_PER_MODEL_TURN);
2576
- if (omittedCallCount > 0) {
2577
- toRun = toRun.slice(0, MAX_CALLS_PER_MODEL_TURN);
2578
- activeDeferredToolCalls = activeDeferredToolCalls.slice(0, MAX_CALLS_PER_MODEL_TURN);
2579
- deferReason =
2580
- "Deferred — exceeded max tool calls per model turn; re-prioritise next batch.";
2581
- 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`));
2582
- messages.push({
2583
- role: "system",
2584
- content: `You emitted too many tool calls in one response. Only the first ${MAX_CALLS_PER_MODEL_TURN} were executed; ` +
2585
- `${omittedCallCount} were not run. After reviewing results, issue a small, prioritized next batch.`,
2586
- });
2587
- }
2588
- // X4: if the batch mixes work tools with task.update(in_progress),
2589
- // run the in_progress updates first so the plan gate does not block
2590
- // work that the model intended to open in the same message.
2591
- {
2592
- const isInProgressUpdate = (b) => b.call.name === "task.update" &&
2593
- String(b.call.args?.state ?? "") === "in_progress";
2594
- const updates = toRun.filter(isInProgressUpdate);
2595
- if (updates.length > 0 && updates.length < toRun.length) {
2596
- const rest = toRun.filter((b) => !isInProgressUpdate(b));
2597
- toRun = [...updates, ...rest];
2598
- }
2599
- }
2851
+ // Preserve model/document order. In particular, never move a later
2852
+ // in_progress transition ahead of the preceding work or done receipt;
2853
+ // doing so inverts dependency order and desynchronizes the task pane.
2600
2854
  // Re-index toRun positions for UI callIds[] (0..n-1 this turn).
2601
2855
  toRun = toRun.map((b, index) => ({ ...b, index }));
2602
2856
  const allCalls = toRun.map((b) => b.call);
@@ -2609,16 +2863,11 @@ export async function runAgentLoop(prompt, options = {}) {
2609
2863
  if (allCalls.length > 1) {
2610
2864
  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`));
2611
2865
  }
2612
- // Emit only the calls that will actually execute, after thinking
2613
- // + assistant text so transcript order remains correct.
2614
2866
  for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
2615
2867
  if (!deferred.call.name || deferred.call.name === "…")
2616
2868
  continue;
2617
2869
  writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
2618
2870
  }
2619
- // Dialect-neutral history: full assistant toolCalls (including deferred
2620
- // ids) so providers never see orphan tool_call ids. Missing results are
2621
- // filled with synthetic cancelled messages after the batch.
2622
2871
  if (historyNativeCalls.length) {
2623
2872
  appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
2624
2873
  }
@@ -2629,32 +2878,7 @@ export async function runAgentLoop(prompt, options = {}) {
2629
2878
  .join("\n\n");
2630
2879
  pushAssistantHistory(standardizedContent);
2631
2880
  }
2632
- // Scoped-parallel batch execution
2633
- // The model may emit several calls in one message. We partition them,
2634
- // IN DOCUMENT ORDER, into segments:
2635
- // • A run of consecutive READ-ONLY, safe-classified calls (the same
2636
- // allowlist tool.batch uses) executes CONCURRENTLY — this is where
2637
- // independent lookups within a single task fan out (e.g. whois +
2638
- // dns + http.fetch during recon).
2639
- // • Every other call (plan.create/task.update, and any mutating or
2640
- // confirm-level tool: fs.write*, shell.exec, pkg.install, net.scan)
2641
- // runs ALONE as a sequential barrier.
2642
- // Because task.update is never parallel-safe, it always acts as a
2643
- // barrier: it commits before the work it gates and after the work it
2644
- // closes. That keeps execution strictly task-by-task and eliminates the
2645
- // plan-state races / overlapping writes that a blanket Promise.all
2646
- // caused, while still letting one task's independent lookups run in
2647
- // parallel. A failed independent read-only lookup does not prevent
2648
- // later recon from running; aborts, blocks, and sequential-barrier
2649
- // failures still stop the batch so the model can react safely.
2650
2881
  const scopeForBatch = await loadScope().catch(() => undefined);
2651
- /**
2652
- * Tools that may share a concurrent wave. Pure read-only BATCH_SAFE
2653
- * tools always qualify when classified safe. Heavy discovery wrappers
2654
- * (pentest.recon, net.context) also run concurrent with dns/http so
2655
- * nmap does not serialize the entire recon wave.
2656
- * net.scan stays a barrier when it needs confirm/sudo UX.
2657
- */
2658
2882
  const isParallelSafe = (c) => {
2659
2883
  if (c.name === "pentest.recon" ||
2660
2884
  c.name === "net.context" ||
@@ -2675,19 +2899,10 @@ export async function runAgentLoop(prompt, options = {}) {
2675
2899
  };
2676
2900
  /** Tools whose failure must NOT cancel sibling calls in this turn. */
2677
2901
  const shouldSoftFailTool = (name) => {
2678
- if (name === "tool.batch")
2902
+ if (isBatchSoftFailTool(name))
2679
2903
  return true;
2680
2904
  if (BATCH_SAFE_TOOLS.has(name))
2681
2905
  return true;
2682
- if (name === "pentest.recon" ||
2683
- name === "net.scan" ||
2684
- name === "net.pingSweep" ||
2685
- name === "net.context" ||
2686
- name === "shell.jobs" ||
2687
- name === "shell.tail" ||
2688
- name === "tool.check") {
2689
- return true;
2690
- }
2691
2906
  return false;
2692
2907
  };
2693
2908
  // Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
@@ -2716,17 +2931,40 @@ export async function runAgentLoop(prompt, options = {}) {
2716
2931
  truncatedToolRetries = 0;
2717
2932
  malformedFenceRetries = 0;
2718
2933
  bareToolJsonRetries = 0;
2719
- // Successful real work restores premature-done budget so long builds
2720
- // don't exhaust retries mid-stream and stop after diagnosing an error.
2721
2934
  if (res.ok && isEvidenceWorkTool(res.call.name)) {
2722
- prematureCompletionRetries = 0;
2723
- actionIntentRetries = 0;
2724
- errorFixNarrationRetries = 0;
2935
+ recovery.prematureComplete = 0;
2936
+ recovery.actionIntent = 0;
2937
+ recovery.errorFix = 0;
2725
2938
  }
2726
2939
  if (res.ok && res.call.name === "shell.start")
2727
2940
  sawServerStart = true;
2728
- if (res.ok && res.call.name === "shell.tail")
2941
+ if (res.ok && res.call.name === "shell.tail") {
2729
2942
  sawServerTail = true;
2943
+ const tailOut = res.result.output ?? res.contextOutput ?? "";
2944
+ if (isServerReadyOutput(tailOut)) {
2945
+ sawServerStart = true;
2946
+ sawServerTail = true;
2947
+ }
2948
+ }
2949
+ if (res.ok &&
2950
+ res.call.name === "shell.exec" &&
2951
+ isPortListeningOutput(String(res.call.args.command ?? ""), res.result.output ?? res.contextOutput ?? "")) {
2952
+ sawServerStart = true;
2953
+ }
2954
+ if (res.ok &&
2955
+ (pentestLike || pentestSession) &&
2956
+ (res.call.name === "http.fetch" ||
2957
+ res.call.name === "shell.exec" ||
2958
+ res.call.name === "net.scan" ||
2959
+ res.call.name === "pentest.recon")) {
2960
+ const blob = `${res.call.name} ${JSON.stringify(res.call.args)}`;
2961
+ if (/\b(sqlmap|hydra|nikto|nuclei|ffuf|gobuster|exploit|payload|idor|xss|union\s+select)\b/i.test(blob) ||
2962
+ (res.call.name === "http.fetch" &&
2963
+ typeof res.call.args.method === "string" &&
2964
+ !/^get$/i.test(res.call.args.method))) {
2965
+ sawActivePentestTest = true;
2966
+ }
2967
+ }
2730
2968
  if (res.ok &&
2731
2969
  ((res.call.name === "http.fetch" &&
2732
2970
  /^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
@@ -2740,7 +2978,7 @@ export async function runAgentLoop(prompt, options = {}) {
2740
2978
  else if (localHttpProbeIsSuccess(out)) {
2741
2979
  sawLocalHttpProbe = true;
2742
2980
  sawFailedLocalHttpProbe = false;
2743
- failedProbeFixRetries = 0;
2981
+ recovery.failedProbe = 0;
2744
2982
  }
2745
2983
  else if (res.call.name === "shell.exec" &&
2746
2984
  !localHttpProbeIsFailure(out)) {
@@ -2775,8 +3013,13 @@ export async function runAgentLoop(prompt, options = {}) {
2775
3013
  }
2776
3014
  }
2777
3015
  if (res.call.name === "plan.create" && res.ok) {
2778
- awaitingPlanApproval = true;
2779
3016
  sawPlanCreateOk = true;
3017
+ if (isPlanMode) {
3018
+ awaitingPlanApproval = true;
3019
+ }
3020
+ else {
3021
+ session.planApproved.value = true;
3022
+ }
2780
3023
  }
2781
3024
  if (res.lastAnswer === "Aborted.")
2782
3025
  aborted = true;
@@ -2789,7 +3032,11 @@ export async function runAgentLoop(prompt, options = {}) {
2789
3032
  };
2790
3033
  const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
2791
3034
  for (const group of groups) {
2792
- if (aborted || blocked || failed || awaitingPlanApproval)
3035
+ if (aborted ||
3036
+ blocked ||
3037
+ failed ||
3038
+ awaitingPlanApproval ||
3039
+ governorPauseReason)
2793
3040
  break;
2794
3041
  if (group.length === 1) {
2795
3042
  const call = group[0];
@@ -2839,9 +3086,11 @@ export async function runAgentLoop(prompt, options = {}) {
2839
3086
  ? "Cancelled — earlier tool was blocked or declined."
2840
3087
  : awaitingPlanApproval
2841
3088
  ? "Deferred — waiting for plan approval."
2842
- : failed
2843
- ? "Cancelledearlier tool in this batch failed."
2844
- : "Cancelled — not executed.";
3089
+ : governorPauseReason
3090
+ ? `Deferredprogress governor paused execution: ${governorPauseReason}`
3091
+ : failed
3092
+ ? "Cancelled — earlier tool in this batch failed."
3093
+ : "Cancelled — not executed.";
2845
3094
  const result = {
2846
3095
  ok: false,
2847
3096
  output: reason,
@@ -2871,29 +3120,31 @@ export async function runAgentLoop(prompt, options = {}) {
2871
3120
  }
2872
3121
  fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
2873
3122
  }
2874
- // plan.create is a hard transaction boundary. Its successful handler
2875
- // persists and displays the plan; returning immediately prevents a
2876
- // stale pre-loop activePlan snapshot from nudging a duplicate plan and
2877
- // prevents calls accidentally batched after plan.create from executing
2878
- // before /implement approval.
3123
+ if (deferredPostToolMessages.length > 0) {
3124
+ messages.push(...deferredPostToolMessages.splice(0));
3125
+ }
2879
3126
  if (awaitingPlanApproval) {
2880
3127
  pendingCalls = [];
2881
- return finishTurn("", productiveSteps);
3128
+ outcomeState.outcome.status = "partial";
3129
+ await saveOutcomeState(outcomeState);
3130
+ moveTurn("partial", "draft plan awaits approval");
3131
+ return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
2882
3132
  }
2883
3133
  if (aborted) {
2884
3134
  lastAnswer = "Aborted.";
3135
+ outcomeState.outcome.status = "aborted";
3136
+ await saveOutcomeState(outcomeState);
3137
+ moveTurn("aborted", "turn aborted");
2885
3138
  writeAbort();
2886
- return finishTurn(lastAnswer, productiveSteps);
3139
+ return finishTurn(lastAnswer, productiveSteps, "aborted");
2887
3140
  }
2888
3141
  if (blocked && blockedResult) {
2889
3142
  lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
2890
- return finishTurn(lastAnswer, productiveSteps);
3143
+ outcomeState.outcome.status = "blocked";
3144
+ await saveOutcomeState(outcomeState);
3145
+ moveTurn("blocked", lastAnswer);
3146
+ return finishTurn(lastAnswer, productiveSteps, "blocked");
2891
3147
  }
2892
- // A plain failure just stops the remaining calls; we fall through so
2893
- // the model sees the failed tool's output and decides what to do next.
2894
- // Compact older messages when the running estimate exceeds budget. Uses
2895
- // the model-written summary path (with plan re-injection) — never a
2896
- // mechanical transcript dump.
2897
3148
  await maybeAutoCompact("post-tool-token-budget");
2898
3149
  if (options.onMessages) {
2899
3150
  try {
@@ -2905,16 +3156,20 @@ export async function runAgentLoop(prompt, options = {}) {
2905
3156
  }
2906
3157
  }
2907
3158
  }
2908
- // maxIterations ceiling reached (safety net — normally the step budget
2909
- // gate with user confirmation handles stopping gracefully).
2910
3159
  const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
2911
- writeAssistantMessage(richSummary);
2912
3160
  lastAnswer = richSummary;
2913
- return finishTurn(lastAnswer, productiveSteps);
3161
+ outcomeState.outcome.status = "paused_budget";
3162
+ await saveOutcomeState(outcomeState);
3163
+ moveTurn("paused_budget", "emergency iteration ceiling reached");
3164
+ return finishTurn(lastAnswer, productiveSteps, "paused_budget", ["Continue unfinished work in a subsequent turn."], "The emergency iteration ceiling was reached.");
2914
3165
  }
2915
3166
  catch (error) {
2916
3167
  const isAbort = isAbortError(error, options.signal);
2917
3168
  const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
3169
+ if (isAbort) {
3170
+ writeAbort();
3171
+ return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
3172
+ }
2918
3173
  if (options.onMessages) {
2919
3174
  try {
2920
3175
  options.onMessages(buildTurnHistory(liveMessages, msg));
@@ -2923,10 +3178,6 @@ export async function runAgentLoop(prompt, options = {}) {
2923
3178
  // ignore
2924
3179
  }
2925
3180
  }
2926
- if (isAbort) {
2927
- writeAbort();
2928
- return "Aborted.";
2929
- }
2930
3181
  emit({
2931
3182
  type: "turn-error",
2932
3183
  message: error instanceof Error ? error.message : String(error),
@@ -2934,4 +3185,8 @@ export async function runAgentLoop(prompt, options = {}) {
2934
3185
  throw error;
2935
3186
  }
2936
3187
  }
3188
+ /** Compatibility boundary for callers that still consume rendered text. */
3189
+ export async function runAgentLoop(prompt, options = {}) {
3190
+ return renderTurnOutcome(await runAgentTurn(prompt, options));
3191
+ }
2937
3192
  //# sourceMappingURL=runner.js.map