mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -1,31 +1,23 @@
1
1
  import { classifyResultKind } from './result-classification.mjs';
2
- import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
3
- import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool, isExternalAdapterTool } from '../tools/builtin.mjs';
4
- import { executeBashSessionTool } from '../tools/bash-session.mjs';
5
- import { executePatchTool, takeApplyPatchUiDiff } from '../tools/patch.mjs';
2
+ import { canonicalizeBuiltinToolName, executeBuiltinTool, isBuiltinTool } from '../tools/builtin.mjs';
3
+ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
6
4
  import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
7
- import { normalizeToolEnvelope, makeToolEnvelope } from './tool-envelope.mjs';
5
+ import { normalizeToolEnvelope } from './tool-envelope.mjs';
8
6
  import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
9
- import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
7
+ import { resolveSessionMaxLoopIterations, WORKER_SOFT_CAP_ITERATIONS, isWorkerSoftCapSession } from '../agent-runtime/agent-loop-policy.mjs';
10
8
  import { isAgentOwner } from '../agent-owner.mjs';
11
- import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal, enqueuePendingMessage, bumpUsageMetricsEpoch } from './manager.mjs';
9
+ import { markSessionToolCall, updateSessionStage, SessionClosedError, bumpUsageMetricsEpoch } from './manager.mjs';
12
10
  import {
13
- recallFastTrackCompactMessages,
14
11
  pruneToolOutputs,
15
12
  pruneToolOutputsUnanchored,
16
13
  semanticCompactMessages,
17
14
  effectiveBudget as compactEffectiveBudget,
18
15
  DEFAULT_COMPACT_TYPE,
19
- drainSessionCycle1,
20
- countRawPendingRows,
21
16
  } from './compact.mjs';
22
17
  import { isContextOverflowError } from '../providers/retry-classifier.mjs';
23
18
  import { stripSoftWarns } from '../tool-loop-guard.mjs';
24
19
  import { maybeOffloadToolResult } from './tool-result-offload.mjs';
