@pentoshi/clai 3.6.0 → 3.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) 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 +309 -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 +1115 -965
  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 +99 -29
  55. package/dist/agent/task-evidence.js +480 -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/index.d.ts +6 -18
  173. package/dist/prompts/index.js +67 -293
  174. package/dist/prompts/index.js.map +1 -1
  175. package/dist/prompts/system.agent.md +242 -0
  176. package/dist/prompts/system.ask.md +56 -0
  177. package/dist/repl/prompt-line.js +0 -36
  178. package/dist/repl/prompt-line.js.map +1 -1
  179. package/dist/repl/slash-commands.d.ts +0 -9
  180. package/dist/repl/slash-commands.js +2 -23
  181. package/dist/repl/slash-commands.js.map +1 -1
  182. package/dist/repl.d.ts +5 -0
  183. package/dist/repl.js +169 -55
  184. package/dist/repl.js.map +1 -1
  185. package/dist/safety/classifier.js +17 -9
  186. package/dist/safety/classifier.js.map +1 -1
  187. package/dist/safety/engagement-policy.d.ts +46 -0
  188. package/dist/safety/engagement-policy.js +198 -0
  189. package/dist/safety/engagement-policy.js.map +1 -0
  190. package/dist/store/engagement.d.ts +115 -0
  191. package/dist/store/engagement.js +278 -0
  192. package/dist/store/engagement.js.map +1 -0
  193. package/dist/store/logs.d.ts +5 -0
  194. package/dist/store/logs.js +52 -2
  195. package/dist/store/logs.js.map +1 -1
  196. package/dist/store/plan.d.ts +57 -5
  197. package/dist/store/plan.js +182 -8
  198. package/dist/store/plan.js.map +1 -1
  199. package/dist/store/scope.d.ts +4 -1
  200. package/dist/store/scope.js +2 -2
  201. package/dist/store/scope.js.map +1 -1
  202. package/dist/tools/definitions.js +70 -1
  203. package/dist/tools/definitions.js.map +1 -1
  204. package/dist/tools/elevated-shell.d.ts +60 -0
  205. package/dist/tools/elevated-shell.js +233 -0
  206. package/dist/tools/elevated-shell.js.map +1 -0
  207. package/dist/tools/file-diff.d.ts +125 -0
  208. package/dist/tools/file-diff.js +495 -0
  209. package/dist/tools/file-diff.js.map +1 -0
  210. package/dist/tools/fs.d.ts +11 -13
  211. package/dist/tools/fs.js +269 -78
  212. package/dist/tools/fs.js.map +1 -1
  213. package/dist/tools/http.d.ts +15 -0
  214. package/dist/tools/http.js +202 -61
  215. package/dist/tools/http.js.map +1 -1
  216. package/dist/tools/jobs.d.ts +71 -10
  217. package/dist/tools/jobs.js +403 -124
  218. package/dist/tools/jobs.js.map +1 -1
  219. package/dist/tools/nmap-runner.d.ts +11 -0
  220. package/dist/tools/nmap-runner.js +115 -74
  221. package/dist/tools/nmap-runner.js.map +1 -1
  222. package/dist/tools/pentest-workflows.d.ts +8 -0
  223. package/dist/tools/pentest-workflows.js +71 -0
  224. package/dist/tools/pentest-workflows.js.map +1 -0
  225. package/dist/tools/registry.d.ts +9 -0
  226. package/dist/tools/registry.js +227 -56
  227. package/dist/tools/registry.js.map +1 -1
  228. package/dist/tools/shell.d.ts +3 -0
  229. package/dist/tools/shell.js +35 -13
  230. package/dist/tools/shell.js.map +1 -1
  231. package/dist/tools/tool-types.d.ts +11 -0
  232. package/dist/tui-v2/app/App.js +25 -39
  233. package/dist/tui-v2/app/App.js.map +1 -1
  234. package/dist/tui-v2/app/command-handlers.js +20 -1
  235. package/dist/tui-v2/app/command-handlers.js.map +1 -1
  236. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  237. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  238. package/dist/tui-v2/app/commands/session-commands.js +5 -0
  239. package/dist/tui-v2/app/commands/session-commands.js.map +1 -1
  240. package/dist/tui-v2/app/plan-lifecycle.d.ts +25 -3
  241. package/dist/tui-v2/app/plan-lifecycle.js +87 -24
  242. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  243. package/dist/tui-v2/bootstrap/patch-opentui-text.d.ts +6 -0
  244. package/dist/tui-v2/bootstrap/patch-opentui-text.js +53 -0
  245. package/dist/tui-v2/bootstrap/patch-opentui-text.js.map +1 -0
  246. package/dist/tui-v2/bootstrap/start-tui-v2.js +7 -0
  247. package/dist/tui-v2/bootstrap/start-tui-v2.js.map +1 -1
  248. package/dist/tui-v2/components/jobs/jobs-panel.js +23 -14
  249. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  250. package/dist/tui-v2/components/modal/confirm-modal.d.ts +1 -1
  251. package/dist/tui-v2/components/modal/confirm-modal.js +18 -7
  252. package/dist/tui-v2/components/modal/confirm-modal.js.map +1 -1
  253. package/dist/tui-v2/components/overlay/overlay-host.js +1 -1
  254. package/dist/tui-v2/components/overlay/overlay-host.js.map +1 -1
  255. package/dist/tui-v2/components/pager/pager.d.ts +6 -0
  256. package/dist/tui-v2/components/pager/pager.js +313 -67
  257. package/dist/tui-v2/components/pager/pager.js.map +1 -1
  258. package/dist/tui-v2/components/status/status-line.d.ts +9 -0
  259. package/dist/tui-v2/components/status/status-line.js +17 -3
  260. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  261. package/dist/tui-v2/components/transcript/assistant-message.js +1 -1
  262. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  263. package/dist/tui-v2/components/transcript/compacted-row.js +1 -1
  264. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  265. package/dist/tui-v2/components/transcript/tool-card.d.ts +9 -1
  266. package/dist/tui-v2/components/transcript/tool-card.js +114 -9
  267. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  268. package/dist/tui-v2/components/transcript/transcript-row.js +2 -2
  269. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  270. package/dist/tui-v2/composer/composer-editor.js +16 -0
  271. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  272. package/dist/tui-v2/controllers/overlay-controller.d.ts +16 -2
  273. package/dist/tui-v2/controllers/overlay-controller.js +92 -12
  274. package/dist/tui-v2/controllers/overlay-controller.js.map +1 -1
  275. package/dist/tui-v2/rendering/artifact-pager-source.d.ts +19 -0
  276. package/dist/tui-v2/rendering/artifact-pager-source.js +103 -0
  277. package/dist/tui-v2/rendering/artifact-pager-source.js.map +1 -0
  278. package/dist/tui-v2/rendering/file-diff-view.d.ts +63 -0
  279. package/dist/tui-v2/rendering/file-diff-view.js +263 -0
  280. package/dist/tui-v2/rendering/file-diff-view.js.map +1 -0
  281. package/dist/tui-v2/rendering/open-tool-output.d.ts +9 -1
  282. package/dist/tui-v2/rendering/open-tool-output.js +65 -12
  283. package/dist/tui-v2/rendering/open-tool-output.js.map +1 -1
  284. package/dist/tui-v2/rendering/pager-chrome.d.ts +12 -0
  285. package/dist/tui-v2/rendering/pager-chrome.js +56 -0
  286. package/dist/tui-v2/rendering/pager-chrome.js.map +1 -0
  287. package/dist/tui-v2/rendering/plan-view.js +1 -1
  288. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  289. package/dist/tui-v2/rendering/syntax-highlight.d.ts +37 -0
  290. package/dist/tui-v2/rendering/syntax-highlight.js +1303 -0
  291. package/dist/tui-v2/rendering/syntax-highlight.js.map +1 -0
  292. package/dist/tui-v2/rendering/theme.d.ts +28 -0
  293. package/dist/tui-v2/rendering/theme.js +28 -0
  294. package/dist/tui-v2/rendering/theme.js.map +1 -1
  295. package/dist/tui-v2/rendering/tool-presenter.d.ts +5 -0
  296. package/dist/tui-v2/rendering/tool-presenter.js +35 -3
  297. package/dist/tui-v2/rendering/tool-presenter.js.map +1 -1
  298. package/dist/tui-v2/state/transcript-hydrate.js +5 -0
  299. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  300. package/dist/tui-v2/state/transcript-reducer.js +12 -2
  301. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  302. package/dist/tui-v2/state/transcript-store.d.ts +7 -0
  303. package/dist/tui-v2/state/transcript-store.js +46 -0
  304. package/dist/tui-v2/state/transcript-store.js.map +1 -1
  305. package/dist/tui-v2/state/transcript-types.d.ts +12 -0
  306. package/dist/tui-v2/state/transcript-types.js +9 -0
  307. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  308. package/dist/types.d.ts +17 -1
  309. package/dist/ui/mentions.d.ts +6 -0
  310. package/dist/ui/mentions.js +50 -12
  311. package/dist/ui/mentions.js.map +1 -1
  312. package/dist/ui/plan-pane.js +1 -1
  313. package/dist/ui/plan-pane.js.map +1 -1
  314. package/package.json +16 -11
