@pentoshi/clai 3.9.2 → 3.9.3

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