25
20
  import { tryReadCached, setReadCached, invalidatePathForSession, markPostEdit, consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
26
- import { createScopedCacheOutcome } from './cache/scoped-cache-outcome.mjs';
27
- import { modelVisibleToolCompletionMessage } from '../../../shared/tool-execution-contract.mjs';
28
- import { createHash } from 'crypto';
29
21
  import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
30
22
 
31
23
  import {
@@ -37,13 +29,8 @@ import {
37
29
  _intraTurnSig,
38
30
  } from './loop/tool-classify.mjs';
39
31
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
40
- let codeGraphRuntimePromise = null;
41
- async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
42
- codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
43
- const mod = await codeGraphRuntimePromise;
44
- if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
45
- return mod.executeCodeGraphTool(name, args, cwd, signal, options);
46
- }
32
+ import { runRecallFastTrackCompact } from './loop/recall-fasttrack.mjs';
33
+ import { executeTool, _scopedCacheOutcomeForCall } from './loop/tool-exec.mjs';
47
34
 
48
35
  // classifyResultKind is imported from result-classification.mjs at the top of
49
36
  // this file; import it from there directly rather than via this module.
@@ -58,6 +45,13 @@ import {
58
45
  compactDebugLog,
59
46
  } from './loop/compact-debug.mjs';
60
47
  import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
48
+ import {
49
+ crossTurnSignature,
50
+ crossTurnDedupStub,
51
+ SOFT_CAP_WRAPUP_MESSAGE,
52
+ SOFT_CAP_REFUSAL_STUB,
53
+ } from './loop/completion-guards.mjs';
54
+ import { isEditProgressTool } from './loop/completion-guards.mjs';
61
55
  import { agentContextOverflowError } from './loop/context-overflow.mjs';
62
56
  import { positiveTokenInt } from './loop/env.mjs';
63
57
  import { normalizeUsage, addUsage } from './loop/usage.mjs';
@@ -76,12 +70,9 @@ import {
76
70
  isEagerDispatchable,
77
71
  messagesArrayChanged,
78
72
  getToolKind,
79
- buildSkillsListResponse,
80
- viewSkill,
81
73
  normalizeHookUpdatedToolOutput,
82
74
  resolveToolResultAfterHook,
83
75
  parseNativeToolSearchPayload,
84
- extractBashSessionId,
85
76
  buildAgentBashSessionArgs,
86
77
  formatMissingToolApprovalUiDenial,
87
78
  resolvePreToolAskApproval,
@@ -93,6 +84,8 @@ import {
93
84
  restoreToolCallBodyForId,
94
85
  } from './loop/stored-tool-args.mjs';
95
86
  import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
87
+ import { classifyTerminationReason, INCOMPLETE_STOP_REASONS } from './loop/termination.mjs';
88
+ import { createSteeringLadder } from './loop/steering-ladder.mjs';
96
89
 
97
90
  // Facade re-exports: these symbols moved to split modules under ./loop/ but
98
91
  // remain part of loop.mjs's public surface (imported by scripts/tests and other
@@ -120,342 +113,8 @@ export {
120
113
  // this catches tight deterministic-failure loops (e.g. a command that errors
121
114
  // the same way every time) far earlier than 100 iterations.
122
115
  const REPEAT_FAIL_LIMIT = 3;
123
- async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
124
- if (!sessionId) throw new Error('recall-fasttrack requires a session id');
125
- const startedAt = Date.now();
126
- const diagnostics = {
127
- hydrateLimit: null,
128
- ingestMs: null,
129
- ingestSkipped: false,
130
- ingestError: null,
131
- initialDumpMs: null,
132
- initialDumpBytes: null,
133
- initialDumpChars: null,
134
- initialRawPending: null,
135
- cycle1Ms: null,
136
- cycle1Skipped: false,
137
- cycle1SkipReason: null,
138
- cycle1Passes: null,
139
- cycle1RawRemaining: null,
140
- cycle1TextBytes: null,
141
- cycle1Error: null,
142
- finalRecallBytes: null,
143
- finalRecallChars: null,
144
- totalMs: null,
145
- };
146
- const query = `session:${sessionId}:all-chunks`;
147
- const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
148
- const callerCtx = {
149
- callerSessionId: sessionId || null,
150
- callerCwd: sessionRef?.cwd || undefined,
151
- routingSessionId: sessionId || null,
152
- clientHostPid: sessionRef?.clientHostPid,
153
- signal: signal || null,
154
- };
155
- const hydrateLimit = positiveTokenInt(sessionRef?.compaction?.recallIngestLimit)
156
- || Math.max(500, Math.min(5000, messages.length || 0));
157
- diagnostics.hydrateLimit = hydrateLimit;
158
- let t0 = Date.now();
159
- try {
160
- await executeInternalTool('memory', {
161
- action: 'ingest_session',
162
- sessionId,
163
- messages,
164
- cwd: sessionRef?.cwd,
165
- limit: hydrateLimit,
166
- }, callerCtx);
167
- } catch (err) {
168
- diagnostics.ingestSkipped = true;
169
- diagnostics.ingestError = compactDiagnosticError(err);
170
- try { process.stderr.write(`[loop] recall-fasttrack ingest skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
171
- } finally {
172
- diagnostics.ingestMs = Date.now() - t0;
173
- }
174
- const dumpArgs = {
175
- action: 'dump_session_roots',
176
- sessionId,
177
- includeRaw: true,
178
- limit: positiveTokenInt(sessionRef?.compaction?.recallChunkLimit ?? sessionRef?.compaction?.recallLimit) || hydrateLimit,
179
- };
180
- const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
181
- t0 = Date.now();
182
- let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
183
- diagnostics.initialDumpMs = Date.now() - t0;
184
- diagnostics.initialDumpChars = String(recallText || '').length;
185
- diagnostics.initialDumpBytes = compactByteLength(recallText);
186
- diagnostics.initialRawPending = countRawPendingRows(recallText);
187
- let cycle1Text = '';
188
- const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
189
- if (hasRawRows) {
190
- t0 = Date.now();
191
- try {
192
- // Drain this session's cycle1 in window×concurrency units until no
193
- // raw rows remain, so the injected root is fully chunked rather than
194
- // carrying the unprocessed transcript tail (single-pass left raw in).
195
- const drained = await drainSessionCycle1(runTool, {
196
- sessionId,
197
- dumpArgs,
198
- deadlineMs: positiveTokenInt(sessionRef?.compaction?.recallCycle1DeadlineMs) || 120_000,
199
- maxPasses: positiveTokenInt(sessionRef?.compaction?.recallCycle1MaxPasses) || 0,
200
- cycleArgs: {
201
- min_batch: 1,
202
- session_cap: 1,
203
- batch_size: positiveTokenInt(sessionRef?.compaction?.recallCycle1BatchSize) || 100,
204
- rows_per_session: positiveTokenInt(sessionRef?.compaction?.recallRowsPerSession) || 100,
205
- window_size: positiveTokenInt(sessionRef?.compaction?.recallWindowSize) || 20,
206
- concurrency: positiveTokenInt(sessionRef?.compaction?.recallConcurrency) || 5,
207
- },
208
- });
209
- recallText = drained.recallText;
210
- cycle1Text = drained.cycle1Text;
211
- diagnostics.cycle1Passes = drained.passes;
212
- diagnostics.cycle1RawRemaining = drained.rawRemaining;
213
- diagnostics.cycle1TextBytes = compactByteLength(cycle1Text);
214
- if (drained.rawRemaining > 0) {
215
- try { process.stderr.write(`[loop] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId || 'unknown'})\n`); } catch {}
216
- }
217
- } catch (err) {
218
- diagnostics.cycle1Error = compactDiagnosticError(err);
219
- try { process.stderr.write(`[loop] recall-fasttrack cycle1 skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
220
- } finally {
221
- diagnostics.cycle1Ms = Date.now() - t0;
222
- }
223
- } else {
224
- diagnostics.cycle1Skipped = true;
225
- diagnostics.cycle1SkipReason = 'session chunks already hydrated';
226
- diagnostics.cycle1Passes = 0;
227
- diagnostics.cycle1RawRemaining = 0;
228
- cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
229
- }
230
- const combinedRecallText = [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n');
231
- diagnostics.finalRecallChars = combinedRecallText.length;
232
- diagnostics.finalRecallBytes = compactByteLength(combinedRecallText);
233
- const result = recallFastTrackCompactMessages(messages, compactBudgetTokens, {
234
- reserveTokens: compactPolicy.reserveTokens,
235
- force: true,
236
- recallText: combinedRecallText,
237
- query,
238
- querySha,
239
- allowEmptyRecall: true,
240
- tailTurns: compactPolicy.tailTurns,
241
- keepTokens: compactPolicy.keepTokens,
242
- preserveRecentTokens: compactPolicy.preserveRecentTokens,
243
- });
244
- diagnostics.totalMs = Date.now() - startedAt;
245
- if (result && typeof result === 'object') {
246
- result.diagnostics = {
247
- ...(result.diagnostics || {}),
248
- pipeline: diagnostics,
249
- };
250
- }
251
- compactDebugLog('recall-fasttrack pipeline', diagnostics);
252
- return result;
253
- }
254
- function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
255
- if (executeOpts.scopedCacheOutcome) {
256
- if (sessionRef && toolCallId) {
257
- if (!sessionRef._scopedCacheOutcomeByCallId) sessionRef._scopedCacheOutcomeByCallId = new Map();
258
- sessionRef._scopedCacheOutcomeByCallId.set(toolCallId, executeOpts.scopedCacheOutcome);
259
- }
260
- return executeOpts.scopedCacheOutcome;
261
- }
262
- if (!callerSessionId || !toolCallId || !_isScopedCacheableTool(toolName)) return null;
263
- const outcome = createScopedCacheOutcome();
264
- if (sessionRef) {
265
- if (!sessionRef._scopedCacheOutcomeByCallId) sessionRef._scopedCacheOutcomeByCallId = new Map();
266
- sessionRef._scopedCacheOutcomeByCallId.set(toolCallId, outcome);
267
- }
268
- return outcome;
269
- }
270
-
271
- async function executeTool(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
272
- const scopedCacheOutcome = _scopedCacheOutcomeForCall(
273
- sessionRef,
274
- executeOpts.toolCallId,
275
- name,
276
- callerSessionId,
277
- executeOpts,
278
- );
279
- const toolOpts = scopedCacheOutcome
280
- ? { ...executeOpts, scopedCacheOutcome }
281
- : executeOpts;
282
- const notificationSessionId = String(executeOpts.notifySessionId || sessionRef?.ownerSessionId || callerSessionId || '').trim();
283
- const notifyFn = typeof executeOpts.notifyFn === 'function'
284
- ? executeOpts.notifyFn
285
- : (text, meta = {}) => {
286
- if (!notificationSessionId) return;
287
- try {
288
- const visible = modelVisibleToolCompletionMessage(text, meta);
289
- if (visible) enqueuePendingMessage(notificationSessionId, visible);
290
- } catch { /* best effort */ }
291
- };
292
- const completionToolOpts = {
293
- ...toolOpts,
294
- sessionId: callerSessionId,
295
- callerSessionId: notificationSessionId || callerSessionId,
296
- routingSessionId: callerSessionId,
297
- clientHostPid: sessionRef?.clientHostPid,
298
- notifyFn,
299
- };
300
- const beforeToolHook = typeof executeOpts.beforeToolHook === 'function'
301
- ? executeOpts.beforeToolHook
302
- : sessionRef?.beforeToolHook;
303
- const toolApprovalHook = typeof executeOpts.toolApprovalHook === 'function'
304
- ? executeOpts.toolApprovalHook
305
- : sessionRef?.toolApprovalHook;
306
- if (beforeToolHook) {
307
- try {
308
- const decision = await beforeToolHook({
309
- name,
310
- args,
311
- cwd,
312
- sessionId: callerSessionId,
313
- toolCallId: executeOpts.toolCallId || null,
314
- });
315
- const action = String(decision?.action || decision?.decision || '').toLowerCase();
316
- if (action === 'deny' || action === 'block') {
317
- const reason = decision?.reason ? `: ${decision.reason}` : '';
318
- return `Error: tool "${name}" denied by hook${reason}`;
319
- }
320
- if (action === 'ask') {
321
- const askReason = String(decision?.reason || 'approval requested by hook').trim();
322
- const askOutcome = await resolvePreToolAskApproval({
323
- toolName: name,
324
- args,
325
- cwd,
326
- sessionId: callerSessionId,
327
- toolCallId: executeOpts.toolCallId || null,
328
- askReason,
329
- toolApprovalHook,
330
- });
331
- if (askOutcome.denial) return askOutcome.denial;
332
- const approval = askOutcome.approval;
333
- if (approval && typeof approval === 'object' && approval.args && typeof approval.args === 'object' && !Array.isArray(approval.args)) {
334
- args = approval.args;
335
- }
336
- }
337
- if ((action === 'modify' || action === 'rewrite') && decision?.args && typeof decision.args === 'object' && !Array.isArray(decision.args)) {
338
- args = decision.args;
339
- }
340
- } catch {
341
- // Hooks are policy extensions. A broken hook must not wedge the agent loop.
342
- }
343
- }
344
- const afterToolHook = typeof executeOpts.afterToolHook === 'function'
345
- ? executeOpts.afterToolHook
346
- : sessionRef?.afterToolHook;
347
- const __result = await (async () => {
348
- if (name === 'Skill') {
349
- return viewSkill(cwd, args?.name);
350
- }
351
- if (name === 'skills_list') {
352
- return buildSkillsListResponse(cwd);
353
- }
354
- if (name === 'skill_view') {
355
- return viewSkill(cwd, args?.name);
356
- }
357
- if (isMcpTool(name)) {
358
- // 24h trace data shows ~24% of external MCP calls are cwd-sensitive
359
- // (bash / grep / read / list / glob etc.) but the worker session's
360
- // cwd was previously dropped here. Inject cwd only when the tool's
361
- // inputSchema declares the field — schemas without it would reject
362
- // an unknown argument.
363
- const needsCwdInjection = cwd
364
- && mcpToolHasField(name, 'cwd')
365
- && (args == null || args.cwd == null);
366
- const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
367
- return executeMcpTool(name, finalArgs);
368
- }
369
- if (name === 'code_graph') {
370
- // cwd chain: args.cwd (caller-explicit) → session cwd → undefined (handler throws)
371
- const graphCwd = (typeof args?.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
372
- return executeCodeGraphToolLazy(name, args, graphCwd, null, toolOpts);
373
- }
374
- if (isInternalTool(name)) {
375
- // callerSessionId propagates into server.mjs dispatchTool so that
376
- // dispatchAiWrapped can detect and reject recursive calls from a
377
- // hidden-role session (recall/search/explore → self).
378
- return executeInternalTool(name, args, {
379
- callerSessionId,
380
- callerCwd: cwd,
381
- clientHostPid: sessionRef?.clientHostPid,
382
- signal: executeOpts.signal,
383
- routingSessionId: callerSessionId,
384
- notifyFn,
385
- });
386
- }
387
- if (name === 'shell') {
388
- const routedArgs = buildAgentBashSessionArgs(args, sessionRef);
389
- if (!routedArgs) {
390
- // clientHostPid scopes background shell-jobs to the dispatching
391
- // terminal's claude.exe pid (agent sessions store it on sessionRef);
392
- // without it resolveJobOwnerHostPid falls back to the daemon-global env.
393
- return executeBuiltinTool(name, args, cwd, completionToolOpts);
394
- }
395
- // Thread the session's AbortSignal so agent type=close can interrupt the
396
- // persistent child process. getSessionAbortSignal is imported at top of
397
- // loop.mjs from manager.mjs; callerSessionId identifies the controller.
398
- let _bashAbortSignal = null;
399
- try { _bashAbortSignal = getSessionAbortSignal(callerSessionId); } catch { /* ignore */ }
400
- const result = await executeBashSessionTool('bash_session', routedArgs, cwd, {
401
- sessionId: callerSessionId,
402
- abortSignal: _bashAbortSignal,
403
- });
404
- const bashSid = extractBashSessionId(result);
405
- if (bashSid) {
406
- sessionRef.implicitBashSessionId = bashSid;
407
- // Track all persistent bash sessions for bulk teardown on close.
408
- if (sessionRef.allBashSessionIds) {
409
- if (!sessionRef.allBashSessionIds.includes(bashSid)) {
410
- sessionRef.allBashSessionIds.push(bashSid);
411
- }
412
- } else {
413
- sessionRef.allBashSessionIds = [bashSid];
414
- }
415
- }
416
- return result;
417
- }
418
- if (name === 'apply_patch') {
419
- const patchArgs = typeof args === 'string' ? { patch: args } : args;
420
- return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId, toolCallId: executeOpts.toolCallId || null });
421
- }
422
- if (isBuiltinTool(name)) {
423
- // clientHostPid threaded for the same per-terminal job-scope reason as
424
- // the bash branch above (see resolveJobOwnerHostPid).
425
- return executeBuiltinTool(name, args, cwd, completionToolOpts);
426
- }
427
- if (isExternalAdapterTool(name)) {
428
- // Foreign-CLI tool names (StrReplace/Write/bash variants) adapt to a
429
- // native execution inside executeBuiltinTool's default: case; on a
430
- // shape mismatch it falls back to the redirect guidance message.
431
- return executeBuiltinTool(name, args, cwd, completionToolOpts);
432
- }
433
- return formatUnknownBuiltinToolMessage(name, args, 'tool');
434
- })();
435
- if (typeof afterToolHook === 'function') {
436
- try {
437
- const hookResult = await afterToolHook({
438
- name,
439
- args,
440
- cwd,
441
- sessionId: callerSessionId,
442
- toolCallId: executeOpts.toolCallId || null,
443
- result: __result,
444
- });
445
- // Envelope-aware hook override: a PostToolUse hook may override the
446
- // model-VISIBLE tool output (the envelope's `result` / stub), but it
447
- // must NEVER drop the `newMessages` channel. Split first, apply the
448
- // override to `result` only, then re-wrap so newMessages survive.
449
- const { result: __res, newMessages: __nm } = normalizeToolEnvelope(__result);
450
- const __overridden = resolveToolResultAfterHook(__res, hookResult);
451
- if (__nm.length) return makeToolEnvelope(__overridden, __nm);
452
- return __overridden;
453
- } catch {
454
- // PostToolUse hooks are best-effort; never let one break the tool result.
455
- }
456
- }
457
- return __result;
458
- }
116
+ // _scopedCacheOutcomeForCall and executeTool moved to ./loop/tool-exec.mjs
117
+ // (imported above).
459
118
  /**
460
119
  * Agent loop: send → tool_call → execute → re-send → repeat until text.
461
120
  * sendOpts may include:
@@ -472,10 +131,6 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
472
131
  // was not done — re-prompt instead of accepting empty as final.
473
132
  // Covers Anthropic (pause_turn, max_tokens), OpenAI (length), Gemini
474
133
  // (MAX_TOKENS, OTHER), and case variants.
475
- const INCOMPLETE_STOP_REASONS = new Set([
476
- 'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
477
- ]);
478
-
479
134
  export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
480
135
  let iterations = 0;
481
136
  let toolCallsTotal = 0;
@@ -571,17 +226,122 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
571
226
  return true;
572
227
  };
573
228
  const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
229
+ // ---- Completion-first loop guards (worker runaway prevention) ----
230
+ // Step 1 (escalation ladder) + the missed-parallelism / serial-rewording
231
+ // steering hints live in the createSteeringLadder controller below; it owns
232
+ // their cumulative counters and emits at most one hint per turn.
233
+ // _editCount counts any executed tool call whose def lacks readOnlyHint
234
+ // (i.e. edit/progress: apply_patch, bash, MCP writes, skills, ...).
235
+ let _editCount = 0;
236
+ // Step 2: cross-turn identical read-only call dedup. Map keyed by
237
+ // signature(name + stableStringify(args)) → { count, firstIteration }.
238
+ // Populated only for SUCCESSFUL isEagerDispatchable (read-only) calls.
239
+ // Bounded to 500 entries (drop-oldest / insertion order).
240
+ const _crossTurnCalls = new Map();
241
+ const _CROSS_TURN_CAP = 500;
242
+ let _dedupStubTotal = 0;
243
+ // Step 3: worker soft-cap wrap-up state.
244
+ const _softCapEnabled = isWorkerSoftCapSession(sessionRef);
245
+ let _softCapActive = false; // tools disabled + wrap-up injected
246
+ let _softCapGraceTurns = 0; // text-only grace turns consumed (max 2)
247
+ let _terminatedBySoftCap = false;
248
+ // Hard-cap final-answer turn: one tool-less wrap-up turn granted when the
249
+ // hard iteration cap fires, so the session ends with text, not empty.
250
+ let _capFinalTurnUsed = false;
251
+ // Completion-first steering ladder controller. Owns the (cumulative) level-1
252
+ // fire count, the all-read-only / serial-single / same-file-grep streaks,
253
+ // and the level-2 latch. Threaded via live getters so it reads the loop's
254
+ // current `iterations` / `_editCount` on every call (no stale snapshots).
255
+ const _steeringLadder = createSteeringLadder({
256
+ sessionId,
257
+ sessionAgent,
258
+ tools,
259
+ getIterations: () => iterations,
260
+ softCapEnabled: _softCapEnabled,
261
+ getEditCount: () => _editCount,
262
+ readOnlyRole: String(sessionRef?.permission || sessionRef?.toolPermission || '') === 'read',
263
+ pushUserMessage: (msg) => messages.push(msg),
264
+ pushSystemReminder: (text) => messages.push({ role: 'user', content: `<system-reminder>\n${text}\n</system-reminder>`, meta: 'hook' }),
265
+ });
574
266
  // Tool execution must use the session cwd even when the caller omitted the
575
267
  // legacy positional cwd argument. Agent workers always carry their cwd on
576
268
  // sessionRef; falling through to pwd()/process.cwd() resolves relatives
577
269
  // against the host/plugin root instead of the worker workspace.
578
270
  cwd = cwd || sessionRef?.cwd || undefined;
271
+ // Staged pre-cap warnings + one true hard stop. The ONLY count-based
272
+ // forced termination is the hard cap at maxLoopIterations (default 200):
273
+ // a genuine runaway guard. Before it, staged warnings fire at 50%/75%/90%
274
+ // of the cap steering the model to converge — warnings only, nothing is
275
+ // cut off early. All other runaway protection is behavior-based (steering
276
+ // ladder early wrap-up, REPEAT_FAIL_LIMIT), never a lower count.
277
+ let _iterWarnStage = 0;
278
+ const _iterWarnAt = [
279
+ Math.floor(maxLoopIterations * 0.5),
280
+ Math.floor(maxLoopIterations * 0.75),
281
+ Math.floor(maxLoopIterations * 0.9),
282
+ ];
579
283
  while (true) {
580
284
  throwIfAborted();
581
285
  if (iterations >= maxLoopIterations) {
582
- process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
583
- terminatedByCap = true;
584
- break;
286
+ // Final-answer turn: instead of breaking mid-transcript (which
287
+ // yields an empty final for locator-style agents that never got to
288
+ // answer), give the model ONE tool-less text turn to wrap up, then
289
+ // stop. Same mechanism as the worker soft cap (empty sendTools).
290
+ if (_capFinalTurnUsed) {
291
+ process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
292
+ terminatedByCap = true;
293
+ // The granted final turn produced no text (model kept emitting
294
+ // tool calls into refusal stubs, or thinking-only). Synthesize a
295
+ // non-empty final so callers never see an empty response.
296
+ if (response && !String(response.content || '').trim()) {
297
+ response.content = sessionAgent === 'explorer'
298
+ ? 'EXPLORATION_FAILED'
299
+ : '[iteration cap reached before final text]';
300
+ if (Array.isArray(response.toolCalls)) response.toolCalls = [];
301
+ }
302
+ break;
303
+ }
304
+ _capFinalTurnUsed = true;
305
+ _softCapActive = true; // reuse soft-cap plumbing: no tool defs, refusal stubs
306
+ messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached. Tools are disabled. Answer NOW with your best result from what you already found.\n</system-reminder>', meta: 'hook' });
307
+ process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); forcing final text turn.\n`);
308
+ }
309
+ if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
310
+ _iterWarnStage += 1;
311
+ const warnAt = _iterWarnAt[_iterWarnStage - 1];
312
+ const stageMsg = _iterWarnStage === 1
313
+ ? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
314
+ : `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
315
+ messages.push({ role: 'user', content: `<system-reminder>\n${stageMsg}\n</system-reminder>`, meta: 'hook' });
316
+ process.stderr.write(`[loop] iteration warning stage ${_iterWarnStage} at ${iterations} (sess=${sessionId || 'unknown'}); continuing with steer.\n`);
317
+ try {
318
+ appendAgentTrace({
319
+ sessionId,
320
+ iteration: iterations,
321
+ kind: 'steer',
322
+ payload: { tag: 'iteration_warning', stage: _iterWarnStage, at: iterations, unit: maxLoopIterations },
323
+ agent: sessionAgent || null,
324
+ });
325
+ } catch { /* best-effort */ }
326
+ }
327
+ // Worker soft cap (Step 3): non-lead sessions that reach the soft-cap
328
+ // iteration count switch to a text-only wrap-up. On the FIRST crossing
329
+ // we disable tool defs (below, via _softCapActive) and inject the
330
+ // assistant-visible wrap-up directive as a user message so the next
331
+ // send produces a final text summary. Lead/TUI sessions never enter.
332
+ const _earlySoftCap = _steeringLadder.earlySoftCapArmed();
333
+ if (_softCapEnabled && !_softCapActive && (iterations >= WORKER_SOFT_CAP_ITERATIONS || _earlySoftCap)) {
334
+ _softCapActive = true;
335
+ messages.push({ role: 'user', content: `<system-reminder>\n${SOFT_CAP_WRAPUP_MESSAGE}\n</system-reminder>`, meta: 'hook' });
336
+ try {
337
+ appendAgentTrace({
338
+ sessionId,
339
+ iteration: iterations,
340
+ kind: 'steer',
341
+ payload: { tag: 'soft_cap_wrapup', soft_cap: WORKER_SOFT_CAP_ITERATIONS, early: _earlySoftCap, level2_fires: _steeringLadder.level2FireCount },
342
+ agent: sessionAgent || null,
343
+ });
344
+ } catch { /* best-effort */ }
585
345
  }