@@ -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, 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
- }
879
- }
880
- }
881
- }
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
1099
  }
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
1100
  }
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
1101
  }
931
1102
  }
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,23 +1323,32 @@ 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);
1108
1353
  const stallSecs = Math.round(TOOL_STALL_ABORT_MS / 1000);
1109
1354
  let stallTimer;
@@ -1132,6 +1377,33 @@ export async function runAgentLoop(prompt, options = {}) {
1132
1377
  },
1133
1378
  confirmed: true,
1134
1379
  userPrompt: prompt,
1380
+ ...(engagementAction && scope
1381
+ ? {
1382
+ engagementAuthorization: {
1383
+ target: engagementDecision?.normalizedTarget || engagementAction.target,
1384
+ ...(scope.expiresAt ? { expiresAt: scope.expiresAt } : {}),
1385
+ },
1386
+ authorizeNetworkHop: async (url, resolvedAddresses) => {
1387
+ const hop = actionFromUrl({
1388
+ url,
1389
+ method: engagementAction.method,
1390
+ phase: engagementAction.phase,
1391
+ capability: engagementAction.capability,
1392
+ resolvedAddresses,
1393
+ });
1394
+ const hopDecision = evaluateEngagementAction(scope, hop);
1395
+ await auditLog("engagement.policy.hop", {
1396
+ ...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
1397
+ ...(engagementRecord ? { actionId: engagementRecord.id } : {}),
1398
+ url,
1399
+ resolvedAddresses,
1400
+ allowed: hopDecision.allowed,
1401
+ reason: hopDecision.reason,
1402
+ });
1403
+ return { allowed: hopDecision.allowed, reason: hopDecision.reason };
1404
+ },
1405
+ }
1406
+ : {}),
1135
1407
  });
