@pentoshi/clai 3.9.1 → 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 (334) 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 +850 -237
  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/stream-recovery.d.ts +83 -0
  53. package/dist/agent/stream-recovery.js +270 -0
  54. package/dist/agent/stream-recovery.js.map +1 -0
  55. package/dist/agent/task-analyzer.js +7 -3
  56. package/dist/agent/task-analyzer.js.map +1 -1
  57. package/dist/agent/task-evidence.js +2 -0
  58. package/dist/agent/task-evidence.js.map +1 -1
  59. package/dist/agent/task-sync.d.ts +11 -0
  60. package/dist/agent/task-sync.js +31 -0
  61. package/dist/agent/task-sync.js.map +1 -1
  62. package/dist/agent/tool-call-parser.d.ts +6 -13
  63. package/dist/agent/tool-call-parser.js +186 -82
  64. package/dist/agent/tool-call-parser.js.map +1 -1
  65. package/dist/agent/tool-history.d.ts +8 -2
  66. package/dist/agent/tool-history.js +8 -1
  67. package/dist/agent/tool-history.js.map +1 -1
  68. package/dist/agent/turn-outcome.d.ts +4 -1
  69. package/dist/agent/turn-outcome.js +16 -2
  70. package/dist/agent/turn-outcome.js.map +1 -1
  71. package/dist/app/adapters/agent-event-adapter.js +27 -0
  72. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  73. package/dist/app/adapters/current-agent-adapter.js +1 -0
  74. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  75. package/dist/app/adapters/current-jobs-adapter.js +4 -3
  76. package/dist/app/adapters/current-jobs-adapter.js.map +1 -1
  77. package/dist/app/adapters/current-store-adapter.js +4 -3
  78. package/dist/app/adapters/current-store-adapter.js.map +1 -1
  79. package/dist/app/adapters/current-updates-adapter.d.ts +1 -1
  80. package/dist/app/adapters/current-updates-adapter.js +34 -5
  81. package/dist/app/adapters/current-updates-adapter.js.map +1 -1
  82. package/dist/app/adapters/quiet-meta-tools.js +2 -1
  83. package/dist/app/adapters/quiet-meta-tools.js.map +1 -1
  84. package/dist/app/controllers/job-controller.d.ts +4 -3
  85. package/dist/app/controllers/job-controller.js +9 -6
  86. package/dist/app/controllers/job-controller.js.map +1 -1
  87. package/dist/app/controllers/plan-controller.js +14 -0
  88. package/dist/app/controllers/plan-controller.js.map +1 -1
  89. package/dist/app/controllers/responder-settlement.js +2 -2
  90. package/dist/app/controllers/responder-settlement.js.map +1 -1
  91. package/dist/app/controllers/session-compact-helper.d.ts +23 -1
  92. package/dist/app/controllers/session-compact-helper.js +117 -7
  93. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  94. package/dist/app/controllers/session-context-usage.d.ts +24 -0
  95. package/dist/app/controllers/session-context-usage.js +89 -0
  96. package/dist/app/controllers/session-context-usage.js.map +1 -0
  97. package/dist/app/controllers/session-controller.d.ts +18 -17
  98. package/dist/app/controllers/session-controller.js +127 -119
  99. package/dist/app/controllers/session-controller.js.map +1 -1
  100. package/dist/app/controllers/session-prompt-queue.d.ts +3 -0
  101. package/dist/app/controllers/session-prompt-queue.js +20 -1
  102. package/dist/app/controllers/session-prompt-queue.js.map +1 -1
  103. package/dist/app/controllers/session-responder.js +16 -6
  104. package/dist/app/controllers/session-responder.js.map +1 -1
  105. package/dist/app/controllers/session-turn-request.d.ts +18 -0
  106. package/dist/app/controllers/session-turn-request.js +31 -0
  107. package/dist/app/controllers/session-turn-request.js.map +1 -0
  108. package/dist/app/controllers/turn-continuation.d.ts +14 -0
  109. package/dist/app/controllers/turn-continuation.js +46 -0
  110. package/dist/app/controllers/turn-continuation.js.map +1 -0
  111. package/dist/app/events/app-event.d.ts +21 -0
  112. package/dist/app/events/app-event.js +1 -0
  113. package/dist/app/events/app-event.js.map +1 -1
  114. package/dist/app/ports/agent-port.d.ts +7 -0
  115. package/dist/app/ports/jobs-port.d.ts +4 -3
  116. package/dist/app/ports/persistence-port.d.ts +11 -0
  117. package/dist/app/ports/updates-port.d.ts +8 -0
  118. package/dist/attachments/service.js +13 -2
  119. package/dist/attachments/service.js.map +1 -1
  120. package/dist/commands/providers.js +13 -10
  121. package/dist/commands/providers.js.map +1 -1
  122. package/dist/commands/update.d.ts +4 -0
  123. package/dist/commands/update.js +14 -0
  124. package/dist/commands/update.js.map +1 -1
  125. package/dist/llm/adapters/anthropic-tools.d.ts +14 -1
  126. package/dist/llm/adapters/anthropic-tools.js +36 -5
  127. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  128. package/dist/llm/adapters/gemini-tools.d.ts +3 -1
  129. package/dist/llm/adapters/gemini-tools.js +5 -2
  130. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  131. package/dist/llm/adapters/ollama-tools.js +8 -3
  132. package/dist/llm/adapters/ollama-tools.js.map +1 -1
  133. package/dist/llm/adapters/openai-tools.js +8 -1
  134. package/dist/llm/adapters/openai-tools.js.map +1 -1
  135. package/dist/llm/agentrouter.js +2 -0
  136. package/dist/llm/agentrouter.js.map +1 -1
  137. package/dist/llm/anthropic.d.ts +8 -0
  138. package/dist/llm/anthropic.js +68 -9
  139. package/dist/llm/anthropic.js.map +1 -1
  140. package/dist/llm/aws-mantle.js +60 -12
  141. package/dist/llm/aws-mantle.js.map +1 -1
  142. package/dist/llm/bynara.js +2 -0
  143. package/dist/llm/bynara.js.map +1 -1
  144. package/dist/llm/capabilities.d.ts +7 -0
  145. package/dist/llm/capabilities.js +22 -0
  146. package/dist/llm/capabilities.js.map +1 -1
  147. package/dist/llm/gemini.d.ts +3 -0
  148. package/dist/llm/gemini.js +71 -11
  149. package/dist/llm/gemini.js.map +1 -1
  150. package/dist/llm/groq.d.ts +20 -1
  151. package/dist/llm/groq.js +32 -4
  152. package/dist/llm/groq.js.map +1 -1
  153. package/dist/llm/http.d.ts +29 -0
  154. package/dist/llm/http.js +122 -29
  155. package/dist/llm/http.js.map +1 -1
  156. package/dist/llm/kimchi.js +2 -0
  157. package/dist/llm/kimchi.js.map +1 -1
  158. package/dist/llm/nvidia.js +2 -0
  159. package/dist/llm/nvidia.js.map +1 -1
  160. package/dist/llm/ollama.d.ts +5 -0
  161. package/dist/llm/ollama.js +53 -6
  162. package/dist/llm/ollama.js.map +1 -1
  163. package/dist/llm/openai.js +2 -0
  164. package/dist/llm/openai.js.map +1 -1
  165. package/dist/llm/openrouter.js +2 -0
  166. package/dist/llm/openrouter.js.map +1 -1
  167. package/dist/llm/qwen-cloud.js +2 -0
  168. package/dist/llm/qwen-cloud.js.map +1 -1
  169. package/dist/llm/router.d.ts +16 -0
  170. package/dist/llm/router.js +79 -23
  171. package/dist/llm/router.js.map +1 -1
  172. package/dist/llm/sampling.d.ts +32 -0
  173. package/dist/llm/sampling.js +48 -0
  174. package/dist/llm/sampling.js.map +1 -0
  175. package/dist/llm/stream-progress.d.ts +4 -0
  176. package/dist/llm/stream-progress.js +43 -0
  177. package/dist/llm/stream-progress.js.map +1 -0
  178. package/dist/llm/system-messages.d.ts +29 -0
  179. package/dist/llm/system-messages.js +51 -0
  180. package/dist/llm/system-messages.js.map +1 -0
  181. package/dist/llm/token-usage.d.ts +4 -1
  182. package/dist/llm/token-usage.js +56 -4
  183. package/dist/llm/token-usage.js.map +1 -1
  184. package/dist/llm/tool-protocol.js +30 -0
  185. package/dist/llm/tool-protocol.js.map +1 -1
  186. package/dist/os/process-tree.d.ts +15 -0
  187. package/dist/os/process-tree.js +77 -0
  188. package/dist/os/process-tree.js.map +1 -0
  189. package/dist/prompts/embedded.js +1 -1
  190. package/dist/prompts/embedded.js.map +1 -1
  191. package/dist/prompts/system.agent.md +13 -12
  192. package/dist/repl/prompt-line.d.ts +5 -0
  193. package/dist/repl/prompt-line.js +7 -1
  194. package/dist/repl/prompt-line.js.map +1 -1
  195. package/dist/repl.js +61 -41
  196. package/dist/repl.js.map +1 -1
  197. package/dist/safety/classifier.js +98 -44
  198. package/dist/safety/classifier.js.map +1 -1
  199. package/dist/safety/engagement-policy.d.ts +8 -0
  200. package/dist/safety/engagement-policy.js +52 -7
  201. package/dist/safety/engagement-policy.js.map +1 -1
  202. package/dist/safety/patterns.d.ts +44 -0
  203. package/dist/safety/patterns.js +168 -37
  204. package/dist/safety/patterns.js.map +1 -1
  205. package/dist/store/config.d.ts +9 -1
  206. package/dist/store/config.js +51 -1
  207. package/dist/store/config.js.map +1 -1
  208. package/dist/store/history-index.d.ts +12 -0
  209. package/dist/store/history-index.js +47 -18
  210. package/dist/store/history-index.js.map +1 -1
  211. package/dist/store/history.d.ts +5 -2
  212. package/dist/store/history.js +153 -62
  213. package/dist/store/history.js.map +1 -1
  214. package/dist/store/plan.d.ts +81 -5
  215. package/dist/store/plan.js +406 -46
  216. package/dist/store/plan.js.map +1 -1
  217. package/dist/store/redaction-cache.d.ts +12 -0
  218. package/dist/store/redaction-cache.js +51 -0
  219. package/dist/store/redaction-cache.js.map +1 -0
  220. package/dist/store/responder-settlement.d.ts +5 -1
  221. package/dist/store/responder-settlement.js +42 -30
  222. package/dist/store/responder-settlement.js.map +1 -1
  223. package/dist/store/scope.js +36 -27
  224. package/dist/store/scope.js.map +1 -1
  225. package/dist/store/task-transitions.d.ts +19 -0
  226. package/dist/store/task-transitions.js +46 -0
  227. package/dist/store/task-transitions.js.map +1 -0
  228. package/dist/tools/capabilities.js +33 -18
  229. package/dist/tools/capabilities.js.map +1 -1
  230. package/dist/tools/command-intent.d.ts +24 -0
  231. package/dist/tools/command-intent.js +158 -4
  232. package/dist/tools/command-intent.js.map +1 -1
  233. package/dist/tools/definitions.d.ts +5 -1
  234. package/dist/tools/definitions.js +162 -21
  235. package/dist/tools/definitions.js.map +1 -1
  236. package/dist/tools/fs.d.ts +16 -0
  237. package/dist/tools/fs.js +122 -46
  238. package/dist/tools/fs.js.map +1 -1
  239. package/dist/tools/image.js +1 -1
  240. package/dist/tools/image.js.map +1 -1
  241. package/dist/tools/jobs.d.ts +78 -4
  242. package/dist/tools/jobs.js +542 -115
  243. package/dist/tools/jobs.js.map +1 -1
  244. package/dist/tools/net-ping-sweep.d.ts +3 -1
  245. package/dist/tools/net-ping-sweep.js +64 -26
  246. package/dist/tools/net-ping-sweep.js.map +1 -1
  247. package/dist/tools/nmap-runner.js +5 -2
  248. package/dist/tools/nmap-runner.js.map +1 -1
  249. package/dist/tools/pdf.js +50 -7
  250. package/dist/tools/pdf.js.map +1 -1
  251. package/dist/tools/registry.js +154 -96
  252. package/dist/tools/registry.js.map +1 -1
  253. package/dist/tools/shell.d.ts +24 -2
  254. package/dist/tools/shell.js +113 -28
  255. package/dist/tools/shell.js.map +1 -1
  256. package/dist/tools/tool-types.d.ts +2 -0
  257. package/dist/tools/validate.d.ts +8 -5
  258. package/dist/tools/validate.js +177 -7
  259. package/dist/tools/validate.js.map +1 -1
  260. package/dist/tools/wordlists.js +69 -17
  261. package/dist/tools/wordlists.js.map +1 -1
  262. package/dist/tui/state.d.ts +4 -0
  263. package/dist/tui/state.js +88 -2
  264. package/dist/tui/state.js.map +1 -1
  265. package/dist/tui-v2/actions/keymap.js +3 -8
  266. package/dist/tui-v2/actions/keymap.js.map +1 -1
  267. package/dist/tui-v2/app/App.js +56 -5
  268. package/dist/tui-v2/app/App.js.map +1 -1
  269. package/dist/tui-v2/app/commands/config-commands.js +5 -2
  270. package/dist/tui-v2/app/commands/config-commands.js.map +1 -1
  271. package/dist/tui-v2/app/commands/picker-commands.js +3 -0
  272. package/dist/tui-v2/app/commands/picker-commands.js.map +1 -1
  273. package/dist/tui-v2/app/plan-lifecycle.d.ts +1 -1
  274. package/dist/tui-v2/app/plan-lifecycle.js +2 -1
  275. package/dist/tui-v2/app/plan-lifecycle.js.map +1 -1
  276. package/dist/tui-v2/components/composer/composer-input-box.d.ts +1 -1
  277. package/dist/tui-v2/components/composer/composer-input-box.js +12 -3
  278. package/dist/tui-v2/components/composer/composer-input-box.js.map +1 -1
  279. package/dist/tui-v2/components/jobs/jobs-panel.js +19 -6
  280. package/dist/tui-v2/components/jobs/jobs-panel.js.map +1 -1
  281. package/dist/tui-v2/components/plan/plan-view.js +9 -6
  282. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  283. package/dist/tui-v2/components/status/status-line.d.ts +15 -0
  284. package/dist/tui-v2/components/status/status-line.js +33 -25
  285. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  286. package/dist/tui-v2/components/transcript/assistant-message.js +17 -9
  287. package/dist/tui-v2/components/transcript/assistant-message.js.map +1 -1
  288. package/dist/tui-v2/components/transcript/compacted-row.js +49 -22
  289. package/dist/tui-v2/components/transcript/compacted-row.js.map +1 -1
  290. package/dist/tui-v2/components/transcript/intro-card.js +3 -0
  291. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  292. package/dist/tui-v2/components/transcript/thinking-block.js +2 -1
  293. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  294. package/dist/tui-v2/components/transcript/transcript-view.js +27 -2
  295. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  296. package/dist/tui-v2/components/transcript/use-transcript-selection.js +57 -6
  297. package/dist/tui-v2/components/transcript/use-transcript-selection.js.map +1 -1
  298. package/dist/tui-v2/composer/composer-editor.js +20 -12
  299. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  300. package/dist/tui-v2/rendering/incremental-strip.d.ts +11 -0
  301. package/dist/tui-v2/rendering/incremental-strip.js +89 -0
  302. package/dist/tui-v2/rendering/incremental-strip.js.map +1 -0
  303. package/dist/tui-v2/rendering/plan-view.d.ts +12 -0
  304. package/dist/tui-v2/rendering/plan-view.js +71 -2
  305. package/dist/tui-v2/rendering/plan-view.js.map +1 -1
  306. package/dist/tui-v2/rendering/streaming-markdown.d.ts +25 -0
  307. package/dist/tui-v2/rendering/streaming-markdown.js +108 -0
  308. package/dist/tui-v2/rendering/streaming-markdown.js.map +1 -0
  309. package/dist/tui-v2/rendering/thinking-tail.d.ts +5 -0
  310. package/dist/tui-v2/rendering/thinking-tail.js +26 -0
  311. package/dist/tui-v2/rendering/thinking-tail.js.map +1 -0
  312. package/dist/tui-v2/state/transcript-hydrate.js +6 -2
  313. package/dist/tui-v2/state/transcript-hydrate.js.map +1 -1
  314. package/dist/tui-v2/state/transcript-reducer.js +146 -25
  315. package/dist/tui-v2/state/transcript-reducer.js.map +1 -1
  316. package/dist/tui-v2/state/transcript-types.d.ts +6 -0
  317. package/dist/tui-v2/state/transcript-types.js +17 -0
  318. package/dist/tui-v2/state/transcript-types.js.map +1 -1
  319. package/dist/types.d.ts +29 -0
  320. package/dist/types.js.map +1 -1
  321. package/dist/ui/intro-header.d.ts +1 -0
  322. package/dist/ui/intro-header.js +10 -13
  323. package/dist/ui/intro-header.js.map +1 -1
  324. package/dist/ui/markdown.js +9 -2
  325. package/dist/ui/markdown.js.map +1 -1
  326. package/dist/ui/mentions.d.ts +5 -0
  327. package/dist/ui/mentions.js +66 -7
  328. package/dist/ui/mentions.js.map +1 -1
  329. package/dist/ui/thinking.d.ts +5 -1
  330. package/dist/ui/thinking.js +3 -2
  331. package/dist/ui/thinking.js.map +1 -1
  332. package/dist/version.generated.d.ts +2 -2
  333. package/dist/version.generated.js +2 -2
  334. package/package.json +2 -1