586
346
  // Drain queued steering/prompts BEFORE the
587
347
  // pre-send compact check. The compact decision must see the exact
@@ -614,6 +374,13 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
614
374
  const reactivePending = reactiveOverflowRetryPending === true;
615
375
  const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, { forceReactive: reactivePending });
616
376
  const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, { reactivePending });
377
+ // A pending reactive-overflow retry makes THIS compact pass the
378
+ // recovery from a provider overflow refusal, not the proactive
379
+ // pressure trigger. Tag the emitted events so telemetry can tell
380
+ // them apart. Hoisted above the shouldCompact branch because the
381
+ // PostCompact hook below fires on BOTH paths (fixes a
382
+ // ReferenceError on the no-compact path).
383
+ const compactTrigger = reactivePending ? 'reactive' : 'auto';
617
384
  const compactBudgetTokens = shouldCompact
618
385
  ? (compactTargetBudget({ ...compactPolicy, pressureTokens }) || compactPolicy.boundaryTokens)
619
386
  : compactPolicy.boundaryTokens;
@@ -625,13 +392,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
625
392
  pressureTokens,
626
393
  });
627
394
  } else {
628
- try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
395
+ try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
629
396
  const compactStartedAt = Date.now();
630
- // A pending reactive-overflow retry makes THIS compact pass the
631
- // recovery from a provider overflow refusal, not the proactive
632
- // pressure trigger. Tag the emitted events so telemetry can tell
633
- // them apart, then clear the one-shot flag.
634
- const compactTrigger = reactiveOverflowRetryPending ? 'reactive' : 'auto';
397
+ // Clear the one-shot reactive-overflow flag now that this
398
+ // compact pass is consuming it (compactTrigger already
399
+ // captured it above).
635
400
  reactiveOverflowRetryPending = false;
636
401
  // PreCompact: bridge to the standard hook bus before compaction
637
402
  // runs. session-property hook (manager/loop have no bus access).
@@ -883,7 +648,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
883
648
  }, compactErr);