1136
1408
  if (liveBytes > 0) {
1137
1409
  writeToolOutput(toolEventId, "\n", "\n");
@@ -1141,10 +1413,6 @@ export async function runAgentLoop(prompt, options = {}) {
1141
1413
  catch (toolError) {
1142
1414
  jobManager.updateJobStatus(jobId, "failed", 1);
1143
1415
  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
1416
  if (parentSignal.aborted) {
1149
1417
  writeAbort();
1150
1418
  return {
@@ -1171,54 +1439,84 @@ export async function runAgentLoop(prompt, options = {}) {
1171
1439
  finally {
1172
1440
  if (stallTimer)
1173
1441
  clearTimeout(stallTimer);
1442
+ engagementLease?.release();
1174
1443
  parentSignal.removeEventListener("abort", onParentAbort);
1175
1444
  }
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
1445
  if ((call.name === "shell.exec" || call.name === "shell.start") &&
1182
1446
  typeof call.args.command === "string" &&
1183
1447
  isScaffoldCreateCommand(call.args.command)) {
1184
1448
  const cmd = call.args.command;
1185
1449
  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);
1450
+ const out = result.output ?? "";
1451
+ // Prefer path reported by the scaffolder (handles quoted-cd mis-parse leftovers).
1452
+ const fromOutput = out.match(/Scaffolding project in\s+([^\n]+?)\s*\.{0,3}\s*$/im)?.[1]?.trim().replace(/['"]/g, "");
1453
+ const fromScaffold = (fromOutput && fromOutput.startsWith("/")
1454
+ ? fromOutput
1455
+ : undefined) ??
1456
+ extractProjectRootFromScaffold(cmd, cwdArg);
1457
+ const cancelled = isScaffoldCancelledOutput(out);
1458
+ let materialized = scaffoldLooksMaterialized(fromScaffold);
1459
+ // One re-check: create-vite can report success before FS snapshot is visible.
1460
+ if (!materialized && fromScaffold) {
1461
+ materialized = scaffoldLooksMaterialized(fromScaffold);
1462
+ }
1189
1463
  const abortedMid = !result.ok &&
1190
1464
  (result.exitCode === 124 ||
1191
1465
  result.exitCode === 130 ||
1192
- /timed out|aborted|Command aborted/i.test(result.output ?? ""));
1193
- if (result.ok && (cancelled || !materialized)) {
1466
+ /timed out|aborted|Command aborted/i.test(out));
1467
+ const resumableMaterialized = Boolean(fromScaffold && materialized && (cancelled || abortedMid || !result.ok));
1468
+ if (resumableMaterialized && fromScaffold) {
1469
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1470
+ await persistProjectRootOnPlan(fromScaffold);
1471
+ result = {
1472
+ ...result,
1473
+ ok: true,
1474
+ exitCode: 0,
1475
+ output: out +
1476
+ (out.endsWith("\n") ? "" : "\n") +
1477
+ `The scaffold reported ${cancelled ? "cancellation/refusal" : "interruption"}, but a usable project tree already exists at ${fromScaffold} ` +
1478
+ `(package/manifest present). Treat this as resumable: do NOT re-run the scaffolder. ` +
1479
+ `Inspect the existing files, finish any missing install, implement the requested feature, then run/verify.`,
1480
+ };
1481
+ writeNotice("info", `project root → ${fromScaffold} (existing materialized scaffold — continue)`, chalk.dim(` ℹ existing scaffold at ${fromScaffold} — continue, do not re-create\n`));
1482
+ }
1483
+ else if (result.ok && cancelled && !materialized) {
1194
1484
  result = {
1195
1485
  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
- : "") +
1486
+ output: out +
1487
+ (out.endsWith("\n") ? "" : "\n") +
1488
+ `Scaffold FAILED: tool reported cancel/refuse. ` +
1489
+ (fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
1202
1490
  `If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
1203
1491
  exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
1204
1492
  };
1205
1493
  }
1494
+ else if (result.ok && !materialized) {
1495
+ // Soft warn only when we truly see no tree — do not flip ok if output
1496
+ // clearly scaffolded (path may still resolve on next tool).
1497
+ const claimedScaffold = /Scaffolding project in\b/i.test(out);
1498
+ if (!claimedScaffold) {
1499
+ result = {
1500
+ ok: false,
1501
+ output: out +
1502
+ (out.endsWith("\n") ? "" : "\n") +
1503
+ `Scaffold FAILED: target project tree was not created. ` +
1504
+ (fromScaffold ? `Expected project at ${fromScaffold}. ` : "") +
1505
+ `If the folder already exists, CONTINUE it (do not re-scaffold). Otherwise use a new empty name or hand-write a minimal tree.`,
1506
+ exitCode: result.exitCode && result.exitCode !== 0 ? result.exitCode : 1,
1507
+ };
1508
+ }
1509
+ else if (fromScaffold) {
1510
+ setActiveProjectRootIfValid(fromScaffold, { force: true });
1511
+ await persistProjectRootOnPlan(fromScaffold);
1512
+ writeNotice("info", `project root → ${fromScaffold} (scaffold output claimed success — continue)`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1513
+ }
1514
+ }
1206
1515
  else if (result.ok && fromScaffold && materialized) {
1207
1516
  setActiveProjectRootIfValid(fromScaffold, { force: true });
1517
+ await persistProjectRootOnPlan(fromScaffold);
1208
1518
  writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1209
1519
  }
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
1520
  }
1223
1521
  const output = result.output.trim();
1224
1522
  // Always keep a full on-disk copy of tool output (any size) so the
@@ -1245,21 +1543,177 @@ export async function runAgentLoop(prompt, options = {}) {
1245
1543
  exitCode: result.exitCode,
1246
1544
  output: result.output.slice(0, 4_000),
1247
1545
  });
1546
+ if (engagementGraph && engagementRecord) {
1547
+ if (result.backgroundJob) {
1548
+ const checkpointInput = {
1549
+ jobId: result.backgroundJob.id,
1550
+ status: result.backgroundJob.status,
1551
+ artifactPath: result.backgroundJob.artifactPath,
1552
+ offset: result.backgroundJob.nextOffset ?? 0,
1553
+ observation: result.output.slice(0, 16_000),
1554
+ };
1555
+ const reconciled = reconcileEngagementJob(engagementGraph, checkpointInput);
1556
+ if (!reconciled || reconciled.actionId !== engagementRecord.id) {
1557
+ recordEngagementCheckpoint(engagementGraph, {
1558
+ actionId: engagementRecord.id,
1559
+ ...checkpointInput,
1560
+ });
1561
+ }
1562
+ }
1563
+ else {
1564
+ finishEngagementAction(engagementGraph, engagementRecord.id, {
1565
+ ok: result.ok,
1566
+ observation: result.output.slice(0, 16_000),
1567
+ ...(savedOutputPath ? { artifactPath: savedOutputPath } : {}),
1568
+ scannerLead: call.name === "net.scan" || call.name.startsWith("pentest."),
1569
+ });
1570
+ }
1571
+ await saveEngagement(engagementGraph);
1572
+ }
1573
+ const newEvidence = recordToolEvidence(outcomeState, {
1574
+ tool: call.name,
1575
+ callId: toolEventId,
1576
+ ok: result.ok,
1577
+ output: result.output,
1578
+ ...(savedOutputPath ? { artifact: savedOutputPath } : {}),
1579
+ ...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
1580
+ args: call.args,
1581
+ });
1582
+ let hypothesisDelta = 0;
1583
+ if (!result.ok) {
1584
+ const before = outcomeState.failedHypotheses.length;
1585
+ recordFailedHypothesis(outcomeState, {
1586
+ signature: `${call.name}:${result.exitCode ?? 1}`,
1587
+ premise: `${call.name} with ${JSON.stringify(call.args).slice(0, 1_000)}`,
1588
+ });
1589
+ hypothesisDelta = outcomeState.failedHypotheses.length - before;
1590
+ retryDependenciesChanged = false;
1591
+ retryEnvironmentChanged = false;
1592
+ moveTurn("exploring", `${call.name} failed; revise the premise`);
1593
+ }
1594
+ else {
1595
+ const mutatesDependencies = /^(?:fs\.(?:write|writeMany|edit|replaceLines|append|delete)|pkg\.install)$/.test(call.name) ||
1596
+ ((call.name === "shell.exec" || call.name === "shell.start") &&
1597
+ /\b(?:install|mkdir|create|generate|build)\b/i.test(String(call.args.command ?? "")));
1598
+ retryDependenciesChanged ||= mutatesDependencies;
1599
+ retryEnvironmentChanged ||=
1600
+ call.name === "pkg.install" ||
1601
+ ((call.name === "shell.exec" || call.name === "shell.start") &&
1602
+ isPackageInstallCommand(String(call.args.command ?? "")));
1603
+ }
1604
+ const governed = governProgress(governorState, "activity", {
1605
+ evidenceDelta: newEvidence.length,
1606
+ hypothesisDelta,
1607
+ repetitionScore: loopGuard.getAttemptCount(call.name, call.args) > 1 ? 1 : 0,
1608
+ policy: {
1609
+ resourceEnvelope: Math.max(12, maxSteps),
1610
+ emergencyCeiling: Math.max(70, maxSteps * 3),
1611
+ reflectionAfterNoDelta: 3,
1612
+ pauseAfterNoDelta: 6,
1613
+ repetitionThreshold: 0.8,
1614
+ },
1615
+ });
1616
+ governorState = governed.state;
1617
+ if (governed.recommendation === "reflect") {
1618
+ deferredPostToolMessages.push({
1619
+ role: "system",
1620
+ content: `PROGRESS GOVERNOR: ${governed.reason}. Reassess the current premise and choose the next action that can produce criterion-linked evidence.`,
1621
+ });
1622
+ }
1623
+ else if (governed.recommendation === "paused_budget") {
1624
+ governorPauseReason = governed.reason;
1625
+ }
1626
+ await saveOutcomeState(outcomeState);
1248
1627
  loopGuard.recordAttempt(step, call.name, call.args, result.ok, result.exitCode);
1249
1628
  // Evidence for verify-before-done: only successful real work counts.
1250
1629
  if (result.ok && isEvidenceWorkTool(call.name)) {
1251
1630
  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);
1631
+ // Never credit whichever task happens to be open after execution: a
1632
+ // later task.update in the same batch may already have changed it.
1633
+ const creditId = dispatchedTaskId;
1634
+ const cmd = typeof call.args.command === "string" ? call.args.command : "";
1635
+ const signals = {};
1636
+ if (isFeatureImplementationCall(call))
1637
+ signals.featureWrite = true;
1638
+ if (call.name === "fs.write" ||
1639
+ call.name === "fs.writeMany" ||
1640
+ call.name === "fs.edit" ||
1641
+ call.name === "fs.replaceLines" ||
1642
+ call.name === "fs.append") {
1643
+ signals.sourceWrite = true;
1644
+ }
1645
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1646
+ isPackageInstallCommand(cmd)) {
1647
+ signals.installOk = true;
1648
+ }
1649
+ if ((call.name === "shell.exec" || call.name === "shell.start") &&
1650
+ isScaffoldCreateCommand(cmd)) {
1651
+ signals.scaffoldOk = true;
1652
+ }
1653
+ if (isDevServerCall(call))
1654
+ signals.devServerStart = true;
1655
+ const out = result.output ?? "";
1656
+ if ((call.name === "shell.tail" || call.name === "shell.start") &&
1657
+ isServerReadyOutput(out)) {
1658
+ signals.serverReady = true;
1659
+ }
1660
+ if (call.name === "shell.exec" &&
1661
+ isPortListeningOutput(cmd, out)) {
1662
+ signals.portListening = true;
1663
+ }
1664
+ if (localHttpProbeIsSuccess(out) ||
1665
+ (sawLocalHttpProbe && !sawFailedLocalHttpProbe)) {
1666
+ // Prefer explicit success parse on this result
1667
+ if (/\b(localhost|127\.0\.0\.1)\b/i.test(`${call.name} ${cmd} ${JSON.stringify(call.args)}`)) {
1668
+ if (localHttpProbeIsSuccess(out)) {
1669
+ signals.localHttpProbeOk = true;
1670
+ }
1671
+ }
1672
+ }
1673
+ // Remote/pentest evidence — never conflate with local app runtime
1674
+ if (isRemoteReconToolCall(call))
1675
+ signals.remoteReconOk = true;
1676
+ if (isRemoteActiveTestCall(call))
1677
+ signals.remoteActiveTestOk = true;
1678
+ // Always bank the success for later absorb (preflight / no open task).
1679
+ sessionLooseWork.push({
1680
+ toolName: call.name,
1681
+ ...(Object.keys(signals).length > 0 ? { signals } : {}),
1682
+ });
1683
+ taskWorkLedger = recordTaskWorkSuccess(taskWorkLedger, creditId, call.name, signals);
1684
+ // If nothing was open, still try to attach to the next ready explore
1685
+ // task so "Check Node/npm" can complete without thrash.
1686
+ if ((!creditId || !taskWorkLedger || taskWorkLedger.taskId !== creditId) && liveAfter) {
1687
+ const ready = readyPlanTasks(liveAfter)[0];
1688
+ if (ready) {
1689
+ const absorbed = absorbLooseWorkIntoLedger(ledgerFromTaskEvidence(ready.id, ready.evidence), ready.id, ready.title, [{ toolName: call.name, signals }], { planKind: liveAfter.kind });
1690
+ if (absorbed && absorbed.successWorkCount > 0) {
1691
+ const task = liveAfter.tasks.find((t) => t.id === ready.id);
1692
+ if (task) {
1693
+ task.evidence = taskEvidenceFromLedger(absorbed);
1694
+ if (!taskWorkLedger ||
1695
+ taskWorkLedger.taskId !== ready.id ||
1696
+ taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
1697
+ taskWorkLedger = absorbed;
1698
+ }
1699
+ await savePlan(liveAfter).catch(() => undefined);
1700
+ }
1701
+ }
1702
+ }
1703
+ }
1704
+ if (liveAfter && creditId && taskWorkLedger?.taskId === creditId) {
1705
+ const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
1706
+ if (task) {
1707
+ task.evidence = taskEvidenceFromLedger(taskWorkLedger);
1708
+ await savePlan(liveAfter).catch(() => undefined);
1709
+ }
1710
+ }
1711
+ refreshSessionState(liveAfter);
1254
1712
  }
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
1713
  if (!result.ok) {
1260
1714
  const reflection = loopGuard.getFailureReflection();
1261
1715
  if (reflection) {
1262
- messages.push({ role: "system", content: reflection });
1716
+ deferredPostToolMessages.push({ role: "system", content: reflection });
1263
1717
  const failCount = loopGuard.consecutiveFailureCount();
1264
1718
  writeNotice("warn", `${failCount} consecutive failures — model evaluating approach`, chalk.yellow(` ⚠ ${failCount} consecutive failures — evaluating approach\n`));
1265
1719
  }
@@ -1298,9 +1752,6 @@ export async function runAgentLoop(prompt, options = {}) {
1298
1752
  artifactPath: savedOutputPath,
1299
1753
  summary: contextOutput,
1300
1754
  });
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
1755
  if (writesDirectly && savedOutputPath) {
1305
1756
  const short = chalk.dim(` saved ${savedOutputPath}\n`);
1306
1757
  process.stdout.write(short);
@@ -1312,15 +1763,6 @@ export async function runAgentLoop(prompt, options = {}) {
1312
1763
  }
1313
1764
  return { ok: result.ok, call, result, contextOutput };
1314
1765
  }
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
1766
  const AUTO_COMPACT_KEEP_RECENT = 6;
1325
1767
  let lastCompactionMsgCount = 0;
1326
1768
  const summarizeForCompaction = async (summaryPrompt) => {
@@ -1328,10 +1770,7 @@ export async function runAgentLoop(prompt, options = {}) {
1328
1770
  provider,
1329
1771
  model,
1330
1772
  messages: [
1331
- {
1332
- role: "system",
1333
- content: "You compress conversation history into an accurate, concise continuation memory for another assistant.",
1334
- },
1773
+ { role: "system", content: COMPACTION_SYSTEM_PROMPT },
1335
1774
  { role: "user", content: summaryPrompt },
1336
1775
  ],
1337
1776
  temperature: 0.1,
@@ -1367,6 +1806,8 @@ export async function runAgentLoop(prompt, options = {}) {
1367
1806
  content: planContextMessage(livePlan, session.planApproved.value),
1368
1807
  });
1369
1808
  }
1809
+ // Re-inject live SESSION STATE after compaction (older flags survive).
1810
+ refreshSessionState(livePlan);
1370
1811
  lastCompactionMsgCount = messages.length;
1371
1812
  // Final count the model actually receives (may include re-injected plan).
1372
1813
  const afterTokens = estimateMessagesTokens(messages);
@@ -1375,10 +1816,6 @@ export async function runAgentLoop(prompt, options = {}) {
1375
1816
  estimatedTokens: afterTokens,
1376
1817
  reason,
1377
1818
  });
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
1819
  const insertedSummary = messages.find((m) => m.role === "system" &&
1383
1820
  m.content.startsWith(COMPACTION_MEMORY_PREFIX))?.content ?? "";
1384
1821
  const summaryText = insertedSummary.startsWith(`${COMPACTION_MEMORY_PREFIX}\n\n`)
@@ -1404,84 +1841,32 @@ export async function runAgentLoop(prompt, options = {}) {
1404
1841
  }
1405
1842
  }
1406
1843
  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
1844
  visibleCommitted = false;
1411
1845
  // `step` is the productive-step index (used for display + audit). It only
1412
1846
  // advances when the previous iteration actually executed a tool.
1413
1847
  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
- }
1848
+ if (governorPauseReason) {
1849
+ const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
1850
+ outcomeState.outcome.status = "paused_budget";
1851
+ await saveOutcomeState(outcomeState);
1852
+ moveTurn("paused_budget", governorPauseReason);
1853
+ lastAnswer = richSummary;
1854
+ return finishTurn(lastAnswer, productiveSteps, "paused_budget", outcomeState.outcome.criteria
1855
+ .filter((criterion) => criterion.required && criterion.status !== "proven")
1856
+ .map((criterion) => criterion.statement), governorPauseReason);
1460
1857
  }
1461
1858
  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
1859
  let call;
1465
1860
  let assistantText;
1466
1861
  let recoveredFromBareJson = false;
1467
1862
  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
1863
  call = pendingCalls.shift();
1472
1864
  assistantText = { visible: "", thinkContent: "", hasThinking: false };
1473
1865
  const batchStatus = ` ↳ continuing batch (${pendingCalls.length} more queued)\n`;
1474
1866
  writeStatus(batchStatus, chalk.dim(batchStatus));
1475
1867
  }
1476
1868
  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
1869
  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
1870
  const streamLabel = step === 0 ? "waiting for model" : `step ${step + 1}`;
1486
1871
  let spinner = writesDirectly
1487
1872
  ? startThinkingSpinner(streamLabel, options.signal)
@@ -1496,9 +1881,6 @@ export async function runAgentLoop(prompt, options = {}) {
1496
1881
  let accumulatedText = "";
1497
1882
  const callIds = [];
1498
1883
  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
1884
  const deferredToolCalls = [];
1503
1885
  const deltaParser = writesDirectly
1504
1886
  ? undefined
@@ -1518,7 +1900,7 @@ export async function runAgentLoop(prompt, options = {}) {
1518
1900
  if (messages[0]?.role === "system") {
1519
1901
  messages[0] = {
1520
1902
  role: "system",
1521
- content: buildSystemContent(nativeToolsActive),
1903
+ content: composeCurrentSystemPrompt(nativeToolsActive),
1522
1904
  };
1523
1905
  }
1524
1906
  const turnTools = selectToolDefs(nativeToolsActive, useCompactSystemPrompt);
@@ -1530,30 +1912,13 @@ export async function runAgentLoop(prompt, options = {}) {
1530
1912
  dialect: toolDialect,
1531
1913
  step,
1532
1914
  });
1915
+ assertValidToolProtocol(messages);
1533
1916
  completion = await streamWithProvider({
1534
1917
  provider,
1535
1918
  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
1919
  allowModelFallback: false,
1541
1920
  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
1921
  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
1922
  maxTokens: 32_768,
1558
1923
  signal: options.signal,
1559
1924
  thinking: retryWithoutThinking
@@ -1656,9 +2021,6 @@ export async function runAgentLoop(prompt, options = {}) {
1656
2021
  }
1657
2022
  }
1658
2023
  }
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
2024
  if (!sawReasoning && /<think/i.test(token)) {
1663
2025
  sawReasoning = true;
1664
2026
  inThinking = true;
@@ -1671,11 +2033,6 @@ export async function runAgentLoop(prompt, options = {}) {
1671
2033
  spinner.setLabel("generating response (0 tokens)");
1672
2034
  generatedTokens = 0;
1673
2035
  }
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
2036
  if (inThinking) {
1680
2037
  const cleaned = token.replace(/<\/?think[^>]*>/gi, "");
1681
2038
  if (cleaned) {
@@ -1711,13 +2068,6 @@ export async function runAgentLoop(prompt, options = {}) {
1711
2068
  (toolsAttached && !isTextOnlyModel(provider, model));
1712
2069
  const assistantTextResult = rememberThinkingFromText(completion.text);
1713
2070
  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
2071
  if (assistantText.hasThinking) {
1722
2072
  writeThinkingBlock(assistantText.thinkContent);
1723
2073
  }
@@ -1780,11 +2130,6 @@ export async function runAgentLoop(prompt, options = {}) {
1780
2130
  }
1781
2131
  }
1782
2132
  }
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
2133
  if (nativeToolCalls.length) {
1789
2134
  const first = nativeToolCalls[0];
1790
2135
  if (first.args?._parseError) {
@@ -1807,12 +2152,6 @@ export async function runAgentLoop(prompt, options = {}) {
1807
2152
  }
1808
2153
  }
1809
2154
  }
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
2155
  if (looksLikePromptLeak(assistantText.visible)) {
1817
2156
  if (call || nativeToolCalls.length) {
1818
2157
  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 +2160,6 @@ export async function runAgentLoop(prompt, options = {}) {
1821
2160
  nativeToolCalls = [];
1822
2161
  deferredToolCalls.length = 0;
1823
2162
  }
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
2163
  if (nativeToolCalls.length) {
1829
2164
  // Only salvage when args failed to parse (truncated JSON). A clean
1830
2165
  // parse with finish_reason=length is a complete tool call — execute it.
@@ -1887,14 +2222,6 @@ export async function runAgentLoop(prompt, options = {}) {
1887
2222
  }
1888
2223
  }
1889
2224
  }
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
2225
  if (!assistantText.visible.trim() && !call) {
1899
2226
  emptyVisibleRetries += 1;
1900
2227
  if (emptyVisibleRetries <= 3) {
@@ -1912,10 +2239,10 @@ export async function runAgentLoop(prompt, options = {}) {
1912
2239
  ? toolsAttached
1913
2240
  ? "No visible output. This is current or scheduled information: call web.search now. Do NOT answer from memory."
1914
2241
  : "No visible output. This is current or scheduled information: emit exactly one valid ```tool block for web.search now. Do NOT answer from memory or hide the tool call in <think> tags."
1915
- : buildLikeTurn && !activePlan
2242
+ : isPlanMode && !activePlan
1916
2243
  ? 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. " +
2244
+ ? "No visible output. In plan mode: gather context or call plan.create when ready (do not only describe the plan)."
2245
+ : "No visible output. In plan mode: emit a ```tool block for research/recon or plan.create. " +
1919
2246
  "Do NOT hide tool calls in <think> tags — put them in the visible response."
1920
2247
  : toolsAttached
1921
2248
  ? "No visible output. " + toolNudge(true)
@@ -1924,8 +2251,6 @@ export async function runAgentLoop(prompt, options = {}) {
1924
2251
  messages.push(recoveryUserMessage(buildNudge));
1925
2252
  continue;
1926
2253
  }
1927
- // Exhausted retries — surface a clear notice and exit the turn instead
1928
- // of falling through and triggering premature-completion loops.
1929
2254
  writeNotice("warn", "model returned an empty response after retries — no answer produced", chalk.yellow(" ⚠ model returned an empty response after retries — no answer produced\n"));
1930
2255
  return finishTurn("Model returned an empty response after retries.", step + 1);
1931
2256
  }
@@ -1934,12 +2259,6 @@ export async function runAgentLoop(prompt, options = {}) {
1934
2259
  emptyVisibleRetries = 0;
1935
2260
  retryWithoutThinking = false;
1936
2261
  }
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
2262
  let bareArgsOnly = false;
1944
2263
  recoveredFromBareJson = false;
1945
2264
  if (!call) {
@@ -1975,16 +2294,13 @@ export async function runAgentLoop(prompt, options = {}) {
1975
2294
  ? " ⚠ tool call missing its name — asking the model to call a tool properly\n"
1976
2295
  : " ⚠ tool call missing its name/fence — asking the model to re-emit a proper ```tool block\n"));
1977
2296
  pushAssistantHistory(assistantText.visible);
1978
- messages.push(recoveryUserMessage(buildLikeTurn && !activePlan
2297
+ messages.push(recoveryUserMessage(isPlanMode && !activePlan
1979
2298
  ? toolsAttached
1980
2299
  ? "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."
2300
+ "In plan mode: call plan.create (or research tools) via the platform tool interface."
1983
2301
  : "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."
2302
+ "In plan mode, call plan.create with a proper ```tool block when ready, e.g.:\n" +
2303
+ '```tool\n{"name":"plan.create","args":{"goal":"…","detail":"…","tasks":["…"],"kind":"coding"}}\n```'
1988
2304
  : toolsAttached
1989
2305
  ? "Your previous message was a bare JSON args object with no tool name, so NOTHING ran. " +
1990
2306
  toolNudge(true) +
@@ -1998,10 +2314,6 @@ export async function runAgentLoop(prompt, options = {}) {
1998
2314
  }
1999
2315
  // Exhausted retries — fall through to the normal answer path.
2000
2316
  }
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
2317
  if (/<\|tool_call(?:s_section)?_begin\|>|<\|tool_call_argument_begin\|>/i.test(assistantText.visible)) {
2006
2318
  writeNotice("warn", "tool call was malformed or cut off — asking the model to retry in JSON form", chalk.yellow(" ⚠ tool call was malformed or cut off — asking the model to retry in JSON form\n"));
2007
2319
  pushAssistantHistory(assistantText.visible);
@@ -2015,17 +2327,8 @@ export async function runAgentLoop(prompt, options = {}) {
2015
2327
  "Do not use <|tool_call_begin|> markers."));
2016
2328
  continue;
2017
2329
  }
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
2330
  if (looksLikeTruncatedToolCall(assistantText.visible)) {
2025
2331
  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
2332
  const salvaged = salvageTruncatedWrite(assistantText.visible);
2030
2333
  if (salvaged && truncatedToolRetries <= 5) {
2031
2334
  // Write the salvaged partial content
@@ -2085,14 +2388,6 @@ export async function runAgentLoop(prompt, options = {}) {
2085
2388
  // Exhausted retries — fall through so we don't loop forever, but the
2086
2389
  // user at least sees the (broken) output and the stop notice.
2087
2390
  }
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
2391
  const hasFencedCallShape = countToolFences(assistantText.visible) > 0 &&
2097
2392
  /```tool\s*\n[\s\S]*?"(?:name|args)"\s*:/i.test(assistantText.visible);
2098
2393
  if (hasFencedCallShape) {
@@ -2145,43 +2440,21 @@ export async function runAgentLoop(prompt, options = {}) {
2145
2440
  }
2146
2441
  // Exhausted retries — fall through to the normal path.
2147
2442
  }
2148
- // Normal final-answer path: strip any stray sentinel tokens that
2149
- // somehow leaked into prose so the answer renders cleanly.
2150
2443
  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
2444
  const narratedAction = looksLikeActionNarration(cleaned);
2159
2445
  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);
2446
+ const reconciledPlanAtCompletion = await reconcileOpenTaskBeforeFinalizing();
2447
+ const livePlanAtCompletion = reconciledPlanAtCompletion ??
2448
+ (await loadPlan(session.sessionId).catch(() => undefined));
2168
2449
  const planStatusAtCompletion = livePlanAtCompletion?.status ?? activePlan?.status;
2169
2450
  const completedPlanDuringThisTurn = activePlan?.status !== "completed" &&
2170
2451
  planStatusAtCompletion === "completed";
2171
2452
  const planHasOpenWorkNow = planHasOpenWork(planStatusAtCompletion);
2172
- // User-driven reasons to require tools this turn (build/pentest/
2173
- // approved plan / freshness). Idle greetings never qualify.
2174
2453
  const userExpectsWork = freshWebSearchRequired ||
2175
2454
  (planHasOpenWorkNow && session.planApproved.value) ||
2176
2455
  (!informationalQuery &&
2177
2456
  !idleOrSocialPrompt &&
2178
2457
  (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
2458
  const wantsAction = !completedPlanDuringThisTurn &&
2186
2459
  !idleOrSocialPrompt &&
2187
2460
  (userExpectsWork ||
@@ -2191,12 +2464,6 @@ export async function runAgentLoop(prompt, options = {}) {
2191
2464
  !activePlan &&
2192
2465
  looksLikePlanNarration(cleaned);
2193
2466
  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
2467
  const shouldRetryBeforeFinalizing = productiveSteps === 0 ||
2201
2468
  planNarrated ||
2202
2469
  (session.planApproved.value &&
@@ -2206,126 +2473,71 @@ export async function runAgentLoop(prompt, options = {}) {
2206
2473
  (buildLikeTurn && errorFixNarration);
2207
2474
  if (wantsAction &&
2208
2475
  cleaned.trim().length > 0 &&
2209
- actionIntentRetries < 3 &&
2210
2476
  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"));
2477
+ let action;
2478
+ if (errorFixNarration && budgetRemaining(recovery, "errorFix")) {
2479
+ action = recoveryForErrorDiagnosis(toolsAttached);
2224
2480
  }
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"));
2481
+ else if (budgetRemaining(recovery, "actionIntent") &&
2482
+ planHasOpenWorkNow &&
2483
+ session.planApproved.value) {
2484
+ action = recoveryForNarration(toolsAttached, "plan_open");
2230
2485
  }
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"));
2486
+ else if (budgetRemaining(recovery, "actionIntent") &&
2487
+ pentestLikeTurn) {
2488
+ action = recoveryForNarration(toolsAttached, "pentest");
2238
2489
  }
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"));
2490
+ else if (budgetRemaining(recovery, "actionIntent") &&
2491
+ (freshWebSearchRequired || narratedWebAction)) {
2492
+ action = recoveryForNarration(toolsAttached, "web");
2248
2493
  }
2249
- else if (buildLikeTurn &&
2494
+ else if (budgetRemaining(recovery, "actionIntent") &&
2495
+ buildLikeTurn &&
2250
2496
  (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"));
2497
+ action = recoveryForNarration(toolsAttached, "build_plan_prose");
2258
2498
  }
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"));
2499
+ else if (budgetRemaining(recovery, "actionIntent") &&
2500
+ buildLikeTurn) {
2501
+ action = recoveryForNarration(toolsAttached, "build");
2266
2502
  }
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"));
2503
+ else if (budgetRemaining(recovery, "actionIntent")) {
2504
+ action = recoveryForNarration(toolsAttached, "generic");
2505
+ }
2506
+ if (action) {
2507
+ consumeBudget(recovery, action.budgetKey);
2508
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2509
+ pushAssistantHistory(assistantText.visible);
2510
+ messages.push(recoveryUserMessage(action.message));
2511
+ continue;
2275
2512
  }
2276
- pushAssistantHistory(assistantText.visible);
2277
- messages.push(recoveryUserMessage(nudge));
2278
- continue;
2279
2513
  }
2280
2514
  if (freshWebSearchRequired &&
2281
2515
  !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"));
2516
+ budgetRemaining(recovery, "freshnessUsed")) {
2517
+ const action = recoveryForFreshness(freshnessGuardMessage() +
2518
+ (toolsAttached
2519
+ ? " Call the web_search tool now."
2520
+ : " Reply with ONLY a fenced ```tool block for web.search now."));
2521
+ consumeBudget(recovery, action.budgetKey);
2522
+ writeNotice("info", action.notice, chalk.dim(` ℹ ${action.notice}\n`));
2285
2523
  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
- });
2524
+ messages.push({ role: "user", content: action.message });
2293
2525
  continue;
2294
2526
  }
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) {
2527
+ if (isPlanMode &&
2528
+ !informationalQuery &&
2529
+ !idleOrSocialPrompt &&
2530
+ budgetRemaining(recovery, "forcePlan")) {
2306
2531
  const planAtEnd = await loadPlan(session.sessionId).catch(() => undefined);
2307
2532
  if (!planAtEnd && !sawPlanCreateOk) {
2308
- forcePlanRetries += 1;
2533
+ const action = recoveryForMissingPlan(toolsAttached);
2534
+ consumeBudget(recovery, action.budgetKey);
2309
2535
  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"));
2536
+ messages.push({ role: "user", content: action.message });
2537
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2323
2538
  continue;
2324
2539
  }
2325
2540
  }
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
2541
  if (buildLike &&
2330
2542
  !pentestLike &&
2331
2543
  !pentestSession &&
@@ -2334,166 +2546,131 @@ export async function runAgentLoop(prompt, options = {}) {
2334
2546
  !sawFeatureImplWrite &&
2335
2547
  (sawScaffoldOk || sawLocalAppMaterialWork) &&
2336
2548
  productiveSteps > 0 &&
2337
- featureImplRetries < 2) {
2338
- featureImplRetries += 1;
2549
+ budgetRemaining(recovery, "featureImpl")) {
2550
+ const action = recoveryForMissingFeature(getActiveProjectRoot());
2551
+ consumeBudget(recovery, action.budgetKey);
2339
2552
  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"));
2553
+ messages.push({ role: "user", content: action.message });
2554
+ writeNotice("warn", action.notice, chalk.yellow(` ${action.notice}\n`));
2352
2555
  continue;
2353
2556
  }
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
2557
  if (buildLike &&
2361
2558
  !pentestLike &&
2362
2559
  !pentestSession &&
2363
- (!sawServerStart || !sawServerTail || !sawLocalHttpProbe) &&
2364
- runtimeVerificationRetries < 2 &&
2365
- // Feature apps must implement first (handled above); only verify live after that
2560
+ budgetRemaining(recovery, "runtimeVerify") &&
2366
2561
  (!featureAppAsk || sawFeatureImplWrite)) {
2367
2562
  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;
2563
+ // Durable plan evidence or multi-signal proof this turn is enough
2564
+ const planRuntimeOk = Boolean(runtimePlan && planHasVerifiedRuntime(runtimePlan));
2565
+ const sessionRuntimeOk = sawServerStart &&
2566
+ (sawServerTail || sawLocalHttpProbe || planRuntimeOk);
2567
+ if (!planRuntimeOk && !sessionRuntimeOk) {
2568
+ const codingPlanFinished = Boolean(runtimePlan &&
2569
+ session.planApproved.value &&
2570
+ runtimePlan.kind !== "pentest" &&
2571
+ runtimePlan.tasks.length > 0 &&
2572
+ runtimePlan.tasks.every((task) => task.state === "done" || task.state === "skipped"));
2573
+ const freestyleLocalAppDone = !session.planApproved.value &&
2574
+ sawLocalAppMaterialWork &&
2575
+ productiveSteps > 0 &&
2576
+ freestyleClaimsAppReady(cleaned) &&
2577
+ (getActiveProjectRoot() !== undefined ||
2578
+ /\b(?:npm|pnpm|yarn|bun)\s+run\s+dev\b/i.test(cleaned) ||
2579
+ /\bopen\s+http:\/\/localhost\b/i.test(cleaned));
2580
+ if (codingPlanFinished || freestyleLocalAppDone) {
2581
+ const action = recoveryForRuntimeVerify(getActiveProjectRoot());
2582
+ consumeBudget(recovery, action.budgetKey);
2583
+ pushAssistantHistory(assistantText.visible);
2584
+ messages.push({ role: "user", content: action.message });
2585
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2586
+ continue;
2587
+ }
2400
2588
  }
2401
2589
  }
2402
- // Failed localhost probe (e.g. HTTP 500): model must FIX, not stop.
2403
2590
  if (buildLike &&
2404
2591
  !pentestLike &&
2405
2592
  !pentestSession &&
2406
2593
  sawFailedLocalHttpProbe &&
2407
2594
  !sawLocalHttpProbe &&
2408
- failedProbeFixRetries < 3 &&
2595
+ budgetRemaining(recovery, "failedProbe") &&
2409
2596
  cleaned.trim().length > 0) {
2410
- failedProbeFixRetries += 1;
2597
+ const action = recoveryForFailedProbe();
2598
+ consumeBudget(recovery, action.budgetKey);
2411
2599
  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"));
2600
+ messages.push({ role: "user", content: action.message });
2601
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2602
+ continue;
2603
+ }
2604
+ if ((pentestLike || pentestSession) &&
2605
+ budgetRemaining(recovery, "shallowPentest") &&
2606
+ looksLikeShallowPentestReport(cleaned, {
2607
+ productiveSteps,
2608
+ sawActiveTest: sawActivePentestTest,
2609
+ })) {
2610
+ const action = recoveryForShallowPentest();
2611
+ consumeBudget(recovery, action.budgetKey);
2612
+ pushAssistantHistory(assistantText.visible);
2613
+ messages.push({ role: "user", content: action.message });
2614
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2420
2615
  continue;
2421
2616
  }
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) {
2617
+ if (session.planApproved.value &&
2618
+ budgetRemaining(recovery, "prematureComplete")) {
2431
2619
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2432
2620
  const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress");
2433
2621
  if (livePlan && unfinished && unfinished.length > 0) {
2434
- prematureCompletionRetries += 1;
2435
2622
  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,
2623
+ const action = recoveryForPrematureComplete({
2624
+ unfinished,
2625
+ next,
2626
+ pentest: livePlan.kind === "pentest" || pentestSession,
2627
+ errorFix: errorFixNarration,
2463
2628
  });
2629
+ consumeBudget(recovery, action.budgetKey);
2630
+ writeNotice("warn", action.notice, chalk.yellow(` ⚠ ${action.notice}\n`));
2631
+ pushAssistantHistory(assistantText.visible);
2632
+ messages.push({ role: "user", content: action.message });
2464
2633
  continue;
2465
2634
  }
2466
2635
  }
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 = "";
2636
+ let outcomeStatus = "succeeded";
2637
+ const remainingCriteria = [];
2473
2638
  if (session.planApproved.value) {
2474
2639
  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);
2640
+ const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
2641
+ const failedTasks = livePlan?.tasks.filter((t) => t.state === "failed") ?? [];
2642
+ remainingCriteria.push(...unfinished.map((task) => `[${task.id}] ${task.title}`), ...failedTasks.map((task) => `[${task.id}] retry failed task: ${task.title}`));
2643
+ if (failedTasks.length > 0)
2644
+ outcomeStatus = "failed";
2645
+ else if (unfinished.length > 0)
2646
+ outcomeStatus = "partial";
2490
2647
  }
2491
- if (completionWarning) {
2492
- writeNotice("warn", completionWarningText, completionWarning);
2648
+ recordAnswerEvidence(outcomeState, cleaned);
2649
+ outcomeState.outcome.status = deriveOutcomeStatus(outcomeState.outcome, outcomeState.evidence);
2650
+ await saveOutcomeState(outcomeState);
2651
+ const unsupportedCriteria = outcomeState.outcome.criteria.filter((criterion) => criterion.required &&
2652
+ !validateCriterionEvidence(criterion, outcomeState.evidence).ok);
2653
+ if (unsupportedCriteria.length > 0 && outcomeStatus === "succeeded") {
2654
+ outcomeStatus = "partial";
2493
2655
  }
2494
- await auditLog("agent.final", { provider, model, steps: step + 1 });
2656
+ remainingCriteria.push(...unsupportedCriteria
2657
+ .map((criterion) => criterion.statement)
2658
+ .filter((statement) => !remainingCriteria.includes(statement)));
2659
+ moveTurn("verifying", "evaluating current criterion-linked evidence");
2660
+ moveTurn(outcomeStatus, `turn completed with ${outcomeStatus} evidence status`);
2661
+ await auditLog("agent.final", {
2662
+ provider,
2663
+ model,
2664
+ steps: step + 1,
2665
+ outcomeStatus,
2666
+ remainingCriteria,
2667
+ });
2495
2668
  lastAnswer = cleaned;
2496
- return finishTurn(lastAnswer, step + 1);
2669
+ return finishTurn(lastAnswer, step + 1, outcomeStatus, remainingCriteria, outcomeStatus === "failed"
2670
+ ? "One or more required plan tasks failed."
2671
+ : outcomeStatus === "partial"
2672
+ ? "Required outcome criteria remain unsupported by current evidence."
2673
+ : undefined);
2497
2674
  }
2498
2675
  // A valid primary tool call exists for this fresh model turn. Show any
2499
2676
  // prose / thinking that preceded it, record the assistant message ONCE.
@@ -2538,11 +2715,6 @@ export async function runAgentLoop(prompt, options = {}) {
2538
2715
  let toRun = bound;
2539
2716
  let activeDeferredToolCalls = deferredToolCalls;
2540
2717
  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
2718
  const planCallIndex = bound.findIndex((b) => b.call.name === "plan.create");
2547
2719
  if (planCallIndex > 0) {
2548
2720
  const deferredCount = bound.length - planCallIndex;
@@ -2571,32 +2743,9 @@ export async function runAgentLoop(prompt, options = {}) {
2571
2743
  `the follow-on call(s) were not. Wait for the plan to be reviewed, then proceed task by task.`,
2572
2744
  });
2573
2745
  }
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
- }
2746
+ // Preserve model/document order. In particular, never move a later
2747
+ // in_progress transition ahead of the preceding work or done receipt;
2748
+ // doing so inverts dependency order and desynchronizes the task pane.
2600
2749
  // Re-index toRun positions for UI callIds[] (0..n-1 this turn).
2601
2750
  toRun = toRun.map((b, index) => ({ ...b, index }));
2602
2751
  const allCalls = toRun.map((b) => b.call);
@@ -2609,16 +2758,11 @@ export async function runAgentLoop(prompt, options = {}) {
2609
2758
  if (allCalls.length > 1) {
2610
2759
  writeNotice("info", `${allCalls.length} tool calls in this message — running scoped (independent read-only lookups in parallel, everything else in order)`, chalk.dim(` ℹ ${allCalls.length} tool calls — read-only lookups in parallel, the rest in order\n`));
2611
2760
  }
2612
- // Emit only the calls that will actually execute, after thinking
2613
- // + assistant text so transcript order remains correct.
2614
2761
  for (const deferred of activeDeferredToolCalls.slice(0, allCalls.length)) {
2615
2762
  if (!deferred.call.name || deferred.call.name === "…")
2616
2763
  continue;
2617
2764
  writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
2618
2765
  }
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
2766
  if (historyNativeCalls.length) {
2623
2767
  appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
2624
2768
  }
@@ -2629,32 +2773,7 @@ export async function runAgentLoop(prompt, options = {}) {
2629
2773
  .join("\n\n");
2630
2774
  pushAssistantHistory(standardizedContent);
2631
2775
  }
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
2776
  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
2777
  const isParallelSafe = (c) => {
2659
2778
  if (c.name === "pentest.recon" ||
2660
2779
  c.name === "net.context" ||
@@ -2675,19 +2794,10 @@ export async function runAgentLoop(prompt, options = {}) {
2675
2794
  };
2676
2795
  /** Tools whose failure must NOT cancel sibling calls in this turn. */
2677
2796
  const shouldSoftFailTool = (name) => {
2678
- if (name === "tool.batch")
2797
+ if (isBatchSoftFailTool(name))
2679
2798
  return true;
2680
2799
  if (BATCH_SAFE_TOOLS.has(name))
2681
2800
  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
2801
  return false;
2692
2802
  };
2693
2803
  // Recon waves often emit 6–10 lookups; 4 forced a second sequential wave.
@@ -2716,17 +2826,40 @@ export async function runAgentLoop(prompt, options = {}) {
2716
2826
  truncatedToolRetries = 0;
2717
2827
  malformedFenceRetries = 0;
2718
2828
  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
2829
  if (res.ok && isEvidenceWorkTool(res.call.name)) {
2722
- prematureCompletionRetries = 0;
2723
- actionIntentRetries = 0;
2724
- errorFixNarrationRetries = 0;
2830
+ recovery.prematureComplete = 0;
2831
+ recovery.actionIntent = 0;
2832
+ recovery.errorFix = 0;
2725
2833
  }
2726
2834
  if (res.ok && res.call.name === "shell.start")
2727
2835
  sawServerStart = true;
2728
- if (res.ok && res.call.name === "shell.tail")
2836
+ if (res.ok && res.call.name === "shell.tail") {
2729
2837
  sawServerTail = true;
2838
+ const tailOut = res.result.output ?? res.contextOutput ?? "";
2839
+ if (isServerReadyOutput(tailOut)) {
2840
+ sawServerStart = true;
2841
+ sawServerTail = true;
2842
+ }
2843
+ }
2844
+ if (res.ok &&
2845
+ res.call.name === "shell.exec" &&
2846
+ isPortListeningOutput(String(res.call.args.command ?? ""), res.result.output ?? res.contextOutput ?? "")) {
2847
+ sawServerStart = true;
2848
+ }
2849
+ if (res.ok &&
2850
+ (pentestLike || pentestSession) &&
2851
+ (res.call.name === "http.fetch" ||
2852
+ res.call.name === "shell.exec" ||
2853
+ res.call.name === "net.scan" ||
2854
+ res.call.name === "pentest.recon")) {
2855
+ const blob = `${res.call.name} ${JSON.stringify(res.call.args)}`;
2856
+ if (/\b(sqlmap|hydra|nikto|nuclei|ffuf|gobuster|exploit|payload|idor|xss|union\s+select)\b/i.test(blob) ||
2857
+ (res.call.name === "http.fetch" &&
2858
+ typeof res.call.args.method === "string" &&
2859
+ !/^get$/i.test(res.call.args.method))) {
2860
+ sawActivePentestTest = true;
2861
+ }
2862
+ }
2730
2863
  if (res.ok &&
2731
2864
  ((res.call.name === "http.fetch" &&
2732
2865
  /^(?:https?:\/\/)?(?:localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(String(res.call.args.url ?? ""))) ||
@@ -2740,7 +2873,7 @@ export async function runAgentLoop(prompt, options = {}) {
2740
2873
  else if (localHttpProbeIsSuccess(out)) {
2741
2874
  sawLocalHttpProbe = true;
2742
2875
  sawFailedLocalHttpProbe = false;
2743
- failedProbeFixRetries = 0;
2876
+ recovery.failedProbe = 0;
2744
2877
  }
2745
2878
  else if (res.call.name === "shell.exec" &&
2746
2879
  !localHttpProbeIsFailure(out)) {
@@ -2775,8 +2908,13 @@ export async function runAgentLoop(prompt, options = {}) {
2775
2908
  }
2776
2909
  }
2777
2910
  if (res.call.name === "plan.create" && res.ok) {
2778
- awaitingPlanApproval = true;
2779
2911
  sawPlanCreateOk = true;
2912
+ if (isPlanMode) {
2913
+ awaitingPlanApproval = true;
2914
+ }
2915
+ else {
2916
+ session.planApproved.value = true;
2917
+ }
2780
2918
  }
2781
2919
  if (res.lastAnswer === "Aborted.")
2782
2920
  aborted = true;
@@ -2789,7 +2927,11 @@ export async function runAgentLoop(prompt, options = {}) {
2789
2927
  };
2790
2928
  const groups = groupToolCallsForExecution(allCalls, isParallelSafe, PARALLEL_LIMIT);
2791
2929
  for (const group of groups) {
2792
- if (aborted || blocked || failed || awaitingPlanApproval)
2930
+ if (aborted ||
2931
+ blocked ||
2932
+ failed ||
2933
+ awaitingPlanApproval ||
2934
+ governorPauseReason)
2793
2935
  break;
2794
2936
  if (group.length === 1) {
2795
2937
  const call = group[0];
@@ -2839,9 +2981,11 @@ export async function runAgentLoop(prompt, options = {}) {
2839
2981
  ? "Cancelled — earlier tool was blocked or declined."
2840
2982
  : awaitingPlanApproval
2841
2983
  ? "Deferred — waiting for plan approval."
2842
- : failed
2843
- ? "Cancelledearlier tool in this batch failed."
2844
- : "Cancelled — not executed.";
2984
+ : governorPauseReason
2985
+ ? `Deferredprogress governor paused execution: ${governorPauseReason}`
2986
+ : failed
2987
+ ? "Cancelled — earlier tool in this batch failed."
2988
+ : "Cancelled — not executed.";
2845
2989
  const result = {
2846
2990
  ok: false,
2847
2991
  output: reason,
@@ -2871,29 +3015,31 @@ export async function runAgentLoop(prompt, options = {}) {
2871
3015
  }
2872
3016
  fillMissingToolResults(messages, historyNativeCalls, "Cancelled — not executed this turn.");
2873
3017
  }
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.
3018
+ if (deferredPostToolMessages.length > 0) {
3019
+ messages.push(...deferredPostToolMessages.splice(0));
3020
+ }
2879
3021
  if (awaitingPlanApproval) {
2880
3022
  pendingCalls = [];
2881
- return finishTurn("", productiveSteps);
3023
+ outcomeState.outcome.status = "partial";
3024
+ await saveOutcomeState(outcomeState);
3025
+ moveTurn("partial", "draft plan awaits approval");
3026
+ return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
2882
3027
  }
2883
3028
  if (aborted) {
2884
3029
  lastAnswer = "Aborted.";
3030
+ outcomeState.outcome.status = "aborted";
3031
+ await saveOutcomeState(outcomeState);
3032
+ moveTurn("aborted", "turn aborted");
2885
3033
  writeAbort();
2886
- return finishTurn(lastAnswer, productiveSteps);
3034
+ return finishTurn(lastAnswer, productiveSteps, "aborted");
2887
3035
  }
2888
3036
  if (blocked && blockedResult) {
2889
3037
  lastAnswer = blockedResult.lastAnswer || "Blocked or Cancelled.";
2890
- return finishTurn(lastAnswer, productiveSteps);
3038
+ outcomeState.outcome.status = "blocked";
3039
+ await saveOutcomeState(outcomeState);
3040
+ moveTurn("blocked", lastAnswer);
3041
+ return finishTurn(lastAnswer, productiveSteps, "blocked");
2891
3042
  }
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
3043
  await maybeAutoCompact("post-tool-token-budget");
2898
3044
  if (options.onMessages) {
2899
3045
  try {
@@ -2905,16 +3051,20 @@ export async function runAgentLoop(prompt, options = {}) {
2905
3051
  }
2906
3052
  }
2907
3053
  }
2908
- // maxIterations ceiling reached (safety net — normally the step budget
2909
- // gate with user confirmation handles stopping gracefully).
2910
3054
  const richSummary = await buildRichStopSummary(messages, session, productiveSteps);
2911
- writeAssistantMessage(richSummary);
2912
3055
  lastAnswer = richSummary;
2913
- return finishTurn(lastAnswer, productiveSteps);
3056
+ outcomeState.outcome.status = "paused_budget";
3057
+ await saveOutcomeState(outcomeState);
3058
+ moveTurn("paused_budget", "emergency iteration ceiling reached");
3059
+ return finishTurn(lastAnswer, productiveSteps, "paused_budget", ["Continue unfinished work in a subsequent turn."], "The emergency iteration ceiling was reached.");
2914
3060
  }
2915
3061
  catch (error) {
2916
3062
  const isAbort = isAbortError(error, options.signal);
2917
3063
  const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
3064
+ if (isAbort) {
3065
+ writeAbort();
3066
+ return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
3067
+ }
2918
3068
  if (options.onMessages) {
2919
3069
  try {
2920
3070
  options.onMessages(buildTurnHistory(liveMessages, msg));
@@ -2923,10 +3073,6 @@ export async function runAgentLoop(prompt, options = {}) {
2923
3073
  // ignore
2924
3074
  }
2925
3075
  }
2926
- if (isAbort) {
2927
- writeAbort();
2928
- return "Aborted.";
2929
- }
2930
3076
  emit({
2931
3077
  type: "turn-error",
2932
3078
  message: error instanceof Error ? error.message : String(error),
@@ -2934,4 +3080,8 @@ export async function runAgentLoop(prompt, options = {}) {
2934
3080
  throw error;
2935
3081
  }
2936
3082
  }
3083
+ /** Compatibility boundary for callers that still consume rendered text. */
3084
+ export async function runAgentLoop(prompt, options = {}) {
3085
+ return renderTurnOutcome(await runAgentTurn(prompt, options));
3086
+ }
2937
3087
  //# sourceMappingURL=runner.js.map