@@ -1,17 +1,19 @@
1
1
  import chalk from "chalk";
2
2
  import { join } from "node:path";
3
- import { streamWithProvider, completeWithProvider, isEmptyCompletionError } from "../llm/router.js";
3
+ import { completeWithProvider, streamWithProvider } from "../llm/router.js";
4
+ import { streamAlreadyEmitted } from "../llm/stream-progress.js";
5
+ import { classifyStreamFailure, planStreamRecovery, recordRecoveryAttempt, createStreamRecoveryState, resetStreamRecoveryState, } from "./stream-recovery.js";
4
6
  import { resolveToolDialect } from "../llm/capabilities.js";
5
7
  import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
6
8
  import { sanitizeAssistantText } from "../ui/ansi-box.js";
7
9
  import { randomUUID } from "node:crypto";
8
10
  import { jobManager, } from "../tools/jobs.js";
9
- import { responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
11
+ import { isResponderResultLedgerMessage, responderContextMessage, upsertResponderContextMessage, upsertResponderResultLedger, } from "./responder-context.js";
10
12
  import { agentModeDirective, planModeDirective, renderAgentSystemPrompt, renderCompactAgentSystemPrompt, scratchDirFor, toolNudge, } from "../prompts/index.js";
11
13
  import { getConfig } from "../store/config.js";
12
14
  import { beginSessionWorkspace, getActiveSessionWorkspace, } from "../store/session-workspace.js";
13
15
  import { groqInputTokenBudget } from "../llm/groq.js";
14
- import { classifyToolCall, isPentestToolCall, scopeHint, scopeTargetForToolCall, } from "../safety/classifier.js";
16
+ import { classifyToolCall, isPentestToolCall, scopeTargetForToolCall, } from "../safety/classifier.js";
15
17
  /**
16
18
  * Scope/engagement classification runs on every tool call and parses
17
19
  * model-supplied arguments (URLs, hosts, commands). A malformed argument must
@@ -26,20 +28,20 @@ function safeScopeTargetForToolCall(call) {
26
28
  return undefined;
27
29
  }
28
30
  }
29
- function safeEngagementActionForToolCall(call) {
31
+ function safeEngagementActionsForToolCall(call) {
30
32
  try {
31
- return engagementActionForToolCall(call);
33
+ return engagementActionsForToolCall(call);
32
34
  }
33
35
  catch {
34
- return undefined;
36
+ return [];
35
37
  }
36
38
  }
37
39
  import { availableToolNames, normalizeToolCall, runToolCall, BATCH_SAFE_TOOLS, } from "../tools/registry.js";
38
- import { getToolDefinitions, getCompactToolDefinitions, PLAN_TOOL_NAMES, } from "../tools/definitions.js";
40
+ import { getToolDefinitions, getCompactToolDefinitions, RUNNER_META_TOOL_NAMES, } from "../tools/definitions.js";
39
41
  import { appendAssistantWithTools, ensureUniqueToolCallIds, appendToolResult, assertValidToolProtocol, fillMissingToolResults, repairToolProtocol, } from "./tool-history.js";
40
42
  import { formatViewportHint, registerViewport } from "../ui/output-pane.js";
41
43
  import { compactMessagesWithSummary, shouldApplyAutoCompact, COMPACTION_MEMORY_PREFIX, PLAN_IMPLEMENT_MEMORY_PREFIX, isCompactionMemoryMessage, } from "./context-manager.js";
42
- import { buildContextBreakdown, contextBreakdownAuditPayload, } from "./context-breakdown.js";
44
+ import { buildContextBreakdown, contextBreakdownAuditPayload, describeDominantContextBlock, toolSchemaHash, } from "./context-breakdown.js";
43
45
  import { autoCompactTriggerTokens, dedupeToolContextOutput, freeTierGuardNotices, getReliabilityPolicy, resolveStepMaxTokens, } from "./reliability-policy.js";
44
46
  import { auditLog } from "../store/logs.js";
45
47
  import { loadProjectContext } from "../store/project.js";
@@ -52,24 +54,29 @@ import { safeCwd } from "../os/cwd.js";
52
54
  import { analyzeTask, formatTaskAnalysisHint, isNarrowExplicitNmapOperation, } from "./task-analyzer.js";
53
55
  import { computeMaxIterations, computeStepBudget } from "./step-budget.js";
54
56
  import { isScratchOnlyWrite } from "./scratch-write.js";
57
+ import { buildDurableEnvelope, WorkLedger, } from "./durable-envelope.js";
55
58
  import { COMPACTION_SYSTEM_PROMPT, } from "./compaction-summary.js";
56
59
  import { maybeAppendPlanModeReminder, PLAN_REMINDER_TOAST, } from "./plan-mode-reminders.js";
57
60
  import { LoopGuard } from "./loop-guard.js";
58
- import { loadPlan, savePlan, markTask, appendPlanTask, readyPlanTasks, isPlanTerminal, isPlanSuccessful, } from "../store/plan.js";
59
- 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";
60
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";
61
67
  import { createSessionPolicy, isPreApprovalAllowedTool, isPlanModeAllowedShellCommand, isPlanModeAllowedTool, isPlanApprovedByStatus, planHasOpenWork, isAbortError, shouldEnableImageOcr, } from "./session-policy.js";
62
68
  import { saveToolOutput, summarizeOutput, formatToolContext, } from "./tool-output-formatting.js";
63
69
  import { codingSessionFromContext, isProtocolPlaceholderOutput, progressPauseMode, } from "./progress-pause-policy.js";
64
- import { renderPlanForTerminal, planContextMessage, handlePlanTool, resolvePlanTaskId, } from "./plan-tool.js";
65
- import { readTaskUpdateArgs, distinctAdvancingTaskIds, isSimultaneousTaskAdvance, batchUpdateSignature, buildMultiUpdateReminder, multiUpdateToast, } from "./task-sync.js";
66
- 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";
67
74
  import { buildSessionStateBlock, inferNextHint, upsertSessionStateMessage, } from "./session-state.js";
68
- import { buildContinueOrientation } from "./continue-orient.js";
75
+ import { buildContinueOrientation, } from "./continue-orient.js";
69
76
  import { detectPackageManager } from "./workspace-orient.js";
70
77
  import { budgetRemaining, consumeBudget, createRecoveryBudgets, freestyleClaimsAppReady, looksLikeShallowPentestReport, recoveryForErrorDiagnosis, recoveryForFailedProbe, recoveryForFreshness, recoveryForMissingFeature, recoveryForMissingPlan, recoveryForNarration, recoveryForPrematureComplete, recoveryForRuntimeVerify, recoveryForShallowPentest, } from "./must-continue.js";
71
- import { scopeContextMessage } from "./scope-context.js";
72
- 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";
73
80
  import { patchPlanMeta } from "../store/plan.js";
74
81
  import { extractProjectRootFromPlan, extractProjectRootFromScaffold, extractProjectRootFromText, getActiveProjectRoot, setActiveProjectRootIfValid, } from "./project-root.js";
75
82
  import { buildWorkspaceOrientation, discoverImmediateProjectRoots, guessProjectFolderName, isBareParentDirectory, isScaffoldCancelledOutput, scaffoldLooksMaterialized, scaffoldTargetConflictMessage, resolveScaffoldTargetPath, } from "./workspace-orient.js";
@@ -79,28 +86,73 @@ import { composeAgentSystemPrompt } from "./prompt-composer.js";
79
86
  import { createGovernorState, governProgress, } from "./evidence-governor.js";
80
87
  import { createTurnState, transitionTurn, } from "./turn-state.js";
81
88
  import { deriveOutcomeStatus, inferOutcomeKind, openOutcomeState, recordAnswerEvidence, recordFailedHypothesis, recordToolEvidence, saveOutcomeState, validateCriterionEvidence, } from "./outcomes.js";
82
- import { createTurnOutcome, renderTurnOutcome } from "./turn-outcome.js";
89
+ import { createTurnOutcome, normalizeTurnOutcomeInput, renderTurnOutcome, } from "./turn-outcome.js";
83
90
  import { beginEngagementAction, finishEngagementAction, recordEngagementCheckpoint, reconcileEngagementJob, openEngagement, saveEngagement, } from "../store/engagement.js";
84
91
  export * from "./tool-call-parser.js";
85
92
  export { createSessionPolicy, isPreApprovalAllowedTool, isPlanApprovedByStatus, planHasOpenWork, shouldEnableImageOcr, } from "./session-policy.js";
86
93
  export function styleToolChatter(call, text) {
87
94
  return shouldDimToolChatter(call) ? chalk.dim(text) : text;
88
95
  }
89
- 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) {
90
107
  if (!plan)
91
108
  return false;
92
109
  const unfinished = plan.tasks.filter((task) => !task.responderOwned &&
93
110
  (task.state === "pending" || task.state === "in_progress"));
94
- if (unfinished.length === 0 ||
95
- !unfinished.every((task) => classifyTaskTitle(task.title, { planKind: plan.kind }) === "report")) {
111
+ if (unfinished.length === 0)
96
112
  return false;
97
- }
98
- return (runningJobs.some((job) => job.responder) ||
99
- notifications.some((notification) => (!currentNotificationId || notification.id !== currentNotificationId) &&
100
- 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 &&
101
123
  !notification.archivedAt &&
102
124
  !notification.readAt &&
103
- !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
+ }));
134
+ }
135
+ /**
136
+ * Cancellable backoff. Resolves after `ms`, or rejects immediately if the
137
+ * signal aborts (double-Esc) so recovery waits never trap a cancelled turn.
138
+ */
139
+ function delay(ms, signal) {
140
+ if (ms <= 0)
141
+ return Promise.resolve();
142
+ if (signal?.aborted) {
143
+ return Promise.reject(signal.reason ?? new Error("Aborted"));
144
+ }
145
+ return new Promise((resolve, reject) => {
146
+ const onAbort = () => {
147
+ clearTimeout(timer);
148
+ reject(signal?.reason ?? new Error("Aborted"));
149
+ };
150
+ const timer = setTimeout(() => {
151
+ signal?.removeEventListener("abort", onAbort);
152
+ resolve();
153
+ }, ms);
154
+ signal?.addEventListener("abort", onAbort, { once: true });
155
+ });
104
156
  }
105
157
  export async function runAgentTurn(prompt, options = {}) {
106
158
  const agentMode = options.mode === "plan" || options.mode === "agent" || options.mode === "ask"
@@ -223,33 +275,61 @@ export async function runAgentTurn(prompt, options = {}) {
223
275
  };
224
276
  /** Strip a known prefix from a string, returning the remainder unchanged. */
225
277
  const insertedText = (value, prefix) => value.startsWith(prefix) ? value.slice(prefix.length) : value;
226
- /**
227
- * Surface the compacted-context summary to both the TUI (via an event)
228
- * and, when running with a direct stdout writer, as a rendered box.
229
- * Token-count stats are always emitted for logs.
230
- */
231
- const writeCompacted = (summary, beforeTokens, afterTokens) => {
232
- 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
+ });
233
299
  if (writesDirectly) {
234
- const header = chalk.dim(" \u2726 Compacted Context");
235
- const footer = chalk.dim(` ~${beforeTokens.toLocaleString()} \u2192 ~${afterTokens.toLocaleString()} tokens`);
236
- const body = summary ? renderMarkdown(summary) : "(empty summary)";
237
- process.stdout.write(`${header}\n\n${body}\n${footer}\n`);
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 });
306
+ if (writesDirectly) {
307
+ process.stdout.write(chalk.yellow(`\n Compaction failed: ${message} (~${retainedTokens.toLocaleString()} tokens retained)\n`));
238
308
  }