884
649
  }
885
650
  }
886
- try { opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
651
+ try { await opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
887
652
  const compactChanged = messagesArrayChanged(messages, compacted);
888
653
  if (compactChanged) {
889
654
  messages.length = 0;
@@ -992,7 +757,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
992
757
  } else {
993
758
  delete opts.toolChoice;
994
759
  }
995
- const sendTools = forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools;
760
+ // Soft-cap wrap-up (Step 3a): once active, send NO tool definitions so
761
+ // the provider can only emit text. Overrides the forced-first-tool path.
762
+ const sendTools = _softCapActive
763
+ ? []
764
+ : (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
996
765
  // Eager-dispatch queue: when the provider streams a tool-call event,
997
766
  // start read-only tools immediately so execution overlaps with the
998
767
  // remaining SSE parse. Writes and unknown tools wait until send()
@@ -1033,6 +802,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1033
802
  const _rfg = sessionRef?._repeatFailGuard;
1034
803
  if (_rfg && _rfg.sig === _sig && _rfg.count >= REPEAT_FAIL_LIMIT) return null;
1035
804
  }
805
+ // Cross-turn dedup also gates eager dispatch (mirror of the
806
+ // repeat-failure guard above): a read-only call whose (name,args)
807
+ // signature already ran in an EARLIER turn must NOT be eagerly
808
+ // re-executed — the serial for-body pushes the [cross-turn-dedup]
809
+ // stub instead. Without this gate startEagerRun/onToolCall would
810
+ // re-run the call before the serial dedup check ever sees it.
811
+ {
812
+ const _ctSig = crossTurnSignature(call.name, call.arguments);
813
+ const _prior = _crossTurnCalls.get(_ctSig);
814
+ if (_prior && _prior.firstIteration < iterations) return null;
815
+ }
1036
816
  const toolKind = getToolKind(call.name);
1037
817
  // Shared pre-dispatch deny: identical predicate runs in the
1038
818
  // serial path below. If any role/permission guard would reject
@@ -1374,6 +1154,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1374
1154
  // tool-call-blocked vs contract-required oscillation.
1375
1155
  if (!response.toolCalls?.length) {
1376
1156
  // No tool calls. Decide between final-answer accept vs nudge.
1157
+ // Reviewer fix: a zero-tool turn (final-pre-send steering drain or
1158
+ // contract nudge `continue`) must not bridge the all-read-only
1159
+ // streak across non-tool turns — that would fire level-2 early on
1160
+ // a worker that paused to synthesize text mid-run.
1161
+ _steeringLadder.resetAllReadOnlyStreak();
1377
1162
  // - has content + non-hidden role → valid final, break.
1378
1163
  // - empty content + hidden role → contract allows text-only
1379
1164
  // terminal turn, break.
@@ -1447,6 +1232,37 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1447
1232
  : {}),
1448
1233
  };
1449
1234
  messages.push(_assistantTurnMsg);
1235
+ // Soft-cap wrap-up (Step 3b): tools are disabled but the model still
1236
+ // emitted tool calls. Do NOT execute them — push a refusal stub for
1237
+ // each (after the assistant turn is appended so tool_use/tool_result
1238
+ // pairing stays valid) and consume a grace turn. After 2 grace turns,
1239
+ // terminate via the soft-cap path so a model that never complies stops.
1240
+ if (_softCapActive) {
1241
+ for (const _c of calls) {
1242
+ pushToolResultMessage({
1243
+ role: 'tool',
1244
+ content: SOFT_CAP_REFUSAL_STUB,
1245
+ toolCallId: _c.id,
1246
+ toolKind: 'error',
1247
+ });
1248
+ }
1249
+ _softCapGraceTurns += 1;
1250
+ try {
1251
+ appendAgentTrace({
1252
+ sessionId,
1253
+ iteration: iterations,
1254
+ kind: 'steer',
1255
+ payload: { tag: 'soft_cap_wrapup', grace_turn: _softCapGraceTurns },
1256
+ agent: sessionAgent || null,
1257
+ });
1258
+ } catch { /* best-effort */ }
1259
+ if (_softCapGraceTurns >= 2) {
1260
+ _terminatedBySoftCap = true;
1261
+ break;
1262
+ }
1263
+ if (sessionId) updateSessionStage(sessionId, 'connecting');
1264
+ continue;
1265
+ }
1450
1266
  // Execute each tool and append results.
1451
1267
  //
1452
1268
  // Intra-turn duplicate suppression: when an LLM emits two tool_use
@@ -1501,6 +1317,42 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1501
1317
  });
1502
1318
  continue;
1503
1319
  }
1320
+ // Cross-turn identical-call stub (Step 2): a SUCCESSFUL read-only
1321
+ // (isEagerDispatchable) call whose (name,args) signature already ran
1322
+ // in an EARLIER turn is not re-executed — its result is unchanged and
1323
+ // already in context. Warn at the 2nd occurrence; append the "stuck"
1324
+ // escalation tail once the session has emitted 5+ dedup stubs total.
1325
+ // Never applies to write/bash/MCP/skill tools (not eager-dispatchable).
1326
+ if (isEagerDispatchable(call.name, tools)) {
1327
+ const _ctSig = crossTurnSignature(call.name, call.arguments);
1328
+ const _prior = _crossTurnCalls.get(_ctSig);
1329
+ if (_prior && _prior.firstIteration < iterations) {
1330
+ _prior.count += 1;
1331
+ _dedupStubTotal += 1;
1332
+ const _stub = crossTurnDedupStub(call.name, _prior.firstIteration, _dedupStubTotal >= 5);
1333
+ pushToolResultMessage({
1334
+ role: 'tool',
1335
+ content: _stub,
1336
+ toolCallId: call.id,
1337
+ });
1338
+ try {
1339
+ appendAgentTrace({
1340
+ sessionId,
1341
+ iteration: iterations,
1342
+ kind: 'steer',
1343
+ payload: {
1344
+ tag: 'cross_turn_dedup',
1345
+ tool: call.name,
1346
+ occurrence: _prior.count,
1347
+ first_iteration: _prior.firstIteration,
1348
+ dedup_stub_total: _dedupStubTotal,
1349
+ },
1350
+ agent: sessionAgent || null,
1351
+ });
1352
+ } catch { /* best-effort */ }
1353
+ continue;
1354
+ }
1355
+ }
1504
1356
  // Cross-iteration repeat-failure guard. Distinct from the