239
309
  };
240
310
  // Points at the live message array so finishTurn can hand the full
241
311
  // conversation back to the caller. Assigned once `messages` is built below;
242
312
  // all later mutations are in-place so this reference stays current.
243
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
+ };
244
321
  const finishTurn = (answer, steps, status = "succeeded", remainingCriteria = [], reason) => {
245
- const outcome = createTurnOutcome({
322
+ releaseUnreadResponderClaims();
323
+ const outcome = createTurnOutcome(normalizeTurnOutcomeInput({
246
324
  status,
247
325
  answer,
248
326
  steps,
249
327
  remainingCriteria,
250
328
  reason,
329
+ }));
330
+ const rendered = renderTurnOutcome(outcome, {
331
+ diagnostics: !suppressOutcomeDiagnostics,
251
332
  });
252
- const rendered = renderTurnOutcome(outcome);
253
333
  writeAssistantMessage(rendered);
254
334
  if (options.onMessages) {
255
335
  try {
@@ -300,6 +380,7 @@ export async function runAgentTurn(prompt, options = {}) {
300
380
  // freshness retries — a false "act don't narrate" path burned tokens on
301
381
  // web.search recovery loops after a simple "hi".
302
382
  const idleOrSocialPrompt = looksLikeIdleOrSocialPrompt(prompt);
383
+ suppressOutcomeDiagnostics = informationalQuery || idleOrSocialPrompt;
303
384
  const freshWebSearchRequired = !buildLikeTurn &&
304
385
  !pentestLikeTurn &&
305
386
  !idleOrSocialPrompt &&
@@ -325,7 +406,7 @@ export async function runAgentTurn(prompt, options = {}) {
325
406
  const base = compact
326
407
  ? getCompactToolDefinitions()
327
408
  : getToolDefinitions();
328
- const allow = new Set([...toolNames, ...PLAN_TOOL_NAMES]);
409
+ const allow = new Set([...toolNames, ...RUNNER_META_TOOL_NAMES]);
329
410
  return base.filter((d) => allow.has(d.name));
330
411
  };
331
412
  let lastAnswer = "";
@@ -446,9 +527,9 @@ export async function runAgentTurn(prompt, options = {}) {
446
527
  return sections.join("\n\n");
447
528
  };
448
529
  const systemSections = [buildSystemContent(nativeToolsActive)];
449
- if (activePlan) {
450
- systemSections.push(planContextMessage(activePlan, session.planApproved.value));
451
- }
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.
452
533
  // Soft mid-work recovery (any domain): re-attach to jobs / open tasks /
453
534
  // last tools after interrupt or "continue" — not a hard gate.
454
535
  if (!informationalQuery && !idleOrSocialPrompt) {
@@ -460,6 +541,7 @@ export async function runAgentTurn(prompt, options = {}) {
460
541
  recentJobs: jobManager.getRecentJobs(12, session.sessionId),
461
542
  informationalQuery,
462
543
  idleOrSocial: idleOrSocialPrompt,
544
+ ...(options.previousTurn ? { previousTurn: options.previousTurn } : {}),
463
545
  });
464
546
  if (continueBrief) {
465
547
  systemSections.push(continueBrief);
@@ -562,7 +644,7 @@ export async function runAgentTurn(prompt, options = {}) {
562
644
  if (!has("plan")) {
563
645
  sections.push({
564
646
  kind: "plan",
565
- 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.",
566
648
  mandatory: true,
567
649
  });
568
650
  }
@@ -613,13 +695,31 @@ export async function runAgentTurn(prompt, options = {}) {
613
695
  userMessage,
614
696
  ];
615
697
  liveMessages = messages;
616
- 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");
617
703
  const responderWakeNotificationId = responderWakeTurn
618
704
  ? /^notification=(.+)$/m.exec(prompt)?.[1]?.trim()
619
705
  : undefined;
620
- const unreadResponderNotificationIds = new Set();
621
- if (responderWakeNotificationId) {
622
- 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);
623
723
  }
624
724
  const refreshResponderInbox = () => {
625
725
  const running = jobManager
@@ -630,6 +730,7 @@ export async function runAgentTurn(prompt, options = {}) {
630
730
  .getPendingNotifications(session.sessionId)
631
731
  .filter((notification) => notification.responder &&
632
732
  unreadResponderNotificationIds.has(notification.id) &&
733
+ matchesWakeRevision(notification) &&
633
734
  !notification.readAt &&
634
735
  !notification.analyzedAt &&
635
736
  !notification.archivedAt)
@@ -692,10 +793,44 @@ export async function runAgentTurn(prompt, options = {}) {
692
793
  };
693
794
  const loopGuard = new LoopGuard();
694
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
+ };
695
823
  // Track consecutive thinking-only responses so we can nudge the model
696
824
  // to actually act instead of silently returning an empty answer.
697
825
  let emptyVisibleRetries = 0;
698
826
  let retryWithoutThinking = false;
827
+ // Robust stream-failure recovery. When a provider stream/complete fails we
828
+ // try working approaches (backoff, compaction, thinking-off, provider
829
+ // fallback) before surrendering the turn — see ./stream-recovery. Both are
830
+ // reset on any successful stream so each failure episode gets a fresh
831
+ // budget and we only give up in the worst case.
832
+ let allowModelFallback = false;
833
+ const recoveryState = createStreamRecoveryState();
699
834
  // Track tool calls truncated by the token limit so we can ask the model
700
835
  // to retry in smaller pieces instead of leaking broken JSON as an answer.
701
836
  let truncatedToolRetries = 0;
@@ -777,7 +912,7 @@ export async function runAgentTurn(prompt, options = {}) {
777
912
  });
778
913
  };
779
914
  const reconcileOpenTaskBeforeFinalizing = async () => {
780
- const plan = await loadPlan(session.sessionId).catch(() => undefined);
915
+ let plan = await loadPlan(session.sessionId).catch(() => undefined);
781
916
  const open = plan?.tasks.find((task) => task.state === "in_progress" && !task.responderOwned);
782
917
  if (!plan || !open)
783
918
  return plan;
@@ -785,42 +920,80 @@ export async function runAgentTurn(prompt, options = {}) {
785
920
  if (!gate.ok)
786
921
  return plan;
787
922
  const reconciledTaskIds = [open.id];
788
- 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.");
789
925
  while (true) {
790
- const observation = readyPlanTasks(plan).find((task) => isRuntimeObservationTask(task.title) ||
791
- (plan.kind === "pentest" && isRemoteObservationTask(task.title)));
926
+ const observation = readyPlanTasks(working).find((task) => isRuntimeObservationTask(task.title) ||
927
+ (working.kind === "pentest" && isRemoteObservationTask(task.title)));
792
928
  if (!observation)
793
929
  break;
794
- const observationGate = completionGateForTask(plan, observation.id);
930
+ const observationGate = completionGateForTask(working, observation.id);
795
931
  if (!observationGate.ok)
796
932
  break;
797
- markTask(plan, observation.id, "done", plan.kind === "pentest"
933
+ markTask(working, observation.id, "done", working.kind === "pentest"
798
934
  ? "Satisfied by verified remote evidence from the preceding task."
799
935
  : "Satisfied by the verified runtime evidence from the preceding task.");
800
936
  reconciledTaskIds.push(observation.id);
801
937
  }
802
- if (plan.status === "draft" || plan.status === "approved") {
803
- plan.status = "in_progress";
938
+ if (working.status === "draft" || working.status === "approved") {
939
+ working.status = "in_progress";
804
940
  }
805
- if (isPlanTerminal(plan)) {
806
- plan.status = isPlanSuccessful(plan) ? "completed" : "abandoned";
941
+ if (isPlanTerminal(working)) {
942
+ working.status = isPlanSuccessful(working) ? "completed" : "abandoned";
807
943
  }
808
- 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;
809
973
  writePlanUpdate(plan, renderPlanForTerminal(plan) + "\n");
810
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`));
811
975
  taskWorkLedger = null;
812
976
  return plan;
813
977
  };
814
978
  async function persistProjectRootOnPlan(root) {
815
- const live = await loadPlan(session.sessionId).catch(() => undefined);
816
- if (!live)
817
- return;
818
979
  const pm = detectPackageManager(root);
819
- patchPlanMeta(live, {
820
- projectRoot: root,
821
- ...(pm ? { packageManager: pm } : {}),
822
- });
823
- 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);
824
997
  }
825
998
  refreshSessionState = (plan) => {
826
999
  if (idleOrSocialPrompt || informationalQuery)
@@ -871,6 +1044,11 @@ export async function runAgentTurn(prompt, options = {}) {
871
1044
  : undefined,
872
1045
  };
873
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
+ }
874
1052
  upsertSessionStateMessage(messages, buildSessionStateBlock(snap));
875
1053
  };
876
1054
  refreshSessionState(activePlan);
@@ -907,7 +1085,10 @@ export async function runAgentTurn(prompt, options = {}) {
907
1085
  kind: inferOutcomeKind({ userIntent: prompt, buildLike, pentestLike }),
908
1086
  continueExisting: continueExistingOutcome,
909
1087
  });
1088
+ loopGuard.restoreCompletedOperations(outcomeState.completedOperations ?? []);
910
1089
  await saveOutcomeState(outcomeState);
1090
+ // Canonical mutation/artifact ledger feeding the durable compaction envelope.
1091
+ const workLedger = new WorkLedger();
911
1092
  let governorState = createGovernorState();
912
1093
  let governorPauseReason;
913
1094
  let turnState = createTurnState();
@@ -962,10 +1143,54 @@ export async function runAgentTurn(prompt, options = {}) {
962
1143
  return release;
963
1144
  },
964
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
+ }
965
1189
  async function executeSingleTool(rawCall, toolEventId, parentSignal) {
966
1190
  const scratchDir = scratchDirFor(safeCwd());
967
1191
  let call = normalizeToolCall(rawCall);
968
1192
  let dispatchedTaskId;
1193
+ let delegation;
969
1194
  let engagementLease;
970
1195
  let engagementGraph;
971
1196
  let engagementRecord;
@@ -986,7 +1211,13 @@ export async function runAgentTurn(prompt, options = {}) {
986
1211
  return { ok: true, call, result, contextOutput: recoveryText };
987
1212
  }
988
1213
  if (narrowNmapOperation) {
989
- 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
+ ]);
990
1221
  if (!allowed.has(call.name)) {
991
1222
  const reason = `Narrow nmap request: ${call.name} was not run because the user requested only one nmap operation. ` +
992
1223
  `Call net.scan with the requested target/options; do not create a plan or add DNS, WHOIS, HTTP, recon, or vulnerability steps.`;
@@ -1026,17 +1257,27 @@ export async function runAgentTurn(prompt, options = {}) {
1026
1257
  detail: String(retryReasonRaw.detail ?? ""),
1027
1258
  }
1028
1259
  : undefined;
1029
- // Only block identical *failed* re-tries without changed context.
1030
- // Successful re-calls always run for real — no "already succeeded /
1031
- // use prior results" nags (those caused model abnormalities).
1260
+ const currentProbeState = probeStateKey(call);
1032
1261
  const loopCheck = loopGuard.shouldBlock(call.name, call.args, {
1033
1262
  dependenciesChanged: retryDependenciesChanged,
1034
1263
  environmentChanged: retryEnvironmentChanged,
1264
+ ...(currentProbeState ? { stateKey: currentProbeState } : {}),
1035
1265
  ...(retryReason ? { retryReason } : {}),
1036
1266
  });
1037
1267
  if (loopCheck.block) {
1038
1268
  const reason = loopCheck.reason ??
1039
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
+ }
1040
1281
  writeNotice("warn", reason, chalk.yellow(` ⚠ ${reason}\n`));
1041
1282
  const result = { ok: false, output: reason, exitCode: 1 };
1042
1283
  emitToolResult(toolEventId, result, reason);
@@ -1050,29 +1291,63 @@ export async function runAgentTurn(prompt, options = {}) {
1050
1291
  if (call.name === "plan.create" ||
1051
1292
  call.name === "task.add" ||
1052
1293
  call.name === "task.move" ||
1294
+ call.name === "job.read" ||
1053
1295
  call.name === "task.read" ||
1054
1296
  call.name === "task.update") {
1055
- if (call.name === "task.read") {
1056
- 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"
1057
1299
  ? call.args.notificationId.trim()
1058
1300
  : "";
1059
- const notification = jobManager
1060
- .getPendingNotifications(session.sessionId)
1061
- .find((candidate) => candidate.id === notificationId);
1062
- const visible = unreadResponderNotificationIds.has(notificationId);
1063
- 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 &&
1064
1325
  visible &&
1065
- jobManager.markRead(notificationId, session.sessionId));
1066
- const output = marked
1067
- ? `Responder notification ${notificationId} marked delivered and read after model analysis.`
1068
- : !notificationId
1069
- ? "task.read failed: notificationId is required."
1070
- : !visible
1071
- ? `task.read failed: notification ${notificationId} was not delivered to this model turn. Analyze a delivered result before marking it read.`
1072
- : `task.read failed: notification ${notificationId} is unavailable, archived, or its read state could not be persisted.`;
1073
- 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) {
1074
1346
  upsertResponderResultLedger(messages, notification);
1075
- unreadResponderNotificationIds.delete(notificationId);
1347
+ unreadResponderNotificationIds.delete(notification.id);
1348
+ }
1349
+ else if (staleWakeSettled && responderWakeNotificationId) {
1350
+ unreadResponderNotificationIds.delete(responderWakeNotificationId);
1076
1351
  }
1077
1352
  if (!alreadyPrintedIds.has(toolEventId)) {
1078
1353
  const toolCallLine = chalk.cyan(` ▶ ${call.name}`) +
@@ -1189,7 +1464,7 @@ export async function runAgentTurn(prompt, options = {}) {
1189
1464
  taskWorkLedger = led;
1190
1465
  if (planResult.plan && led && led.successWorkCount > 0 && persisted) {
1191
1466
  persisted.evidence = taskEvidenceFromLedger(led);
1192
- await savePlan(planResult.plan).catch(() => undefined);
1467
+ await persistTaskEvidence(persisted.id, persisted.evidence);
1193
1468
  }
1194
1469
  }
1195
1470
  else if (stateRaw === "done" && resolved) {
@@ -1198,7 +1473,7 @@ export async function runAgentTurn(prompt, options = {}) {
1198
1473
  const t = planResult.plan.tasks.find((x) => x.id === resolved);
1199
1474
  if (t) {
1200
1475
  t.evidence = taskEvidenceFromLedger(taskWorkLedger);
1201
- await savePlan(planResult.plan).catch(() => undefined);
1476
+ await persistTaskEvidence(t.id, t.evidence);
1202
1477
  }
1203
1478
  }
1204
1479
  taskWorkLedger = null;
@@ -1327,7 +1602,18 @@ export async function runAgentTurn(prompt, options = {}) {
1327
1602
  livePlanForGate.status === "approved") {
1328
1603
  livePlanForGate.status = "in_progress";
1329
1604
  }
1330
- 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);
1331
1617
  // Preserve evidence already credited to this task (e.g. pentest
1332
1618
  // recon that ran before the task was formally opened).
1333
1619
  if (!taskWorkLedger ||
@@ -1385,22 +1671,29 @@ export async function runAgentTurn(prompt, options = {}) {
1385
1671
  alreadyPrintedIds.add(toolEventId);
1386
1672
  }
1387
1673
  const scopeTarget = safeScopeTargetForToolCall(call);
1388
- const engagementAction = pentestSession || isPentestToolCall(call) || Boolean(scope)
1389
- ? safeEngagementActionForToolCall(call)
1390
- : undefined;
1391
- const engagementDecision = engagementAction
1392
- ? evaluateEngagementAction(scope, engagementAction)
1393
- : undefined;
1394
- 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;
1395
1688
  if (scope) {
1396
1689
  engagementGraph = await openEngagement(scope);
1397
1690
  engagementRecord = beginEngagementAction(engagementGraph, {
1398
1691
  tool: call.name,
1399
- target: engagementDecision.normalizedTarget || engagementAction.target,
1400
- phase: engagementDecision.phase,
1401
- capability: engagementDecision.capability,
1402
- authorized: engagementDecision.allowed,
1403
- 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,
1404
1697
  });
1405
1698
  await saveEngagement(engagementGraph);
1406
1699
  }
@@ -1408,16 +1701,22 @@ export async function runAgentTurn(prompt, options = {}) {
1408
1701
  ...(engagementGraph ? { engagementId: engagementGraph.id } : {}),
1409
1702
  ...(engagementRecord ? { actionId: engagementRecord.id } : {}),
1410
1703
  tool: call.name,
1411
- target: engagementDecision.normalizedTarget,
1412
- phase: engagementDecision.phase,
1413
- capability: engagementDecision.capability,
1414
- allowed: engagementDecision.allowed,
1415
- reason: engagementDecision.reason,
1704
+ target: decisionForAction.normalizedTarget,
1705
+ phase: decisionForAction.phase,
1706
+ capability: decisionForAction.capability,
1707
+ allowed: decisionForAction.allowed,
1708
+ reason: decisionForAction.reason,
1416
1709
  });
1417
- if (!engagementDecision.allowed) {
1418
- const target = engagementDecision.normalizedTarget || scopeTarget || engagementAction.target;
1419
- const reason = `Blocked engagement action for ${target}: ${engagementDecision.reason}. ` +
1420
- 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
+ });
1421
1720
  writeToolBlocked(toolEventId, call.name, reason, chalk.red(` ✗ ${reason}\n`));
1422
1721
  const result = { ok: false, output: reason, exitCode: 1 };
1423
1722
  emitToolResult(toolEventId, result, reason);
@@ -1524,6 +1823,53 @@ export async function runAgentTurn(prompt, options = {}) {
1524
1823
  const candidate = pickPendingTaskForToolCall(readyPlanTasks(planAtDispatch), call, planAtDispatch.tasks.map((task) => task.title));
1525
1824
  dispatchedTaskId = candidate?.id;
1526
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
+ }
1527
1873
  if (dispatchedTaskId &&
1528
1874
  (!taskWorkLedger || taskWorkLedger.taskId !== dispatchedTaskId)) {
1529
1875
  const dispatchedTask = planAtDispatch?.tasks.find((task) => task.id === dispatchedTaskId);
@@ -1532,7 +1878,12 @@ export async function runAgentTurn(prompt, options = {}) {
1532
1878
  if (engagementAction) {
1533
1879
  engagementLease = engagementPolicy.acquire(scope, engagementAction);
1534
1880
  if (!engagementLease.decision.allowed) {
1535
- 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
+ });
1536
1887
  const result = { ok: false, output: reason, exitCode: 1 };
1537
1888
  emitToolResult(toolEventId, result, reason);
1538
1889
  return { ok: false, call, result, contextOutput: reason };
@@ -1634,7 +1985,9 @@ export async function runAgentTurn(prompt, options = {}) {
1634
1985
  confirmed: true,
1635
1986
  userPrompt: prompt,
1636
1987
  sessionId: session.sessionId,
1637
- ...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
1988
+ ...(delegation?.taskId ? { taskId: delegation.taskId } : {}),
1989
+ ...(delegation ? { delegationId: delegation.id } : {}),
1990
+ ...(dispatchedTaskId ? { parentTaskId: dispatchedTaskId } : {}),
1638
1991
  wakeOnCompletion: true,
1639
1992
  monitor: {
1640
1993
  toolName: call.name,
@@ -1745,13 +2098,18 @@ export async function runAgentTurn(prompt, options = {}) {
1745
2098
  // User Esc/Ctrl+C: force-settle may resolve with a cancel result
1746
2099
  // instead of throwing — still end the turn as aborted.
1747
2100
  if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
1748
- writeAbort();
2101
+ const result = {
2102
+ ok: false,
2103
+ output: "Cancelled by user.",
2104
+ exitCode: 130,
2105
+ };
2106
+ emitToolResult(toolEventId, result, result.output);
1749
2107
  return {
1750
2108
  ok: false,
1751
2109
  call,
1752
- result: { ok: false, output: "Aborted." },
1753
- contextOutput: "Aborted.",
1754
- lastAnswer: "Aborted.",
2110
+ result,
2111
+ contextOutput: result.output,
2112
+ aborted: true,
1755
2113
  };
1756
2114
  }
1757
2115
  if (liveBytes > 0) {
@@ -1763,13 +2121,18 @@ export async function runAgentTurn(prompt, options = {}) {
1763
2121
  jobManager.updateJobStatus(jobId, "failed", 1);
1764
2122
  if (isAbortError(toolError, toolAc.signal) || forceSettled) {
1765
2123
  if (parentSignal.aborted && !stalledByWatchdog && !hardTimedOut) {
1766
- writeAbort();
2124
+ const result = {
2125
+ ok: false,
2126
+ output: "Cancelled by user.",
2127
+ exitCode: 130,
2128
+ };
2129
+ emitToolResult(toolEventId, result, result.output);
1767
2130
  return {
1768
2131
  ok: false,
1769
2132
  call,
1770
- result: { ok: false, output: "Aborted." },
1771
- contextOutput: "Aborted.",
1772
- lastAnswer: "Aborted.",
2133
+ result,
2134
+ contextOutput: result.output,
2135
+ aborted: true,
1773
2136
  };
1774
2137
  }
1775
2138
  result = {
@@ -1873,6 +2236,27 @@ export async function runAgentTurn(prompt, options = {}) {
1873
2236
  writeNotice("info", `project root → ${fromScaffold}`, chalk.dim(` ℹ project root set to ${fromScaffold}\n`));
1874
2237
  }
1875
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
+ }
1876
2260
  if (result.backgroundJob) {
1877
2261
  const durableJob = jobManager.getJob(result.backgroundJob.id);
1878
2262
  // Responder linkage is opt-in: only jobs launched with responder:true
@@ -1880,9 +2264,10 @@ export async function runAgentTurn(prompt, options = {}) {
1880
2264
  // Plain background jobs stay pollable (shell.jobs/shell.tail) as before.
1881
2265
  if (durableJob?.responder) {
1882
2266
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
1883
- let linkedTaskId = dispatchedTaskId;
2267
+ let linkedTaskId = delegation?.taskId;
1884
2268
  let linkedParentTaskId;
1885
2269
  let responderTaskId;
2270
+ let responderChildId;
1886
2271
  if (durableJob && livePlan) {
1887
2272
  const existing = livePlan.tasks.find((task) => task.jobId === durableJob.id);
1888
2273
  const parentTaskId = dispatchedTaskId && livePlan.tasks.some((task) => task.id === dispatchedTaskId)
@@ -1897,46 +2282,64 @@ export async function runAgentTurn(prompt, options = {}) {
1897
2282
  : "in_progress";
1898
2283
  const note = `job=${durableJob.id} pid=${durableJob.pid ?? "?"} status=${durableJob.status} ` +
1899
2284
  `artifact=${durableJob.stdoutArtifact}`;
1900
- const responderTask = existing ?? appendPlanTask(livePlan, {
1901
- title: `Responder · ${durableJob.name ?? durableJob.commandDisplay.slice(0, 96)}`,
1902
- state: terminalState,
1903
- note,
1904
- dependencies: [],
1905
- resourceLocks: [],
1906
- parentTaskId,
1907
- jobId: durableJob.id,
1908
- processId: durableJob.pid,
1909
- responderOwned: true,
1910
- });
1911
- responderTask.state = terminalState;
1912
- responderTask.note = note;
1913
- responderTask.jobId = durableJob.id;
1914
- responderTask.processId = durableJob.pid;
1915
- responderTask.responderOwned = true;
1916
- if (parentTaskId)
1917
- responderTask.parentTaskId = parentTaskId;
1918
- if (isPlanTerminal(livePlan)) {
1919
- livePlan.status = isPlanSuccessful(livePlan)
1920
- ? "completed"
1921
- : "abandoned";
1922
- }
1923
- else if (livePlan.status !== "draft") {
1924
- livePlan.status = "in_progress";
1925
- }
1926
- let responderPlanSaved = false;
1927
- try {
1928
- await savePlan(livePlan);
1929
- responderPlanSaved = true;
1930
- }
1931
- 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) {
1932
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`));
1933
2335
  }
1934
- if (responderPlanSaved) {
1935
- linkedTaskId = responderTask.id;
2336
+ else {
2337
+ linkedTaskId = responderChildId;
1936
2338
  linkedParentTaskId = parentTaskId;
1937
- responderTaskId = responderTask.id;
1938
- pendingSessionStatePlan = livePlan;
1939
- 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");
1940
2343
  }
1941
2344
  }
1942
2345
  if (durableJob) {
@@ -2021,13 +2424,17 @@ export async function runAgentTurn(prompt, options = {}) {
2021
2424
  }
2022
2425
  await saveEngagement(engagementGraph);
2023
2426
  }
2427
+ workLedger.recordToolCall(call, result.ok, savedOutputPath);
2428
+ const completedProbeState = probeStateKey(call);
2024
2429
  const newEvidence = recordToolEvidence(outcomeState, {
2025
2430
  tool: call.name,
2026
2431
  callId: toolEventId,
2027
2432
  ok: result.ok,
2433
+ ...(result.exitCode !== undefined ? { exitCode: result.exitCode } : {}),
2028
2434
  output: result.output,
2029
2435
  ...(savedOutputPath ? { artifact: savedOutputPath } : {}),
2030
2436
  ...(dispatchedTaskId ? { taskId: dispatchedTaskId } : {}),
2437
+ ...(completedProbeState ? { stateKey: completedProbeState } : {}),
2031
2438
  args: call.args,
2032
2439
  });
2033
2440
  let hypothesisDelta = 0;
@@ -2100,7 +2507,7 @@ export async function runAgentTurn(prompt, options = {}) {
2100
2507
  }
2101
2508
  }
2102
2509
  await saveOutcomeState(outcomeState);
2103
- 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);
2104
2511
  // Evidence for verify-before-done: only successful real work counts.
2105
2512
  if (result.ok && isEvidenceWorkTool(call.name)) {
2106
2513
  const liveAfter = await loadPlan(session.sessionId).catch(() => undefined);
@@ -2172,7 +2579,7 @@ export async function runAgentTurn(prompt, options = {}) {
2172
2579
  taskWorkLedger.successWorkCount < absorbed.successWorkCount) {
2173
2580
  taskWorkLedger = absorbed;
2174
2581
  }
2175
- await savePlan(liveAfter).catch(() => undefined);
2582
+ await persistTaskEvidence(task.id, task.evidence);
2176
2583
  }
2177
2584
  }
2178
2585
  }
@@ -2181,7 +2588,7 @@ export async function runAgentTurn(prompt, options = {}) {
2181
2588
  const task = liveAfter.tasks.find((candidate) => candidate.id === creditId);
2182
2589
  if (task) {
2183
2590
  task.evidence = taskEvidenceFromLedger(taskWorkLedger);
2184
- await savePlan(liveAfter).catch(() => undefined);
2591
+ await persistTaskEvidence(task.id, task.evidence);
2185
2592
  }
2186
2593
  }
2187
2594
  // Do NOT refreshSessionState here. executeSingleTool often finishes
@@ -2249,13 +2656,24 @@ export async function runAgentTurn(prompt, options = {}) {
2249
2656
  // Align with /compact default: small recency + dense memory (not keepRecent=6 fat tails).
2250
2657
  const AUTO_COMPACT_KEEP_RECENT = 2;
2251
2658
  let lastCompactionMsgCount = 0;
2659
+ const compactionAttempts = new CompactionAttemptLedger();
2660
+ let activeCompactionId;
2252
2661
  /** E5: identical tool bodies within this turn → pointer instead of re-append. */
2253
2662
  const toolResultHashes = new Map();
2254
2663
  /** E4: consecutive free-tier stream failures this turn. */
2255
2664
  let freeTierConsecutiveFailures = 0;
2256
2665
  let freeTierLargeContextWarned = false;
2257
- const summarizeForCompaction = async (summaryPrompt) => {
2258
- const response = await completeWithProvider({
2666
+ // Surface the free-tier "failed N times / switch provider" advisory at most
2667
+ // once per turn — the recovery planner already narrates each retry, so
2668
+ // repeating this on every failure just adds noise.
2669
+ let freeTierAdvisoryShown = false;
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 = {
2259
2677
  provider,
2260
2678
  model,
2261
2679
  messages: [
@@ -2263,11 +2681,32 @@ export async function runAgentTurn(prompt, options = {}) {
2263
2681
  { role: "user", content: summaryPrompt },
2264
2682
  ],
2265
2683
  temperature: 0.1,
2266
- // Prefer dense memory; enough room for findings without forcing a tiny stub.
2267
2684
  maxTokens: 2_048,
2268
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" },
2269
2704
  });
2270
- return response.text;
2705
+ const retryVisible = stripThinking(retry.text).visible.trim();
2706
+ if (retryVisible && compactionId) {
2707
+ writeCompactionDelta(compactionId, retryVisible);
2708
+ }
2709
+ return retry.text;
2271
2710
  };
2272
2711
  /**
2273
2712
  * Estimate the complete next model request, including attached native-tool
@@ -2280,20 +2719,85 @@ export async function runAgentTurn(prompt, options = {}) {
2280
2719
  const nextTools = selectToolDefs(native, useCompactSystemPrompt);
2281
2720
  return buildContextBreakdown(contextMessages, nextTools).estimatedTotalTokens;
2282
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
+ }
2283
2771
  async function maybeAutoCompact(reason, force = false) {
2284
2772
  const beforeTokens = estimateNextRequestTokens(messages);
2285
- // E1: auto-compact trigger (default 72k soft; hard ceiling 100k).
2286
- const compactTrigger = autoCompactTriggerTokens();
2773
+ const budget = resolveRequestBudget({ provider, model });
2774
+ const compactTrigger = budget.effectiveTrigger;
2287
2775
  if (!force && beforeTokens < compactTrigger)
2288
2776
  return;
2777
+ // Structural eligibility only: there must be closed history to summarize.
2289
2778
  if (messages.length <= AUTO_COMPACT_KEEP_RECENT + 2)
2290
2779
  return;
2291
- // Avoid compaction loops: don't re-compact until enough new messages have
2292
- // accumulated since the last compaction.
2293
- 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))
2294
2791
  return;
2792
+ const compactionId = `compact-${randomUUID().slice(0, 12)}`;
2793
+ activeCompactionId = compactionId;
2794
+ writeCompactionStarted(compactionId, beforeTokens);
2295
2795
  try {
2296
- 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
+ });
2297
2801
  const summaryBody = result.messages.find((m) => isCompactionMemoryMessage(m))?.content ??
2298
2802
  "";
2299
2803
  if (!shouldApplyAutoCompact({
@@ -2303,9 +2807,27 @@ export async function runAgentTurn(prompt, options = {}) {
2303
2807
  afterTokens: result.afterTokens,
2304
2808
  afterMessages: result.messages,
2305
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);
2306
2827
  return;
2307
2828
  }
2308
2829
  messages.splice(0, messages.length, ...result.messages);
2830
+ compactionAttempts.recordSuccess(attemptKey);
2309
2831
  loopGuard.resetReadOnly();
2310
2832
  // Token stats use the same complete request estimate as the trigger.
2311
2833
  const compactedTokens = estimateNextRequestTokens(messages);
@@ -2313,10 +2835,7 @@ export async function runAgentTurn(prompt, options = {}) {
2313
2835
  // after older turns (which carried the plan context) were summarized.
2314
2836
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
2315
2837
  if (livePlan) {
2316
- messages.push({
2317
- role: "system",
2318
- content: planContextMessage(livePlan, session.planApproved.value),
2319
- });
2838
+ upsertPlanContextMessage(messages, planContextMessage(livePlan, session.planApproved.value));
2320
2839
  }
2321
2840
  // Re-inject live SESSION STATE after compaction (older flags survive).
2322
2841
  refreshSessionState(livePlan);
@@ -2337,22 +2856,25 @@ export async function runAgentTurn(prompt, options = {}) {
2337
2856
  ? PLAN_IMPLEMENT_MEMORY_PREFIX
2338
2857
  : COMPACTION_MEMORY_PREFIX);
2339
2858
  // Card shows pre/post of the summarization; plan re-injection is noted.
2340
- writeCompacted(summaryText, beforeTokens, compactedTokens);
2859
+ writeCompactionCompleted(compactionId, summaryText, beforeTokens, compactedTokens);
2341
2860
  const planNote = afterTokens > compactedTokens
2342
2861
  ? ` (compacted to ~${compactedTokens.toLocaleString()}, +plan → ~${afterTokens.toLocaleString()})`
2343
2862
  : "";
2344
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`));
2345
2864
  }
2346
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);
2347
2868
  if (error instanceof Error &&
2348
2869
  (error.name === "AbortError" || error.message.includes("aborted"))) {
2349
2870
  throw error;
2350
2871
  }
2351
- // Summarization failed — DO NOT fall back to a mechanical dump. Keep the
2352
- // current context and continue; we'll try again as it keeps growing.
2353
- await auditLog("agent.compact.failed", {
2354
- reason: error instanceof Error ? error.message : String(error),
2355
- });
2872
+ compactionAttempts.recordFailure(attemptKey);
2873
+ await auditLog("agent.compact.failed", { reason: message });
2874
+ }
2875
+ finally {
2876
+ if (activeCompactionId === compactionId)
2877
+ activeCompactionId = undefined;
2356
2878
  }
2357
2879
  }
2358
2880
  for (let iteration = 0; iteration < maxIterations; iteration += 1) {
@@ -2515,16 +3037,17 @@ export async function runAgentTurn(prompt, options = {}) {
2515
3037
  });
2516
3038
  try {
2517
3039
  if (responderDelivery &&
2518
- !jobManager.markDelivered(responderDelivery.id)) {
3040
+ !jobManager.markDeliveryStarted(responderDelivery.id, session.sessionId)) {
2519
3041
  jobManager.releaseResponderNotificationClaim(responderDelivery.id);
2520
- throw new Error(`failed to mark responder notification ${responderDelivery.id} delivered`);
3042
+ throw new Error(`failed to record responder delivery attempt ${responderDelivery.id}`);
2521
3043
  }
2522
3044
  completion = await streamWithProvider({
2523
3045
  provider,
2524
3046
  model,
2525
- allowModelFallback: false,
3047
+ allowModelFallback,
2526
3048
  messages,
2527
- 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.
2528
3051
  maxTokens: stepMaxTokens,
2529
3052
  signal: options.signal,
2530
3053
  thinking: retryWithoutThinking
@@ -2663,42 +3186,88 @@ export async function runAgentTurn(prompt, options = {}) {
2663
3186
  }
2664
3187
  });
2665
3188
  freeTierConsecutiveFailures = 0;
3189
+ // Stream succeeded → the failure episode is over. Reset the recovery
3190
+ // budget and the one-shot fallback flag so a later, unrelated failure
3191
+ // starts fresh (and we never give up while making progress).
3192
+ resetStreamRecoveryState(recoveryState);
3193
+ allowModelFallback = false;
2666
3194
  }
2667
3195
  catch (streamError) {
3196
+ // User cancelled (double-Esc) — never try to recover, just stop.
3197
+ if (options.signal?.aborted)
3198
+ throw streamError;
2668
3199
  // E4: track free-tier failures for advisory notices (never blocks).
2669
3200
  freeTierConsecutiveFailures += 1;
2670
- for (const notice of freeTierGuardNotices({
2671
- provider,
2672
- estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
2673
- consecutiveFailures: freeTierConsecutiveFailures,
2674
- })) {
2675
- if (notice.includes("Large context"))
2676
- continue; // already shown above
2677
- writeNotice("warn", notice, chalk.yellow(` ⚠ ${notice}\n`));
2678
- }
2679
- // A fully empty model completion (no text, no tool calls) must not
2680
- // kill the turn. It shows up most after auto-compaction, when the
2681
- // tail ends on re-injected system context and the model has nothing
2682
- // to answer. Append a trailing user nudge (so the turn no longer
2683
- // ends on system messages) and retry, bounded like the
2684
- // successful-but-empty path below.
2685
- if (!options.signal?.aborted &&
2686
- isEmptyCompletionError(streamError) &&
2687
- emptyVisibleRetries < 3) {
2688
- emptyVisibleRetries += 1;
2689
- writeNotice("warn", "model streamed an empty response — nudging it to continue", chalk.yellow(" ⚠ model streamed an empty response — nudging it to continue\n"));
2690
- messages.push(recoveryUserMessage("Your previous response was empty. Continue the task now: emit your next tool call, " +
2691
- "or give your final answer if every required step is already complete and verified. " +
2692
- "Do not reply with an empty message."));
2693
- continue;
3201
+ if (!freeTierAdvisoryShown) {
3202
+ for (const notice of freeTierGuardNotices({
3203
+ provider,
3204
+ estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
3205
+ consecutiveFailures: freeTierConsecutiveFailures,
3206
+ })) {
3207
+ if (notice.includes("Large context"))
3208
+ continue; // already shown above
3209
+ writeNotice("warn", notice, chalk.yellow(` ⚠ ${notice}\n`));
3210
+ freeTierAdvisoryShown = true;
3211
+ }
2694
3212
  }
2695
- throw streamError;
3213
+ // Robust recovery: a single flaky provider/model (empty admission,
3214
+ // connection glitch, capacity 5xx, rate limit, or an oversized
3215
+ // request) must not kill the turn. Classify the failure and take a
3216
+ // bounded, escalating recovery step — back off, compact, drop
3217
+ // thinking, or let the router fall back to another provider/model.
3218
+ // We only rethrow (stop the turn) in the worst case: every approach
3219
+ // for that failure class is exhausted or the total budget is spent.
3220
+ const failureKind = classifyStreamFailure(streamError);
3221
+ const plan = planStreamRecovery({
3222
+ kind: failureKind,
3223
+ state: recoveryState,
3224
+ });
3225
+ if (plan.action === "give-up") {
3226
+ throw streamError;
3227
+ }
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
+ }
3236
+ if (plan.notice) {
3237
+ writeNotice("warn", plan.notice, chalk.yellow(` ⚠ ${plan.notice}\n`));
3238
+ }
3239
+ if (plan.disableThinking)
3240
+ retryWithoutThinking = true;
3241
+ if (plan.allowModelFallback)
3242
+ allowModelFallback = true;
3243
+ if (plan.forceCompact) {
3244
+ await maybeAutoCompact(`stream-recovery:${failureKind}`, true);
3245
+ }
3246
+ if (plan.nudge) {
3247
+ messages.push(recoveryUserMessage(plan.nudge));
3248
+ }
3249
+ if (plan.delayMs > 0) {
3250
+ emit({
3251
+ type: "status",
3252
+ text: `retrying in ${Math.ceil(plan.delayMs / 1000)}s (${failureKind})`,
3253
+ });
3254
+ await delay(plan.delayMs, options.signal);
3255
+ }
3256
+ continue;
2696
3257
  }
2697
3258
  }
2698
3259
  finally {
2699
3260
  // Always clear the spinner — abort, network error, or success.
2700
3261
  spinner.stop();
2701
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
+ }
2702
3271
  provider = completion.provider;
2703
3272
  model = completion.model;
2704
3273
  if (completion.usage) {
@@ -2707,6 +3276,22 @@ export async function runAgentTurn(prompt, options = {}) {
2707
3276
  usage: completion.usage,
2708
3277
  model: completion.model,
2709
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
+ }
2710
3295
  }
2711
3296
  deltaParser?.finish();
2712
3297
  // Sticky text-only may have flipped dialect during stream retry.
@@ -2838,25 +3423,28 @@ export async function runAgentTurn(prompt, options = {}) {
2838
3423
  truncatedToolRetries += 1;
2839
3424
  if (truncatedToolRetries <= 5) {
2840
3425
  try {
2841
- const writeResult = await fsWrite(salvaged.path, salvaged.content, { confirmed: true });
3426
+ const writeResult = await applySalvagedWrite(salvaged);
2842
3427
  if (writeResult.ok) {
2843
3428
  const lineCount = salvaged.content.split("\n").length;
2844
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`));
2845
3430
  // Pair assistant tool_calls with synthetic results so the
2846
3431
  // next turn is not orphaned, then nudge for append.
2847
- appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls);
3432
+ appendAssistantWithTools(messages, assistantText.visible, nativeToolCalls, completion.reasoningBlock);
2848
3433
  for (const tc of nativeToolCalls) {
2849
3434
  appendToolResult(messages, tc.id, tc.id === writeTc.id
2850
3435
  ? `Tool ${tc.name} result (exit=0, ok=true):\nSalvaged partial write: ${lineCount} lines to ${salvaged.path}`
2851
3436
  : `Tool ${tc.name} result (exit=1, ok=false):\nCancelled — sibling write was truncated and salvaged.`, tc.name, tc.id === writeTc.id);
2852
3437
  }
2853
- const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
3438
+ const priorBytes = writeResult.bytesOnDisk;
3439
+ const salvagedToolName = salvaged.operation === "append"
3440
+ ? "fs.append"
3441
+ : "fs.write";
2854
3442
  const appendNudge = toolsAttached
2855
- ? `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}. ` +
2856
3444
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2857
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). ` +
2858
3446
  `Do not re-read the full file; do not re-send content already saved. Use the platform tool interface — no markdown fences.`
2859
- : `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}. ` +
2860
3448
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
2861
3449
  `CONTINUE with ONE large fs.append of the remaining content:\n` +
2862
3450
  '```tool\n{"name":"fs.append","args":{"path":' +
@@ -2990,23 +3578,23 @@ export async function runAgentTurn(prompt, options = {}) {
2990
3578
  truncatedToolRetries += 1;
2991
3579
  const salvaged = salvageTruncatedWrite(assistantText.visible);
2992
3580
  if (salvaged && truncatedToolRetries <= 5) {
2993
- // Write the salvaged partial content
3581
+ // Write the salvaged partial content through the normal
3582
+ // authorization path (classifier + confirmation + receipt).
2994
3583
  try {
2995
- const writeResult = await fsWrite(salvaged.path, salvaged.content, {
2996
- confirmed: true,
2997
- });
3584
+ const writeResult = await applySalvagedWrite(salvaged);
2998
3585
  if (writeResult.ok) {
2999
3586
  const lineCount = salvaged.content.split("\n").length;
3000
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`));
3001
3588
  pushAssistantHistory(stripThinking(assistantText.visible).visible);
3002
- const priorBytes = Buffer.byteLength(salvaged.content, "utf8");
3589
+ const priorBytes = writeResult.bytesOnDisk;
3590
+ const salvagedToolName = salvaged.operation === "append" ? "fs.append" : "fs.write";
3003
3591
  messages.push({
3004
3592
  role: "user",
3005
3593
  content: toolsAttached
3006
- ? `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}. ` +
3007
3595
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
3008
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.`
3009
- : `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}. ` +
3010
3598
  `The file ends with: ${JSON.stringify(salvaged.lastLine)}\n\n` +
3011
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` +
3012
3600
  '```tool\n{"name":"fs.append","args":{"path":' +
@@ -3055,9 +3643,7 @@ export async function runAgentTurn(prompt, options = {}) {
3055
3643
  const salvaged = salvageTruncatedWrite(assistantText.visible);
3056
3644
  if (salvaged) {
3057
3645
  try {
3058
- const writeResult = await fsWrite(salvaged.path, salvaged.content, {
3059
- confirmed: true,
3060
- });
3646
+ const writeResult = await applySalvagedWrite(salvaged);
3061
3647
  if (writeResult.ok) {
3062
3648
  const lineCount = salvaged.content.split("\n").length;
3063
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`));
@@ -3104,7 +3690,7 @@ export async function runAgentTurn(prompt, options = {}) {
3104
3690
  const unread = [...unreadResponderNotificationIds];
3105
3691
  pushAssistantHistory(assistantText.visible);
3106
3692
  messages.push(recoveryUserMessage(`You have ${unread.length} delivered Responder result(s) that remain unread: ${unread.join(", ")}. ` +
3107
- "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."));
3108
3694
  continue;
3109
3695
  }
3110
3696
  const narratedAction = looksLikeActionNarration(cleaned);
@@ -3283,7 +3869,7 @@ export async function runAgentTurn(prompt, options = {}) {
3283
3869
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
3284
3870
  const unfinished = livePlan?.tasks.filter((task) => !task.responderOwned &&
3285
3871
  (task.state === "pending" || task.state === "in_progress"));
3286
- 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);
3287
3873
  if (livePlan &&
3288
3874
  unfinished &&
3289
3875
  unfinished.length > 0 &&
@@ -3305,9 +3891,17 @@ export async function runAgentTurn(prompt, options = {}) {
3305
3891
  const remainingCriteria = [];
3306
3892
  if (session.planApproved.value) {
3307
3893
  const livePlan = await loadPlan(session.sessionId).catch(() => undefined);
3308
- const unfinished = livePlan?.tasks.filter((t) => t.state === "pending" || t.state === "in_progress") ?? [];
3309
- 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") ?? [];
3310
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
+ }
3311
3905
  if (failedTasks.length > 0)
3312
3906
  outcomeStatus = "failed";
3313
3907
  else if (unfinished.length > 0)
@@ -3449,7 +4043,7 @@ export async function runAgentTurn(prompt, options = {}) {
3449
4043
  writeToolCall(deferred.eventId, deferred.call, deferred.rendered);
3450
4044
  }
3451
4045
  if (historyNativeCalls.length) {
3452
- appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls);
4046
+ appendAssistantWithTools(messages, beforeTool ?? "", historyNativeCalls, completion.reasoningBlock);
3453
4047
  }
3454
4048
  else {
3455
4049
  const standardizedContent = (beforeTool ? beforeTool.trim() + "\n\n" : "") +
@@ -3498,7 +4092,8 @@ export async function runAgentTurn(prompt, options = {}) {
3498
4092
  if (res.ok && res.call.name === "plan.create") {
3499
4093
  planCreatedThisTurn = true;
3500
4094
  }
3501
- productiveSteps += 1;
4095
+ if (!res.suppressedRepeat)
4096
+ productiveSteps += 1;
3502
4097
  // E5: collapse identical large tool bodies within this turn to a pointer.
3503
4098
  const deduped = dedupeToolContextOutput({
3504
4099
  content: res.contextOutput,
@@ -3663,7 +4258,7 @@ export async function runAgentTurn(prompt, options = {}) {
3663
4258
  }
3664
4259
  // User Esc/Ctrl+C only — never cancel siblings because a delete failed
3665
4260
  // or a confirm was declined; the model must see every tool result.
3666
- if (res.lastAnswer === "Aborted.")
4261
+ if (res.aborted)
3667
4262
  aborted = true;
3668
4263
  };
3669
4264
  // Multi-task sync guard: when one model message advances more than one
@@ -3684,7 +4279,24 @@ export async function runAgentTurn(prompt, options = {}) {
3684
4279
  : parsed.taskId;
3685
4280
  intents.push({ call: b.call, taskId: resolvedId, state: parsed.state });
3686
4281
  }
3687
- 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)) {
3688
4300
  const signature = batchUpdateSignature(intents);
3689
4301
  if (session.pendingTaskBatch.value === signature) {
3690
4302
  session.pendingTaskBatch.value = undefined;
@@ -3805,7 +4417,7 @@ export async function runAgentTurn(prompt, options = {}) {
3805
4417
  return finishTurn("", productiveSteps, "partial", ["Approve or revise the draft plan before implementation."]);
3806
4418
  }
3807
4419
  if (aborted) {
3808
- lastAnswer = "Aborted.";
4420
+ lastAnswer = "";
3809
4421
  outcomeState.outcome.status = "aborted";
3810
4422
  await saveOutcomeState(outcomeState);
3811
4423
  moveTurn("aborted", "turn aborted");
@@ -3836,11 +4448,12 @@ export async function runAgentTurn(prompt, options = {}) {
3836
4448
  }
3837
4449
  catch (error) {
3838
4450
  const isAbort = isAbortError(error, options.signal);
3839
- const msg = isAbort ? "Aborted." : `Error: ${error instanceof Error ? error.message : String(error)}`;
3840
4451
  if (isAbort) {
3841
4452
  writeAbort();
3842
- return finishTurn(msg, 0, "aborted", [], "The turn was aborted.");
4453
+ return finishTurn("", 0, "aborted");
3843
4454
  }
4455
+ releaseUnreadResponderClaims();
4456
+ const msg = `Error: ${error instanceof Error ? error.message : String(error)}`;
3844
4457
  if (options.onMessages) {
3845
4458
  try {
3846
4459
  options.onMessages(buildTurnHistory(liveMessages, msg));