1505
1357
  // intra-turn dedup above (which spans ONE assistant turn and
1506
1358
  // resets every turn): when the model re-issues an IDENTICAL
@@ -1660,7 +1512,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1660
1512
  // success path below (_executeOk && _resultKind==='normal'). A failed or
1661
1513
  // errored call would otherwise leak its entry in
1662
1514
  // sessionRef._scopedCacheOutcomeByCallId forever — reclaim it here.
1663
- if (sessionRef?._scopedCacheOutcomeByCallId && call?.id && (!_executeOk || _resultKind === 'error')) {
1515
+ if (sessionRef?._scopedCacheOutcomeByCallId instanceof Map && call?.id && (!_executeOk || _resultKind === 'error')) {
1664
1516
  sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
1665
1517
  }
1666
1518
  // PostToolUseFailure: a tool that resolved to a failure (thrown-error
@@ -1851,7 +1703,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1851
1703
  // body via the disk path in that stub.
1852
1704
  if (sessionId && _executeOk && _resultKind === 'normal') {
1853
1705
  if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
1854
- const _outcome = sessionRef?._scopedCacheOutcomeByCallId?.get(call.id);
1706
+ const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
1707
+ ? sessionRef._scopedCacheOutcomeByCallId : null;
1708
+ const _outcome = _outcomeMap?.get(call.id);
1855
1709
  setScopedToolCached({
1856
1710
  sessionId,
1857
1711
  toolName: _toolBare,
@@ -1861,7 +1715,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1861
1715
  toolUseId: call.id,
1862
1716
  complete: _outcome ? _outcome.complete : true,
1863
1717
  });
1864
- sessionRef?._scopedCacheOutcomeByCallId?.delete(call.id);
1718
+ _outcomeMap?.delete(call.id);
1865
1719
  }
1866
1720
  if (_readCacheHit === null && _isReadTool(call.name)) {
1867
1721
  // Pass tool_use id so future cache-hits can reference the body's location in history.
@@ -1885,8 +1739,43 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1885
1739
  ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
1886
1740
  ...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
1887
1741
  });
1742
+ // Completion-first bookkeeping (Steps 1 & 2). Only successful
1743
+ // executions count. Edit/progress = any executed tool whose def
1744
+ // lacks readOnlyHint (apply_patch/bash/MCP-write/skill/...).
1745
+ // Read-only successful calls seed the cross-turn dedup map.
1746
+ if (_executeOk) {
1747
+ const _isEager = isEagerDispatchable(call.name, tools);
1748
+ if (_isEager) {
1749
+ const _ctSig = crossTurnSignature(call.name, call.arguments);
1750
+ if (!_crossTurnCalls.has(_ctSig)) {
1751
+ _crossTurnCalls.set(_ctSig, { count: 1, firstIteration: iterations });
1752
+ if (_crossTurnCalls.size > _CROSS_TURN_CAP) {
1753
+ const _oldest = _crossTurnCalls.keys().next().value;
1754
+ _crossTurnCalls.delete(_oldest);
1755
+ }
1756
+ }
1757
+ } else {
1758
+ // A successful mutating (non-eager) tool invalidates the
1759
+ // cross-turn dedup map wholesale: any prior read/grep may
1760
+ // now return different content, so a post-edit
1761
+ // verification read must NOT be stubbed as "unchanged".
1762
+ if (isEditProgressTool(call.name, false)) {
1763
+ _crossTurnCalls.clear();
1764
+ _editCount += 1;
1765
+ }
1766
+ }
1767
+ }
1888
1768
  } catch (postErr) {
1889
1769
  _postProcessOk = false;
1770
+ // Reviewer fix: the exec itself succeeded — if it was a
1771
+ // mutating edit-progress tool, the file changes are real even
1772
+ // though post-processing failed, so the cross-turn dedup map
1773
+ // must still be invalidated (otherwise a later verification
1774
+ // read could be stubbed as "unchanged" against stale sigs).
1775
+ if (_executeOk && !isEagerDispatchable(call.name, tools) && isEditProgressTool(call.name, false)) {
1776
+ _crossTurnCalls.clear();
1777
+ _editCount += 1;
1778
+ }
1890
1779
  // Post-processing failed AFTER a successful exec: the result is
1891
1780
  // replaced with an error below, so preserve this call's full body
1892
1781
  // too for a clean retry (mirrors the failed-exec path above).
@@ -1961,6 +1850,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1961
1850
  } catch { /* best-effort: PostToolBatch hook must never break the loop */ }
1962
1851
  }
1963
1852
  }
1853
+ // Completion-first steering hints (missed-parallelism / all-read-only /
1854
+ // serial-rewording). At most ONE hint per turn (priority: soft-cap >
1855
+ // level-2 > same-file grep > level-1); soft-cap active suppresses all.
1856
+ // The ladder controller owns the cumulative counters and streaks.
1857
+ _steeringLadder.emitPostBatchSteering(calls, _softCapActive);
1964
1858
  // Mid-turn steering is drained at the next loop's pre-send point,
1965
1859
  // AFTER any auto-compact pass. Draining here would put the steering
1966
1860
  // user turn after the fresh tool results before compaction runs; then
@@ -1971,31 +1865,13 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1971
1865
  }
1972
1866
  // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
1973
1867
  // finish to an explicit Lead-facing error instead of a silent empty
1974
- // "completed". Determine "has content" exactly the way the no-tool-call
1975
- // branch above does (trimmed string content, or any reasoning content).
1976
- const _finalHasContent = (typeof response?.content === 'string' && response.content.trim().length > 0)
1977
- || (typeof response?.reasoningContent === 'string' && response.reasoningContent.trim().length > 0);
1978
- const _finalStopReason = response?.stopReason ?? response?.stop_reason ?? null;
1979
- const _finalIncompleteStop = _finalStopReason && INCOMPLETE_STOP_REASONS.has(_finalStopReason);
1980
- const _finalIsHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
1981
- let terminationReason;
1982
- if (terminatedByCap) {
1983
- // Real problem regardless of hidden/public: the loop never terminated
1984
- // on its own contract.
1985
- terminationReason = 'iteration_cap';
1986
- } else if (!_finalHasContent && _finalIncompleteStop) {
1987
- // Cut short mid-synthesis (token cap / provider pause). Real problem
1988
- // for hidden agents too.
1989
- terminationReason = 'truncated';
1990
- } else if (!_finalHasContent && !_finalIsHidden) {
1991
- // Empty terminal turn. Only public agents violate their contract by
1992
- // finishing empty — hidden agents (explorer/cycle/…) legitimately emit
1993
- // text-only/empty terminal turns per their own role contract, so leave
1994
- // terminationReason undefined for them.
1995
- terminationReason = 'empty';
1996
- } else {
1997
- terminationReason = undefined;
1998
- }
1868
+ // "completed" (see classifyTerminationReason in ./loop/termination.mjs).
1869
+ const terminationReason = classifyTerminationReason(response, {
1870
+ terminatedByCap,
1871
+ terminatedBySoftCap: _terminatedBySoftCap,
1872
+ softCapActive: _softCapActive,
1873
+ sessionAgent,
1874
+ });
1999
1875
  return {
2000
1876
  ...response,
2001
1877
  usage: lastUsage || response.usage,