mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -3,30 +3,35 @@ import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
3
3
  import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool } from '../tools/builtin.mjs';
4
4
  import { executeBashSessionTool } from '../tools/bash-session.mjs';
5
5
  import { executePatchTool } from '../tools/patch.mjs';
6
- import { executeCodeGraphTool, isCodeGraphTool } from '../tools/code-graph.mjs';
7
6
  import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
8
7
  import { collectSkillsCached, loadSkillContent } from '../context/collect.mjs';
9
- import { traceBridgeLoop, traceBridgeTool, traceBridgeToolFailure, traceBridgeCompact, estimateProviderPayloadBytes, messagePrefixHash } from '../bridge-trace.mjs';
10
- import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal, enqueuePendingMessage } from './manager.mjs';
11
- import { estimateMessagesTokens, estimateRequestReserveTokens } from './context-utils.mjs';
8
+ import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash } from '../agent-trace.mjs';
9
+ import { isAgentOwner } from '../agent-owner.mjs';
10
+ import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal, enqueuePendingMessage, bumpUsageMetricsEpoch } from './manager.mjs';
11
+ import { estimateMessagesTokens, estimateRequestReserveTokens, sanitizeToolPairs } from './context-utils.mjs';
12
12
  import {
13
- compactMessages,
13
+ recallFastTrackCompactMessages,
14
14
  pruneToolOutputs,
15
15
  semanticCompactMessages,
16
- compactActiveTurn,
17
- SUMMARY_PREFIX,
16
+ compactTypeIsRecallFastTrack,
17
+ compactTypeIsSemantic,
18
+ normalizeCompactType,
19
+ DEFAULT_COMPACT_TYPE,
18
20
  DEFAULT_COMPACTION_BUFFER_TOKENS,
19
21
  DEFAULT_COMPACTION_BUFFER_RATIO,
20
22
  DEFAULT_COMPACTION_KEEP_TOKENS,
21
23
  compactionBufferTokensForBoundary,
22
24
  normalizeCompactionBufferRatio,
25
+ drainSessionCycle1,
23
26
  } from './compact.mjs';
24
27
  import { isContextOverflowError } from '../providers/retry-classifier.mjs';
25
- import { classifyBashFileLookupCommand, classifyBridgeWorkerGitMutationCommand, stripSoftWarns } from '../tool-loop-guard.mjs';
28
+ import { stripSoftWarns } from '../tool-loop-guard.mjs';
26
29
  import { maybeOffloadToolResult } from './tool-result-offload.mjs';
27
30
  import { tryReadCached, setReadCached, invalidatePathForSession, markPostEdit, consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
28
31
  import { createScopedCacheOutcome } from './cache/scoped-cache-outcome.mjs';
32
+ import { modelVisibleToolCompletionMessage } from '../../../shared/tool-execution-contract.mjs';
29
33
  import { createHash } from 'crypto';
34
+ import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
30
35
 
31
36
  // Tool-name classification for cross-turn read dedup.
32
37
  // Strips the MCP prefix so direct calls and MCP-wrapped calls share the
@@ -48,6 +53,13 @@ const SCOPED_CACHEABLE_TOOLS = new Set([
48
53
  'list',
49
54
  'glob',
50
55
  ]);
56
+ let codeGraphRuntimePromise = null;
57
+ async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
58
+ codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
59
+ const mod = await codeGraphRuntimePromise;
60
+ if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
61
+ return mod.executeCodeGraphTool(name, args, cwd, signal, options);
62
+ }
51
63
  function _isScopedCacheableTool(name) {
52
64
  const n = _stripMcpPrefix(name);
53
65
  return SCOPED_CACHEABLE_TOOLS.has(n);
@@ -79,27 +91,25 @@ function _intraTurnSig(name, args) {
79
91
  return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
80
92
  }
81
93
 
82
- // Shared pre-dispatch deny — single source of truth for role/scope/permission
83
- // rejects. Called by BOTH the eager dispatch path (startEagerTool) and the
84
- // serial dispatch path (executeTool body). Returns null when the call is
85
- // allowed to proceed; otherwise returns the Error string the serial path
86
- // would emit. The eager caller ignores the message body and just treats
87
- // non-null as "do not start eager".
94
+ // Shared pre-dispatch deny — single source of truth for the remaining
95
+ // control-plane / role scoping rejects. Called by BOTH the eager dispatch
96
+ // path (startEagerTool) and the serial dispatch path (executeTool body).
97
+ // Returns null when the call is allowed to proceed; otherwise returns the
98
+ // Error string the serial path would emit. The eager caller ignores the
99
+ // message body and just treats non-null as "do not start eager".
88
100
  //
89
- // Predicates are kept in the same order as the legacy serial branch so a
90
- // bridge-owned control-plane tool fails on _bridgeOwned+_controlPlaneTool
91
- // FIRST (not on permission/wrapper checks) matches the prior wording.
92
- // Bridge workers are sandboxed to code/research tools. They must never reach
101
+ // This is NOT a permission gate runtime permission enforcement was removed
102
+ // (every tool call is trusted). What remains is architectural scoping:
103
+ // agent workers are sandboxed to code/research tools. They must never reach
93
104
  // owner/host control surfaces: session management, the ENTIRE channels module
94
105
  // (Discord messaging, schedules, webhook/config, channel-bridge toggle,
95
106
  // command injection), or host input injection. Explicit name list (no imports)
96
107
  // keeps this hot-path gate dependency-free; add new owner/channel tools here.
97
108
  const WORKER_DENIED_TOOLS = new Set([
98
- // session control-plane — unified into the single `bridge` tool
109
+ // session control-plane — unified into the single `agent` tool
99
110
  // (type=spawn|send|close|list). Denying the one name blocks all worker
100
- // session control. Legacy names kept for defense-in-depth against any
101
- // stale catalog entry that still advertises them.
102
- 'bridge', 'close_session', 'list_sessions', 'create_session',
111
+ // session control.
112
+ 'agent',
103
113
  // channels module (owner/Discord-facing)
104
114
  'reply', 'react', 'edit_message', 'download_attachment', 'fetch',
105
115
  'schedule_status', 'trigger_schedule', 'schedule_control',
@@ -110,29 +120,16 @@ const WORKER_DENIED_TOOLS = new Set([
110
120
  function _preDispatchDeny(call, toolKind, sessionRef) {
111
121
  const name = call?.name;
112
122
  if (typeof name !== 'string' || !name) return null;
113
- const _bridgeOwned = sessionRef?.scope?.startsWith?.('bridge:') || sessionRef?.owner === 'bridge';
123
+ const _agentOwned = sessionRef?.scope?.startsWith?.('agent:')
124
+ || isAgentOwner(sessionRef);
114
125
  const _controlPlaneTool = WORKER_DENIED_TOOLS.has(name);
115
- if (_bridgeOwned && _controlPlaneTool) {
116
- return `Error: control-plane tool "${name}" is Lead-only and not available to bridge agents.`;
126
+ if (_agentOwned && _controlPlaneTool) {
127
+ return `Error: control-plane tool "${name}" is Lead-only and not available to agent workers.`;
117
128
  }
118
129
  const noToolRole = sessionRef?.role === 'cycle1-agent' || sessionRef?.role === 'cycle2-agent';
119
130
  if (noToolRole) {
120
131
  return `Error: tool "${name}" is not available in role "${sessionRef.role}". Re-emit the answer as pipe-separated text per the role's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
121
132
  }
122
- if (isBlockedHiddenWrapperCall(name, sessionRef)) {
123
- return `Error: tool "${name}" is the wrapper your role (${sessionRef?.role || 'hidden'}) backs. Calling it would spawn another hidden agent of the same kind — use direct read/grep/glob/code_graph instead.`;
124
- }
125
- const effectivePermission = effectiveToolPermission(sessionRef);
126
- const permissionBlocked = isBlockedByPermission(name, toolKind, effectivePermission);
127
- if (permissionBlocked && effectivePermission === 'mcp') {
128
- return `Error: tool "${name}" is not available on this session (permission=mcp). Use MCP/internal retrieval tools only.`;
129
- }
130
- if (permissionBlocked && effectivePermission === 'read') {
131
- return `Error: tool "${name}" is not available on this session (permission=read). Use Mixdog MCP read/grep/glob/recall/search/explore instead.`;
132
- }
133
- if (permissionBlocked && effectivePermission && typeof effectivePermission === 'object') {
134
- return `Error: tool "${name}" is not permitted on this session by the role's allow/deny permission policy.`;
135
- }
136
133
  return null;
137
134
  }
138
135
  /** Exported for smoke tests — same runtime deny as the agent loop. */
@@ -142,26 +139,12 @@ export function preDispatchDenyForSession(sessionRef, call, toolKind = 'builtin'
142
139
  import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
143
140
 
144
141
 
145
- import { isHiddenRole } from '../internal-roles.mjs';
146
- import { createRequire } from 'module';
147
142
  import { readFileSync as _readFileSync } from 'fs';
148
143
  import { fileURLToPath } from 'url';
149
144
  import { dirname, resolve as resolvePath, isAbsolute } from 'path';
150
- // Load the CJS permission evaluator. The hooks/ directory lives above
151
- // src/runtime/agent/orchestrator/session/, so we walk up from __dirname.
152
- const _require = createRequire(import.meta.url);
153
- const _hooksLib = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
154
- const { evaluatePermission: _evaluatePermission } = _require(_hooksLib);
155
145
  const MCP_TOOL_PREFIX = 'mcp__plugin_mixdog_mixdog__';
156
146
  const COMPACT_SAFETY_PERCENT = 1.00;
157
147
  const COMPACT_BUFFER_MAX_WINDOW_FRACTION = 0.25;
158
- // Stricter budget used for the one-shot retry after a provider rejects a send
159
- // with a context-window-exceeded error. 0.60×contextWindow forces older
160
- // non-system history into a tighter compact summary when the pre-send estimate
161
- // under-counted provider-side bytes (tool schemas, framing,
162
- // provider-internal token accounting). Used exactly once per send; see the
163
- // retry block around provider.send below.
164
- const OVERFLOW_RETRY_COMPACT_PERCENT = 0.60;
165
148
 
166
149
  function estimateMessagesTokensSafe(messages) {
167
150
  try { return estimateMessagesTokens(messages); }
@@ -229,18 +212,18 @@ function mergeSteeringEntries(entries) {
229
212
  return { content: parts, text: displayText || steeringContentText(parts), count: normalized.length };
230
213
  }
231
214
 
232
- class BridgeContextOverflowError extends Error {
215
+ class AgentContextOverflowError extends Error {
233
216
  constructor({ stage, sessionId, provider, model, contextWindow, budgetTokens, reserveTokens, messageTokensEst }, cause) {
234
217
  const target = [provider, model].filter(Boolean).join('/') || 'target model';
235
218
  const causeMsg = cause && cause.message ? `: ${cause.message}` : '';
236
219
  super(
237
- `bridge context overflow (${target}, stage=${stage || 'compact'}): ` +
220
+ `agent context overflow (${target}, stage=${stage || 'compact'}): ` +
238
221
  `latest turn cannot fit target context budget=${budgetTokens ?? 'unknown'} ` +
239
222
  `reserve=${reserveTokens ?? 'unknown'} contextWindow=${contextWindow ?? 'unknown'} ` +
240
223
  `messageTokensEst=${messageTokensEst ?? 'unknown'}${causeMsg}`,
241
224
  );
242
- this.name = 'BridgeContextOverflowError';
243
- this.code = 'BRIDGE_CONTEXT_OVERFLOW';
225
+ this.name = 'AgentContextOverflowError';
226
+ this.code = 'AGENT_CONTEXT_OVERFLOW';
244
227
  this.sessionId = sessionId || null;
245
228
  this.provider = provider || null;
246
229
  this.model = model || null;
@@ -252,8 +235,8 @@ class BridgeContextOverflowError extends Error {
252
235
  }
253
236
  }
254
237
 
255
- function bridgeContextOverflowError({ stage, sessionId, sessionRef, model, budgetTokens, reserveTokens, messageTokensEst }, cause) {
256
- return new BridgeContextOverflowError({
238
+ function agentContextOverflowError({ stage, sessionId, sessionRef, model, budgetTokens, reserveTokens, messageTokensEst }, cause) {
239
+ return new AgentContextOverflowError({
257
240
  stage,
258
241
  sessionId,
259
242
  provider: sessionRef?.provider || null,
@@ -266,7 +249,7 @@ function bridgeContextOverflowError({ stage, sessionId, sessionRef, model, budge
266
249
  }
267
250
 
268
251
  // Cache-hit results always inline the cached body. The earlier size-gated
269
- // `[cache-hit-ref]` branch confused bridge agents whose context did not
252
+ // `[cache-hit-ref]` branch confused agents whose context did not
270
253
  // contain the referenced prior tool_result, triggering shell-cat detours.
271
254
  // Hard iteration ceiling for every agent loop. Reset to 0 whenever the
272
255
  // transcript is compacted (see the trim block below): a long task that keeps
@@ -366,28 +349,22 @@ function _ensureTranscriptPairing(msgs, sessionId) {
366
349
  }
367
350
  }
368
351
 
369
- // Write-class tools that a permission=read session must not execute. The
370
- // schema still advertises them to keep one unified shard; this runtime set
371
- // is the fail-safe reject at call time.
372
- const READ_BLOCKED_TOOLS = new Set([
373
- 'shell', 'bash_session',
374
- 'apply_patch',
375
- ]);
376
- const MCP_ONLY_ALLOWED_KINDS = new Set(['mcp', 'internal', 'skill']);
377
- // Wrappers that hidden retrieval roles back. Hidden roles MUST NOT call
378
- // these or they spawn another hidden agent of the same kind — nested chain
379
- // + token burn. Block at call time; the role's rule prompt also says so.
380
- const RETRIEVAL_WRAPPERS = new Set(['recall', 'search', 'explore']);
381
- // Hidden roles that may call specific retrieval wrappers. Default policy
382
- // blocks all hidden→wrapper calls; roles listed here have a documented
383
- // need:
384
- // - scheduler-task / webhook-handler: state-changing agents whose
385
- // tasks routinely require both reach-back into past context
386
- // (`recall`) and fresh external info (`search`).
387
- const HIDDEN_ROLE_WRAPPER_ALLOWLIST = {
388
- 'scheduler-task': new Set(['recall', 'search']),
389
- 'webhook-handler': new Set(['recall', 'search']),
390
- };
352
+ /**
353
+ * Pre-provider transcript repair for the agent loop. Reattach valid tool
354
+ * results (non-destructive) before any destructive orphan pairing cleanup.
355
+ * Mutates `messages` in place to preserve the session array reference.
356
+ */
357
+ export function repairTranscriptBeforeProviderSend(messages, sessionId = null) {
358
+ if (!Array.isArray(messages)) return messages;
359
+ const sanitized = sanitizeToolPairs(messages);
360
+ if (sanitized !== messages) {
361
+ messages.length = 0;
362
+ messages.push(...sanitized);
363
+ }
364
+ _ensureTranscriptPairing(messages, sessionId);
365
+ return messages;
366
+ }
367
+
391
368
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
392
369
  // to execute during SSE parsing so tool work overlaps with the rest of the
393
370
  // stream. Writes, bash, MCP and skills stay serial after send() returns.
@@ -396,94 +373,6 @@ function isEagerDispatchable(name, tools) {
396
373
  const def = tools.find(t => t?.name === name);
397
374
  return def?.annotations?.readOnlyHint === true;
398
375
  }
399
- // ── Bridge-worker permission enforcement ──────────────────────────────────────
400
- // Mirrors the PreToolUse hook evaluation for tool calls that originate inside a
401
- // bridge worker session. Worker dispatch previously bypassed the hook pipeline
402
- // entirely; this guard closes that gap by running the same evaluator inline.
403
- //
404
- // `ask` is treated as deny here — forwarding `ask` decisions to the channel
405
- // UI approval flow needs bidirectional prompt plumbing that does not exist.
406
- function _checkWorkerPermission(toolName, toolInput, sessionRef) {
407
- const bareToolName = _stripMcpPrefix(toolName);
408
- if (sessionRef?.owner === 'bridge' && bareToolName === 'shell') {
409
- const cmdClass = classifyBashFileLookupCommand(toolInput?.command);
410
- if (cmdClass) {
411
- return `Error: bridge worker shell file lookup blocked (${cmdClass}). Use Mixdog MCP read/grep/glob/list directly; shell is only for build/test/run/git-style commands.`;
412
- }
413
- const gitClass = classifyBridgeWorkerGitMutationCommand(toolInput?.command);
414
- if (gitClass) {
415
- return `Error: bridge worker git operation blocked (${gitClass}). Git operations are deferred to Lead.`;
416
- }
417
- }
418
- // Even when no explicit permissionMode is propagated to the worker, run
419
- // the evaluator under the most restrictive baseline ('default') so the
420
- // bypass-proof hard-deny patterns (UNC paths, /etc, C:/Windows, etc.)
421
- // and the user's settings.json deny rules still apply. Previously a
422
- // missing permissionMode short-circuited to null and the worker
423
- // ran ungated — a model could dispatch a bridge to read or write
424
- // protected paths even when the same call would have been denied for
425
- // the parent. Callers that genuinely need bypassPermissions can still
426
- // forward it explicitly via session-builder; this only closes the
427
- // silent default-to-bypass path.
428
- const permissionMode = sessionRef?.permissionMode || 'default';
429
- // Prefix bare mixdog tool names so the evaluator path-logic handles them correctly.
430
- const fullName = toolName.startsWith(MCP_TOOL_PREFIX) || toolName.startsWith('mcp__')
431
- ? toolName
432
- : `${MCP_TOOL_PREFIX}${toolName}`;
433
- const projectDir = sessionRef?.cwd || undefined;
434
- const userCwd = sessionRef?.cwd || undefined;
435
- try {
436
- const { decision, reason } = _evaluatePermission({
437
- toolName: fullName,
438
- toolInput: toolInput || {},
439
- permissionMode,
440
- projectDir,
441
- userCwd,
442
- });
443
- if (decision === 'deny' || decision === 'ask') {
444
- return `Error: tool "${toolName}" blocked by permission evaluator (decision=${decision}): ${reason}`;
445
- }
446
- } catch (err) {
447
- // Evaluator errors must not crash the loop — log and allow.
448
- try { process.stderr.write(`[permission-evaluator] error: ${err?.message}\n`); } catch {}
449
- }
450
- return null;
451
- }
452
- function effectiveToolPermission(sessionRef) {
453
- return sessionRef?.toolPermission || sessionRef?.permission || null;
454
- }
455
- function isBlockedByPermission(toolName, toolKind, permission) {
456
- if (permission === 'mcp') return !MCP_ONLY_ALLOWED_KINDS.has(toolKind);
457
- if (permission === 'read') return READ_BLOCKED_TOOLS.has(toolName);
458
- // Object-form {allow,deny} permission (role template / profile). The
459
- // schema-level intersection in createSession only narrows the ADVERTISED
460
- // tool list; it is not a runtime execution boundary. Enforce the same
461
- // allow/deny here as the fail-safe so a tool call for a non-advertised
462
- // (denied / out-of-allow) tool is rejected at dispatch time, matching
463
- // the string-form ('read'/'mcp') guards. Names are compared bare +
464
- // lowercased to mirror createSession's allow/deny set construction.
465
- if (permission && typeof permission === 'object') {
466
- const name = String(_stripMcpPrefix(toolName) || '').toLowerCase();
467
- const deny = Array.isArray(permission.deny) && permission.deny.length > 0
468
- ? permission.deny.map(n => String(n).toLowerCase())
469
- : null;
470
- if (deny && deny.includes(name)) return true;
471
- const allow = Array.isArray(permission.allow) && permission.allow.length > 0
472
- ? permission.allow.map(n => String(n).toLowerCase())
473
- : null;
474
- if (allow && !allow.includes(name)) return true;
475
- return false;
476
- }
477
- return false;
478
- }
479
- function isBlockedHiddenWrapperCall(toolName, sessionRef) {
480
- if (!RETRIEVAL_WRAPPERS.has(toolName)) return false;
481
- if (sessionRef?.owner !== 'bridge') return false;
482
- if (!isHiddenRole(sessionRef?.role)) return false;
483
- const allow = HIDDEN_ROLE_WRAPPER_ALLOWLIST[sessionRef.role];
484
- if (allow && allow.has(toolName)) return false;
485
- return true;
486
- }
487
376
  function messagesArrayChanged(before, after) {
488
377
  if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
489
378
  if (before.length !== after.length) return true;
@@ -522,38 +411,6 @@ function addUsage(total, usage) {
522
411
  }
523
412
  return next;
524
413
  }
525
- function splitMessagesForRemoteCompact(messages) {
526
- if (!Array.isArray(messages)) return null;
527
- const system = messages.filter(m => m?.role === 'system');
528
- const nonSystem = messages.filter(m => m?.role !== 'system');
529
- let turnStart = -1;
530
- for (let i = nonSystem.length - 1; i >= 0; i -= 1) {
531
- if (nonSystem[i]?.role === 'user') {
532
- turnStart = i;
533
- break;
534
- }
535
- }
536
- if (turnStart <= 0) return null;
537
- const oldHistory = nonSystem.slice(0, turnStart);
538
- if (!oldHistory.length) return null;
539
- return [...system, ...oldHistory];
540
- }
541
- function markRemoteCompactFallback(messages, providerName) {
542
- if (!Array.isArray(messages)) return false;
543
- for (const m of messages) {
544
- if (m?.role !== 'user' || typeof m.content !== 'string') continue;
545
- if (!m.content.startsWith(SUMMARY_PREFIX)) continue;
546
- m._mixdogRemoteCompactFallback = 'openai-codex';
547
- m._mixdogRemoteCompactProvider = providerName || 'openai-oauth';
548
- return true;
549
- }
550
- return false;
551
- }
552
- function providerRemoteCompactEnabled(provider, opts) {
553
- if (opts?.remoteCompact === false) return false;
554
- if (process.env.MIXDOG_REMOTE_COMPACT === '0') return false;
555
- return typeof provider?.remoteCompactMessages === 'function';
556
- }
557
414
  function positiveTokenInt(value) {
558
415
  const n = Number(value);
559
416
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
@@ -567,35 +424,187 @@ function envTokenInt(name) {
567
424
  return positiveTokenInt(process.env[name]);
568
425
  }
569
426
  function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
570
- const env = process.env.MIXDOG_BRIDGE_COMPACT_SEMANTIC;
571
- if (env !== undefined) return envFlag('MIXDOG_BRIDGE_COMPACT_SEMANTIC', true);
427
+ if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
572
428
  if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
573
429
  if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on') return true;
574
- // OpenCode keeps compaction as a session concern. For Mixdog, default the
575
- // semantic agent only on bridge-owned workers so direct user sessions and
576
- // narrow tests do not pay an extra provider call unless they opt in.
577
- return sessionRef?.owner === 'bridge';
430
+ // The compact type is already explicit (`semantic` by default). Honor it
431
+ // directly instead of substituting another compaction path.
432
+ return true;
433
+ }
434
+
435
+ function resolveCompactTypeSetting(_sessionRef, cfg = {}) {
436
+ const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
437
+ ?? process.env.MIXDOG_COMPACT_TYPE
438
+ ?? cfg.type
439
+ ?? cfg.compactType
440
+ ?? cfg.compact_type;
441
+ return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
442
+ }
443
+
444
+ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
445
+ if (!sessionId) throw new Error('recall-fasttrack requires a session id');
446
+ const query = `session:${sessionId}:all-chunks`;
447
+ const querySha = createHash('sha256').update(query).digest('hex').slice(0, 16);
448
+ const callerCtx = {
449
+ callerSessionId: sessionId || null,
450
+ callerCwd: sessionRef?.cwd || undefined,
451
+ routingSessionId: sessionId || null,
452
+ clientHostPid: sessionRef?.clientHostPid,
453
+ signal: signal || null,
454
+ };
455
+ const hydrateLimit = positiveTokenInt(sessionRef?.compaction?.recallIngestLimit)
456
+ || Math.max(500, Math.min(5000, messages.length || 0));
457
+ try {
458
+ await executeInternalTool('memory', {
459
+ action: 'ingest_session',
460
+ sessionId,
461
+ messages,
462
+ cwd: sessionRef?.cwd,
463
+ limit: hydrateLimit,
464
+ }, callerCtx);
465
+ } catch (err) {
466
+ try { process.stderr.write(`[loop] recall-fasttrack ingest skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
467
+ }
468
+ const dumpArgs = {
469
+ action: 'dump_session_roots',
470
+ sessionId,
471
+ includeRaw: true,
472
+ limit: positiveTokenInt(sessionRef?.compaction?.recallChunkLimit ?? sessionRef?.compaction?.recallLimit) || hydrateLimit,
473
+ };
474
+ const runTool = (name, args) => executeInternalTool(name, args, callerCtx);
475
+ let recallText = await executeInternalTool('memory', dumpArgs, callerCtx);
476
+ let cycle1Text = '';
477
+ const hasRawRows = /(?:^|\n)# raw_pending\s+\d+\s+id=/i.test(String(recallText || ''));
478
+ if (hasRawRows) {
479
+ try {
480
+ // Drain this session's cycle1 in window×concurrency units until no
481
+ // raw rows remain, so the injected root is fully chunked rather than
482
+ // carrying the unprocessed transcript tail (single-pass left raw in).
483
+ const drained = await drainSessionCycle1(runTool, {
484
+ sessionId,
485
+ dumpArgs,
486
+ deadlineMs: positiveTokenInt(sessionRef?.compaction?.recallCycle1DeadlineMs) || 120_000,
487
+ maxPasses: positiveTokenInt(sessionRef?.compaction?.recallCycle1MaxPasses) || 0,
488
+ cycleArgs: {
489
+ min_batch: 1,
490
+ session_cap: 1,
491
+ batch_size: positiveTokenInt(sessionRef?.compaction?.recallCycle1BatchSize) || 100,
492
+ rows_per_session: positiveTokenInt(sessionRef?.compaction?.recallRowsPerSession) || 100,
493
+ window_size: positiveTokenInt(sessionRef?.compaction?.recallWindowSize) || 20,
494
+ concurrency: positiveTokenInt(sessionRef?.compaction?.recallConcurrency) || 5,
495
+ },
496
+ });
497
+ recallText = drained.recallText;
498
+ cycle1Text = drained.cycle1Text;
499
+ if (drained.rawRemaining > 0) {
500
+ try { process.stderr.write(`[loop] recall-fasttrack drained passes=${drained.passes} rawRemaining=${drained.rawRemaining} (sess=${sessionId || 'unknown'})\n`); } catch {}
501
+ }
502
+ } catch (err) {
503
+ try { process.stderr.write(`[loop] recall-fasttrack cycle1 skipped (sess=${sessionId || 'unknown'}): ${err?.message || err}\n`); } catch {}
504
+ }
505
+ } else {
506
+ cycle1Text = 'cycle1: skipped (session chunks already hydrated)';
507
+ }
508
+ return recallFastTrackCompactMessages(messages, compactBudgetTokens, {
509
+ reserveTokens: compactPolicy.reserveTokens,
510
+ force: true,
511
+ recallText: [`session_id=${sessionId}`, cycle1Text, recallText].map(v => String(v || '').trim()).filter(Boolean).join('\n\n'),
512
+ query,
513
+ querySha,
514
+ allowEmptyRecall: true,
515
+ tailTurns: compactPolicy.tailTurns,
516
+ keepTokens: compactPolicy.keepTokens,
517
+ preserveRecentTokens: compactPolicy.preserveRecentTokens,
518
+ });
519
+ }
520
+ // Percent-named inputs (bufferPercent / bufferPct / *_BUFFER_PERCENT) carry a
521
+ // PERCENT: 1 means 1% (0.01). Ratio-named inputs (bufferRatio / bufferFraction)
522
+ // carry a fraction: 0.01 means 1%, and a legacy value > 1 is read as a percent
523
+ // (10 -> 10%). The shared normalizeCompactionBufferRatio only handles the ratio
524
+ // convention, so resolve percent-named values to a fraction first.
525
+ function _resolveBufferRatioCandidate(percentInputs, ratioInputs) {
526
+ for (const raw of percentInputs) {
527
+ const n = Number(raw);
528
+ if (Number.isFinite(n) && n > 0) return Math.min(1, n / 100);
529
+ }
530
+ for (const raw of ratioInputs) {
531
+ const n = Number(raw);
532
+ if (Number.isFinite(n) && n > 0) return n > 1 ? n / 100 : n;
533
+ }
534
+ return null;
578
535
  }
579
536
  function resolveCompactBufferRatio(cfg = {}) {
580
- return normalizeCompactionBufferRatio(
581
- cfg.bufferPercent
582
- ?? cfg.bufferPct
583
- ?? cfg.bufferRatio
584
- ?? cfg.bufferFraction
585
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_PERCENT
586
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_RATIO,
587
- DEFAULT_COMPACTION_BUFFER_RATIO,
537
+ const resolved = _resolveBufferRatioCandidate(
538
+ [cfg.bufferPercent, cfg.bufferPct, process.env.MIXDOG_AGENT_COMPACT_BUFFER_PERCENT],
539
+ [cfg.bufferRatio, cfg.bufferFraction, process.env.MIXDOG_AGENT_COMPACT_BUFFER_RATIO],
588
540
  );
541
+ return normalizeCompactionBufferRatio(resolved, DEFAULT_COMPACTION_BUFFER_RATIO);
542
+ }
543
+ const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
544
+ function isPersistedZeroBufferTelemetry(cfg = {}, boundaryTokens = 0) {
545
+ const boundary = positiveTokenInt(boundaryTokens);
546
+ if (!boundary) return false;
547
+ if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
548
+ for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
549
+ const n = Number(process.env[envName]);
550
+ if (Number.isFinite(n) && n > 0) return false;
551
+ }
552
+ for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
553
+ const n = Number(cfg?.[key]);
554
+ if (Number.isFinite(n) && n > 0) return false;
555
+ }
556
+ const ratio = Number(cfg?.bufferRatio);
557
+ if (Number.isFinite(ratio) && ratio > 0) return false;
558
+ const explicitTokens = Number(cfg?.bufferTokens ?? cfg?.buffer);
559
+ if (!Number.isFinite(explicitTokens) || explicitTokens !== 0) return false;
560
+ return true;
561
+ }
562
+ function isLegacyDefaultBufferTelemetry(cfg = {}, boundaryTokens = 0) {
563
+ const boundary = positiveTokenInt(boundaryTokens);
564
+ if (!boundary) return false;
565
+ if (envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')) return false;
566
+ for (const envName of ['MIXDOG_AGENT_COMPACT_BUFFER_PERCENT', 'MIXDOG_AGENT_COMPACT_BUFFER_RATIO']) {
567
+ const n = Number(process.env[envName]);
568
+ if (Number.isFinite(n) && n > 0) return false;
569
+ }
570
+ // Percent/fraction-named fields are operator config. The legacy default
571
+ // telemetry persisted bufferTokens + bufferRatio after a check/compact pass.
572
+ for (const key of ['bufferPercent', 'bufferPct', 'bufferFraction']) {
573
+ const n = Number(cfg?.[key]);
574
+ if (Number.isFinite(n) && n > 0) return false;
575
+ }
576
+ const explicitTokens = positiveTokenInt(cfg?.bufferTokens ?? cfg?.buffer);
577
+ const ratio = Number(cfg?.bufferRatio);
578
+ if (!explicitTokens || !Number.isFinite(ratio) || Math.abs(ratio - LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO) > 1e-9) return false;
579
+ const expectedTokens = Math.floor(boundary * LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO);
580
+ const cfgBoundary = positiveTokenInt(cfg?.boundaryTokens);
581
+ const cfgTrigger = positiveTokenInt(cfg?.triggerTokens);
582
+ return explicitTokens === expectedTokens
583
+ || (cfgBoundary === boundary && cfgTrigger > 0 && explicitTokens === Math.max(0, boundary - cfgTrigger));
584
+ }
585
+ function compactBufferConfigForBoundary(cfg = {}, boundaryTokens = 0) {
586
+ const base = cfg || {};
587
+ if (!isLegacyDefaultBufferTelemetry(base, boundaryTokens)
588
+ && !isPersistedZeroBufferTelemetry(base, boundaryTokens)) {
589
+ return base;
590
+ }
591
+ return {
592
+ ...base,
593
+ bufferTokens: null,
594
+ buffer: null,
595
+ bufferRatio: null,
596
+ };
589
597
  }
590
598
  function resolveCompactBufferTokens(boundaryTokens, cfg = {}) {
591
- const configured = positiveTokenInt(cfg.bufferTokens ?? cfg.buffer)
592
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_BUFFER_TOKENS')
593
- || 0;
594
599
  const boundary = positiveTokenInt(boundaryTokens);
600
+ const effectiveCfg = compactBufferConfigForBoundary(cfg, boundary);
601
+ const configured = positiveTokenInt(effectiveCfg.bufferTokens ?? effectiveCfg.buffer)
602
+ || envTokenInt('MIXDOG_AGENT_COMPACT_BUFFER_TOKENS')
603
+ || 0;
595
604
  if (!boundary) return configured || DEFAULT_COMPACTION_BUFFER_TOKENS;
596
605
  return compactionBufferTokensForBoundary(boundary, {
597
606
  explicitTokens: configured,
598
- ratio: resolveCompactBufferRatio(cfg),
607
+ ratio: resolveCompactBufferRatio(effectiveCfg),
599
608
  maxRatio: COMPACT_BUFFER_MAX_WINDOW_FRACTION,
600
609
  });
601
610
  }
@@ -607,7 +616,7 @@ function resolveCompactTargetRatio(cfg = {}) {
607
616
  ?? cfg.targetPct
608
617
  ?? cfg.targetRatio
609
618
  ?? cfg.targetFraction
610
- ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_PERCENT
619
+ ?? process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
611
620
  ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
612
621
  ?? COMPACT_TARGET_RATIO;
613
622
  const n = Number(raw);
@@ -618,15 +627,15 @@ function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
618
627
  const boundary = positiveTokenInt(boundaryTokens);
619
628
  if (!boundary) return null;
620
629
  const explicit = positiveTokenInt(cfg.targetTokens ?? cfg.target)
621
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_TARGET_TOKENS')
630
+ || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_TOKENS')
622
631
  || envTokenInt('MIXDOG_COMPACT_TARGET_TOKENS');
623
632
  if (explicit) return Math.max(1, Math.min(boundary, explicit));
624
633
  const minTarget = Math.min(boundary, positiveTokenInt(cfg.targetMinTokens ?? cfg.minTargetTokens)
625
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_TARGET_MIN_TOKENS')
634
+ || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MIN_TOKENS')
626
635
  || envTokenInt('MIXDOG_COMPACT_TARGET_MIN_TOKENS')
627
636
  || COMPACT_TARGET_MIN_TOKENS);
628
637
  const maxTarget = Math.min(boundary, positiveTokenInt(cfg.targetMaxTokens ?? cfg.maxTargetTokens)
629
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_TARGET_MAX_TOKENS')
638
+ || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MAX_TOKENS')
630
639
  || envTokenInt('MIXDOG_COMPACT_TARGET_MAX_TOKENS')
631
640
  || COMPACT_TARGET_MAX_TOKENS);
632
641
  const byRatio = Math.max(1, Math.floor(boundary * resolveCompactTargetRatio(cfg)));
@@ -634,13 +643,13 @@ function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
634
643
  }
635
644
  function resolveCompactKeepTokens(cfg = {}) {
636
645
  return positiveTokenInt(cfg.keepTokens ?? cfg.keep?.tokens ?? cfg.preserveRecentTokens)
637
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_KEEP_TOKENS')
646
+ || envTokenInt('MIXDOG_AGENT_COMPACT_KEEP_TOKENS')
638
647
  || DEFAULT_COMPACTION_KEEP_TOKENS;
639
648
  }
640
649
  function resolveWorkerCompactPolicy(sessionRef, tools) {
641
650
  if (!sessionRef) return null;
642
651
  const cfg = sessionRef.compaction || {};
643
- const auto = cfg.auto !== false && envFlag('MIXDOG_BRIDGE_COMPACT_AUTO', true);
652
+ const auto = cfg.auto !== false && envFlag('MIXDOG_AGENT_COMPACT_AUTO', true);
644
653
  if (!auto) return { auto: false };
645
654
  const contextWindow = positiveTokenInt(sessionRef.contextWindow ?? cfg.contextWindow);
646
655
  const explicitBoundary = positiveTokenInt(sessionRef.compactBoundaryTokens ?? cfg.boundaryTokens);
@@ -650,28 +659,33 @@ function resolveWorkerCompactPolicy(sessionRef, tools) {
650
659
  : (explicitBoundary || contextWindow || autoLimit);
651
660
  if (!boundaryTokens) return null;
652
661
  const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
653
- const autoTriggerTokens = autoLimit && autoLimit <= compactBoundaryTokens ? Math.max(1, autoLimit) : null;
662
+ // Only an explicit auto-compact limit STRICTLY BELOW the boundary acts as
663
+ // the trigger. A persisted value == boundary (legacy derived full-window
664
+ // autoCompactTokenLimit) would set autoTriggerTokens == boundary and
665
+ // collapse/override the default trigger, so it is ignored in favor of the
666
+ // default boundary trigger.
667
+ const autoTriggerTokens = autoLimit && autoLimit < compactBoundaryTokens ? Math.max(1, autoLimit) : null;
668
+ // Sanitized explicit limit: only a sub-boundary value is a real auto-compact
669
+ // limit. Anything >= boundary is a legacy derived full-window artifact and
670
+ // is reported as null so rememberCompactTelemetry does not re-persist it
671
+ // back onto the session and re-collapse the buffer on the next turn.
672
+ const explicitAutoCompactTokenLimit = autoTriggerTokens;
654
673
  const bufferTokens = autoTriggerTokens
655
674
  ? Math.max(0, compactBoundaryTokens - autoTriggerTokens)
656
675
  : resolveCompactBufferTokens(compactBoundaryTokens, cfg);
657
676
  const bufferRatio = compactBoundaryTokens ? (bufferTokens / compactBoundaryTokens) : resolveCompactBufferRatio(cfg);
658
677
  const triggerTokens = autoTriggerTokens || Math.max(1, compactBoundaryTokens - bufferTokens);
659
678
  const configuredReserve = positiveTokenInt(cfg.reservedTokens)
660
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_RESERVED_TOKENS')
679
+ || envTokenInt('MIXDOG_AGENT_COMPACT_RESERVED_TOKENS')
661
680
  || 0;
662
681
  const requestReserve = estimateRequestReserveTokens(tools);
663
682
  const keepTokens = resolveCompactKeepTokens(cfg);
683
+ const compactType = resolveCompactTypeSetting(sessionRef, cfg);
664
684
  return {
665
685
  auto: true,
666
- prune: cfg.prune === true || envFlag('MIXDOG_BRIDGE_COMPACT_PRUNE', false),
667
- // Narrow active-turn fallback (bridge/worker only). When system +
668
- // current turn alone overflow the budget, shrink older same-turn tool
669
- // outputs / drop older same-turn groups before declaring overflow,
670
- // preserving system + task user + the latest group(s) and tool
671
- // pairing. Defaults on for workers; disable via env to restore the
672
- // strict no-fallback behavior.
673
- activeTurnFallback: cfg.activeTurnFallback !== false
674
- && envFlag('MIXDOG_BRIDGE_COMPACT_ACTIVE_TURN_FALLBACK', true),
686
+ type: compactType,
687
+ compactType,
688
+ prune: cfg.prune === true || envFlag('MIXDOG_AGENT_COMPACT_PRUNE', false),
675
689
  boundaryTokens: compactBoundaryTokens,
676
690
  triggerTokens,
677
691
  bufferTokens,
@@ -681,35 +695,30 @@ function resolveWorkerCompactPolicy(sessionRef, tools) {
681
695
  effectiveContextWindowPercent: Number.isFinite(Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent))
682
696
  ? Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent)
683
697
  : null,
684
- autoCompactTokenLimit: positiveTokenInt(sessionRef.autoCompactTokenLimit ?? cfg.autoCompactTokenLimit),
685
- semantic: resolveSemanticCompactSetting(sessionRef, cfg),
686
- semanticTimeoutMs: positiveTokenInt(cfg.timeoutMs) || envTokenInt('MIXDOG_BRIDGE_COMPACT_TIMEOUT_MS') || 30_000,
687
- tailTurns: positiveTokenInt(cfg.tailTurns) || envTokenInt('MIXDOG_BRIDGE_COMPACT_TAIL_TURNS') || 2,
698
+ autoCompactTokenLimit: explicitAutoCompactTokenLimit,
699
+ semantic: compactTypeIsSemantic(compactType) && resolveSemanticCompactSetting(sessionRef, cfg),
700
+ recallFastTrack: compactTypeIsRecallFastTrack(compactType),
701
+ semanticTimeoutMs: positiveTokenInt(cfg.timeoutMs) || envTokenInt('MIXDOG_AGENT_COMPACT_TIMEOUT_MS') || 30_000,
702
+ tailTurns: positiveTokenInt(cfg.tailTurns) || envTokenInt('MIXDOG_AGENT_COMPACT_TAIL_TURNS') || 2,
688
703
  keepTokens,
689
- preserveRecentTokens: positiveTokenInt(cfg.preserveRecentTokens) || envTokenInt('MIXDOG_BRIDGE_COMPACT_PRESERVE_RECENT_TOKENS') || keepTokens,
704
+ preserveRecentTokens: positiveTokenInt(cfg.preserveRecentTokens) || envTokenInt('MIXDOG_AGENT_COMPACT_PRESERVE_RECENT_TOKENS') || keepTokens,
690
705
  reserveTokens: requestReserve + configuredReserve,
691
706
  requestReserveTokens: requestReserve,
692
707
  configuredReserveTokens: configuredReserve,
693
708
  };
694
709
  }
695
- function latestProviderContextTokens(sessionRef) {
696
- const tokens = positiveTokenInt(sessionRef?.lastContextTokens);
697
- if (!tokens) return 0;
698
- const compactAt = positiveTokenInt(sessionRef?.compaction?.lastChangedAt ?? sessionRef?.compaction?.lastCompactAt) || 0;
699
- const usageAt = positiveTokenInt(sessionRef?.lastContextTokensUpdatedAt) || 0;
700
- // Legacy sessions did not stamp usage. Treat that usage as usable until a
701
- // new compaction boundary appears; after compaction, only post-compaction
702
- // provider usage may pressure the next auto-compact decision.
703
- if (compactAt > 0 && usageAt > 0 && usageAt <= compactAt) return 0;
704
- if (compactAt > 0 && usageAt === 0) return 0;
705
- return tokens;
706
- }
707
- function compactPressureTokens(messageTokensEst, policy, sessionRef) {
708
- const estimated = messageTokensEst === null
709
- ? null
710
- : Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
711
- const providerReported = latestProviderContextTokens(sessionRef);
712
- return Math.max(estimated ?? 0, providerReported || 0);
710
+ /** Transcript + request reserve only (never provider lastContextTokens). */
711
+ function compactPressureTokens(messageTokensEst, policy) {
712
+ if (messageTokensEst === null) return 0;
713
+ return Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
714
+ }
715
+
716
+ /** Telemetry pressure when a reactive overflow retry forces the next compact. */
717
+ function compactionTelemetryPressureTokens(messageTokensEst, policy, { reactivePending = false } = {}) {
718
+ const base = compactPressureTokens(messageTokensEst, policy);
719
+ if (!reactivePending) return base;
720
+ const floor = positiveTokenInt(policy?.triggerTokens) || positiveTokenInt(policy?.boundaryTokens) || 0;
721
+ return floor ? Math.max(base, floor) : base;
713
722
  }
714
723
  function compactTargetBudget(policy) {
715
724
  const boundary = positiveTokenInt(policy?.boundaryTokens);
@@ -718,10 +727,11 @@ function compactTargetBudget(policy) {
718
727
  const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
719
728
  return Math.max(1, Math.min(boundary, targetEffective + reserve));
720
729
  }
721
- function shouldCompactForSession(messageTokensEst, policy, sessionRef) {
730
+ function shouldCompactForSession(messageTokensEst, policy, { forceReactive = false } = {}) {
722
731
  if (!policy?.auto || !policy.boundaryTokens) return false;
732
+ if (forceReactive) return true;
723
733
  if (messageTokensEst === null) return true;
724
- return compactPressureTokens(messageTokensEst, policy, sessionRef) >= (policy.triggerTokens || policy.boundaryTokens);
734
+ return compactPressureTokens(messageTokensEst, policy) >= (policy.triggerTokens || policy.boundaryTokens);
725
735
  }
726
736
  function countPrunedToolOutputs(before, after) {
727
737
  if (!Array.isArray(before) || !Array.isArray(after)) return 0;
@@ -754,7 +764,10 @@ function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
754
764
  rawContextWindow: policy.rawContextWindow || null,
755
765
  effectiveContextWindowPercent: policy.effectiveContextWindowPercent ?? null,
756
766
  autoCompactTokenLimit: policy.autoCompactTokenLimit || null,
767
+ type: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
768
+ compactType: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
757
769
  semantic: policy.semantic === true ? 'auto' : false,
770
+ recallFastTrack: policy.recallFastTrack === true,
758
771
  semanticModel: policy.semanticModel || null,
759
772
  semanticTimeoutMs: policy.semanticTimeoutMs || null,
760
773
  tailTurns: policy.tailTurns || null,
@@ -764,12 +777,26 @@ function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
764
777
  lastBeforeTokens: meta.beforeTokens ?? null,
765
778
  lastAfterTokens: meta.afterTokens ?? null,
766
779
  lastPressureTokens: meta.pressureTokens ?? null,
780
+ currentEstimatedTokens: meta.pressureTokens ?? prev.currentEstimatedTokens ?? null,
781
+ lastApiRequestTokens: positiveTokenInt(sessionRef?.lastContextTokens) || prev.lastApiRequestTokens || null,
767
782
  lastStage: meta.stage || prev.lastStage || null,
768
783
  lastChanged: changed,
769
- lastRemote: meta.remoteCompact === true,
784
+ lastTrigger: meta.trigger || prev.lastTrigger || null,
770
785
  lastSemantic: meta.semanticCompact === true,
771
- lastSemanticError: meta.semanticError || null,
786
+ lastSemanticError: Object.hasOwn(meta, 'semanticError')
787
+ ? (meta.semanticError ?? null)
788
+ : (prev.lastSemanticError ?? null),
789
+ lastRecallFastTrack: meta.recallFastTrack === true,
790
+ lastRecallFastTrackError: Object.hasOwn(meta, 'recallFastTrackError')
791
+ ? (meta.recallFastTrackError ?? null)
792
+ : (prev.lastRecallFastTrackError ?? null),
793
+ lastError: Object.hasOwn(meta, 'compactError') || Object.hasOwn(meta, 'lastError')
794
+ ? (meta.compactError ?? meta.lastError ?? null)
795
+ : (prev.lastError ?? null),
772
796
  lastPruneCount: meta.pruneCount || 0,
797
+ lastDurationMs: meta.durationMs != null && Number.isFinite(Number(meta.durationMs))
798
+ ? Math.max(0, Math.round(Number(meta.durationMs)))
799
+ : null,
773
800
  compactCount: (prev.compactCount || 0) + (changed ? 1 : 0),
774
801
  };
775
802
  if (changed) {
@@ -780,13 +807,32 @@ function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
780
807
  }
781
808
  sessionRef.contextWindow = policy.contextWindow || sessionRef.contextWindow;
782
809
  sessionRef.rawContextWindow = policy.rawContextWindow || sessionRef.rawContextWindow;
783
- sessionRef.autoCompactTokenLimit = policy.autoCompactTokenLimit || sessionRef.autoCompactTokenLimit || null;
784
810
  sessionRef.compactBoundaryTokens = policy.boundaryTokens || sessionRef.compactBoundaryTokens || null;
811
+ // Persist only the sanitized (sub-boundary) explicit limit. policy.autoCompactTokenLimit
812
+ // is already null for legacy derived full-window values, so a stale
813
+ // boundary-sized autoCompactTokenLimit on the session is cleared here rather
814
+ // than carried forward to re-collapse the buffer next turn.
815
+ {
816
+ const _boundary = positiveTokenInt(sessionRef.compactBoundaryTokens);
817
+ const _prevLimit = positiveTokenInt(sessionRef.autoCompactTokenLimit);
818
+ const _keepPrev = _prevLimit && (!_boundary || _prevLimit < _boundary) ? _prevLimit : null;
819
+ sessionRef.autoCompactTokenLimit = policy.autoCompactTokenLimit || _keepPrev || null;
820
+ }
785
821
  if (policy.effectiveContextWindowPercent !== null) {
786
822
  sessionRef.effectiveContextWindowPercent = policy.effectiveContextWindowPercent;
787
823
  }
788
824
  }
789
- const SKILL_TOOL_NAMES = new Set(['skills_list', 'skill_view', 'skill_execute']);
825
+
826
+ function emitCompactEvent(opts, event = {}) {
827
+ if (!opts || typeof opts.onCompactEvent !== 'function') return;
828
+ try { opts.onCompactEvent({ ts: Date.now(), ...event }); }
829
+ catch { /* best-effort UI/log hook */ }
830
+ }
831
+
832
+ function compactEventType(policy, fallback = DEFAULT_COMPACT_TYPE) {
833
+ return policy?.compactType || policy?.type || fallback;
834
+ }
835
+ const SKILL_TOOL_NAMES = new Set(['Skill', 'skills_list', 'skill_view']);
790
836
  const SPECIAL_TOOL_NAMES = new Set(['bash_session', 'apply_patch', 'code_graph']);
791
837
  const BASH_SESSION_HEADER_RE = /\[session: ([^\]\r\n]+)\]/;
792
838
  const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
@@ -902,12 +948,55 @@ function buildSkillsListResponse(cwd) {
902
948
  function viewSkill(cwd, name) {
903
949
  if (!name) return 'Error: skill name is required';
904
950
  const content = loadSkillContent(name, cwd);
905
- return content || `Error: skill "${name}" not found`;
951
+ if (!content) return `Error: skill "${name}" not found`;
952
+ return `<skill>\n<name>${String(name).replace(/[<>&]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[ch]))}</name>\n${content}\n</skill>`;
906
953
  }
907
- function executeSkill(cwd, name, _args) {
908
- if (!name) return 'Error: skill name is required';
909
- const content = loadSkillContent(name, cwd);
910
- return content || `Error: skill "${name}" not found`;
954
+
955
+ /** Normalize PostToolUse hook override values (legacy MCP text envelopes only). */
956
+ export function normalizeHookUpdatedToolOutput(value) {
957
+ if (typeof value === 'string') return value;
958
+ if (value == null) return '';
959
+ if (typeof value === 'object' && Array.isArray(value.content)) {
960
+ const hasNonText = value.content.some((c) => c && typeof c === 'object' && c.type && c.type !== 'text');
961
+ if (hasNonText) return value;
962
+ return value.content
963
+ .map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
964
+ .join('\n');
965
+ }
966
+ return value;
967
+ }
968
+
969
+ export function resolveToolResultAfterHook(originalResult, hookResult) {
970
+ if (!hookResult || typeof hookResult !== 'object' || hookResult.updatedToolOutput === undefined) {
971
+ return originalResult;
972
+ }
973
+ const updated = normalizeHookUpdatedToolOutput(hookResult.updatedToolOutput);
974
+ return updated === undefined ? originalResult : updated;
975
+ }
976
+
977
+ function parseNativeToolSearchPayload(toolName, result) {
978
+ if (toolName !== 'tool_search' || typeof result !== 'string') return null;
979
+ try {
980
+ const parsed = JSON.parse(result);
981
+ const native = parsed?.nativeToolSearch;
982
+ if (!native || typeof native !== 'object') return null;
983
+ const toolReferences = Array.isArray(native.toolReferences)
984
+ ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
985
+ : [];
986
+ const openaiTools = Array.isArray(native.openaiTools)
987
+ ? native.openaiTools.filter((tool) => tool && typeof tool === 'object')
988
+ : [];
989
+ if (!toolReferences.length && !openaiTools.length) return null;
990
+ return {
991
+ toolReferences,
992
+ openaiTools,
993
+ summary: typeof native.summary === 'string' && native.summary
994
+ ? native.summary
995
+ : `Loaded deferred tools: ${toolReferences.join(', ') || openaiTools.map((tool) => tool.name).filter(Boolean).join(', ')}`,
996
+ };
997
+ } catch {
998
+ return null;
999
+ }
911
1000
  }
912
1001
  function extractBashSessionId(result) {
913
1002
  if (typeof result !== 'string') return null;
@@ -915,8 +1004,8 @@ function extractBashSessionId(result) {
915
1004
  return match ? match[1] : null;
916
1005
  }
917
1006
 
918
- export function buildBridgeBashSessionArgs(args, sessionRef) {
919
- if (sessionRef?.owner !== 'bridge') return null;
1007
+ export function buildAgentBashSessionArgs(args, sessionRef) {
1008
+ if (!isAgentOwner(sessionRef)) return null;
920
1009
  // run_in_background is a detached one-shot job, incompatible with the
921
1010
  // persistent bash session. Fall through to the background-job path
922
1011
  // (executeBuiltinTool -> startBackgroundShellJob) so the worker gets a
@@ -938,6 +1027,51 @@ export function buildBridgeBashSessionArgs(args, sessionRef) {
938
1027
  return routedArgs;
939
1028
  }
940
1029
 
1030
+ export function formatMissingToolApprovalUiDenial(toolName, askReason) {
1031
+ const reason = String(askReason || 'approval requested by hook').trim();
1032
+ const name = String(toolName || 'tool');
1033
+ return `Error: tool "${name}" denied by hook: approval required but no approval UI is available${reason ? ` (${reason})` : ''}`;
1034
+ }
1035
+
1036
+ /**
1037
+ * Resolve PreToolUse `{ action: 'ask' }` against an optional approval callback.
1038
+ * Returns `{ denial }` when the tool must not run; otherwise `{ approval }`.
1039
+ */
1040
+ export async function resolvePreToolAskApproval({
1041
+ toolName,
1042
+ args,
1043
+ cwd,
1044
+ sessionId,
1045
+ toolCallId,
1046
+ askReason,
1047
+ toolApprovalHook,
1048
+ }) {
1049
+ const name = String(toolName || 'tool');
1050
+ const reason = String(askReason || 'approval requested by hook').trim();
1051
+ if (typeof toolApprovalHook !== 'function') {
1052
+ return { denial: formatMissingToolApprovalUiDenial(name, reason) };
1053
+ }
1054
+ let approval;
1055
+ try {
1056
+ approval = await toolApprovalHook({
1057
+ name,
1058
+ args,
1059
+ cwd,
1060
+ sessionId,
1061
+ toolCallId: toolCallId || null,
1062
+ reason,
1063
+ });
1064
+ } catch (error) {
1065
+ const detail = error?.message || String(error || 'approval failed');
1066
+ return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
1067
+ }
1068
+ if (!approvalGranted(approval)) {
1069
+ const detail = approvalReason(approval, reason || 'not approved');
1070
+ return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
1071
+ }
1072
+ return { approval };
1073
+ }
1074
+
941
1075
  function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
942
1076
  if (executeOpts.scopedCacheOutcome) {
943
1077
  if (sessionRef && toolCallId) {
@@ -966,14 +1100,20 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
966
1100
  const toolOpts = scopedCacheOutcome
967
1101
  ? { ...executeOpts, scopedCacheOutcome }
968
1102
  : executeOpts;
969
- const notifyFn = (text) => {
970
- if (!callerSessionId) return;
971
- try { enqueuePendingMessage(callerSessionId, String(text || '')); } catch { /* best effort */ }
972
- };
1103
+ const notificationSessionId = String(executeOpts.notifySessionId || sessionRef?.ownerSessionId || callerSessionId || '').trim();
1104
+ const notifyFn = typeof executeOpts.notifyFn === 'function'
1105
+ ? executeOpts.notifyFn
1106
+ : (text, meta = {}) => {
1107
+ if (!notificationSessionId) return;
1108
+ try {
1109
+ const visible = modelVisibleToolCompletionMessage(text, meta);
1110
+ if (visible) enqueuePendingMessage(notificationSessionId, visible);
1111
+ } catch { /* best effort */ }
1112
+ };
973
1113
  const completionToolOpts = {
974
1114
  ...toolOpts,
975
1115
  sessionId: callerSessionId,
976
- callerSessionId,
1116
+ callerSessionId: notificationSessionId || callerSessionId,
977
1117
  routingSessionId: callerSessionId,
978
1118
  clientHostPid: sessionRef?.clientHostPid,
979
1119
  notifyFn,
@@ -981,6 +1121,9 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
981
1121
  const beforeToolHook = typeof executeOpts.beforeToolHook === 'function'
982
1122
  ? executeOpts.beforeToolHook
983
1123
  : sessionRef?.beforeToolHook;
1124
+ const toolApprovalHook = typeof executeOpts.toolApprovalHook === 'function'
1125
+ ? executeOpts.toolApprovalHook
1126
+ : sessionRef?.toolApprovalHook;
984
1127
  if (beforeToolHook) {
985
1128
  try {
986
1129
  const decision = await beforeToolHook({
@@ -995,6 +1138,23 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
995
1138
  const reason = decision?.reason ? `: ${decision.reason}` : '';
996
1139
  return `Error: tool "${name}" denied by hook${reason}`;
997
1140
  }
1141
+ if (action === 'ask') {
1142
+ const askReason = String(decision?.reason || 'approval requested by hook').trim();
1143
+ const askOutcome = await resolvePreToolAskApproval({
1144
+ toolName: name,
1145
+ args,
1146
+ cwd,
1147
+ sessionId: callerSessionId,
1148
+ toolCallId: executeOpts.toolCallId || null,
1149
+ askReason,
1150
+ toolApprovalHook,
1151
+ });
1152
+ if (askOutcome.denial) return askOutcome.denial;
1153
+ const approval = askOutcome.approval;
1154
+ if (approval && typeof approval === 'object' && approval.args && typeof approval.args === 'object' && !Array.isArray(approval.args)) {
1155
+ args = approval.args;
1156
+ }
1157
+ }
998
1158
  if ((action === 'modify' || action === 'rewrite') && decision?.args && typeof decision.args === 'object' && !Array.isArray(decision.args)) {
999
1159
  args = decision.args;
1000
1160
  }
@@ -1002,15 +1162,19 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1002
1162
  // Hooks are policy extensions. A broken hook must not wedge the agent loop.
1003
1163
  }
1004
1164
  }
1165
+ const afterToolHook = typeof executeOpts.afterToolHook === 'function'
1166
+ ? executeOpts.afterToolHook
1167
+ : sessionRef?.afterToolHook;
1168
+ const __result = await (async () => {
1169
+ if (name === 'Skill') {
1170
+ return viewSkill(cwd, args?.name);
1171
+ }
1005
1172
  if (name === 'skills_list') {
1006
1173
  return buildSkillsListResponse(cwd);
1007
1174
  }
1008
1175
  if (name === 'skill_view') {
1009
1176
  return viewSkill(cwd, args?.name);
1010
1177
  }
1011
- if (name === 'skill_execute') {
1012
- return executeSkill(cwd, args?.name, args?.args);
1013
- }
1014
1178
  if (isMcpTool(name)) {
1015
1179
  // 24h trace data shows ~24% of external MCP calls are cwd-sensitive
1016
1180
  // (bash / grep / read / list / glob etc.) but the worker session's
@@ -1023,10 +1187,10 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1023
1187
  const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
1024
1188
  return executeMcpTool(name, finalArgs);
1025
1189
  }
1026
- if (isCodeGraphTool(name)) {
1190
+ if (name === 'code_graph') {
1027
1191
  // cwd chain: args.cwd (caller-explicit) → session cwd → undefined (handler throws)
1028
1192
  const graphCwd = (typeof args?.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
1029
- return executeCodeGraphTool(name, args, graphCwd, null, toolOpts);
1193
+ return executeCodeGraphToolLazy(name, args, graphCwd, null, toolOpts);
1030
1194
  }
1031
1195
  if (isInternalTool(name)) {
1032
1196
  // callerSessionId propagates into server.mjs dispatchTool so that
@@ -1042,14 +1206,14 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1042
1206
  });
1043
1207
  }
1044
1208
  if (name === 'shell') {
1045
- const routedArgs = buildBridgeBashSessionArgs(args, sessionRef);
1209
+ const routedArgs = buildAgentBashSessionArgs(args, sessionRef);
1046
1210
  if (!routedArgs) {
1047
1211
  // clientHostPid scopes background shell-jobs to the dispatching
1048
- // terminal's claude.exe pid (bridge sessions store it on sessionRef);
1212
+ // terminal's claude.exe pid (agent sessions store it on sessionRef);
1049
1213
  // without it resolveJobOwnerHostPid falls back to the daemon-global env.
1050
1214
  return executeBuiltinTool(name, args, cwd, completionToolOpts);
1051
1215
  }
1052
- // Thread the session's AbortSignal so bridge type=close can interrupt the
1216
+ // Thread the session's AbortSignal so agent type=close can interrupt the
1053
1217
  // persistent child process. getSessionAbortSignal is imported at top of
1054
1218
  // loop.mjs from manager.mjs; callerSessionId identifies the controller.
1055
1219
  let _bashAbortSignal = null;
@@ -1073,7 +1237,8 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1073
1237
  return result;
1074
1238
  }
1075
1239
  if (name === 'apply_patch') {
1076
- return executePatchTool(name, args, cwd, { sessionId: callerSessionId });
1240
+ const patchArgs = typeof args === 'string' ? { patch: args } : args;
1241
+ return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId });
1077
1242
  }
1078
1243
  if (isBuiltinTool(name)) {
1079
1244
  // clientHostPid threaded for the same per-terminal job-scope reason as
@@ -1081,6 +1246,23 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1081
1246
  return executeBuiltinTool(name, args, cwd, completionToolOpts);
1082
1247
  }
1083
1248
  return formatUnknownBuiltinToolMessage(name, args, 'tool');
1249
+ })();
1250
+ if (typeof afterToolHook === 'function') {
1251
+ try {
1252
+ const hookResult = await afterToolHook({
1253
+ name,
1254
+ args,
1255
+ cwd,
1256
+ sessionId: callerSessionId,
1257
+ toolCallId: executeOpts.toolCallId || null,
1258
+ result: __result,
1259
+ });
1260
+ return resolveToolResultAfterHook(__result, hookResult);
1261
+ } catch {
1262
+ // PostToolUse hooks are best-effort; never let one break the tool result.
1263
+ }
1264
+ }
1265
+ return __result;
1084
1266
  }
1085
1267
  /**
1086
1268
  * Agent loop: send → tool_call → execute → re-send → repeat until text.
@@ -1109,6 +1291,22 @@ const INCOMPLETE_STOP_REASONS = new Set([
1109
1291
  'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
1110
1292
  ]);
1111
1293
 
1294
+ export function approvalGranted(value) {
1295
+ if (value === true) return true;
1296
+ if (!value || typeof value !== 'object') return false;
1297
+ if (value.approved === true || value.allow === true || value.allowed === true) return true;
1298
+ const decision = String(value.decision || value.action || value.result || '').trim().toLowerCase();
1299
+ return decision === 'approve' || decision === 'approved' || decision === 'allow' || decision === 'yes';
1300
+ }
1301
+
1302
+ export function approvalReason(value, fallback = '') {
1303
+ if (value && typeof value === 'object') {
1304
+ const reason = String(value.reason || value.message || '').trim();
1305
+ if (reason) return reason;
1306
+ }
1307
+ return fallback;
1308
+ }
1309
+
1112
1310
  export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
1113
1311
  let iterations = 0;
1114
1312
  let toolCallsTotal = 0;
@@ -1116,6 +1314,12 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1116
1314
  let firstTurnUsage;
1117
1315
  let response;
1118
1316
  let contractNudges = 0;
1317
+ let contextOverflowRetryUsed = false;
1318
+ // Set when a provider context-overflow refusal triggers the in-turn
1319
+ // reactive compact retry below; consumed by the next pre-send compact pass
1320
+ // so its telemetry/events carry trigger:'reactive' (distinct from the
1321
+ // proactive pre-send pressure trigger). Cleared after that pass reads it.
1322
+ let reactiveOverflowRetryPending = false;
1119
1323
  const opts = sendOpts || {};
1120
1324
  const sessionId = opts.sessionId || null;
1121
1325
  const signal = opts.signal || null;
@@ -1125,9 +1329,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1125
1329
  ? tools.find(tool => tool?.name === forcedFirstTool)
1126
1330
  : null;
1127
1331
  // Opaque providerState passthrough. The loop never inspects provider-native
1128
- // payloads; the originating provider owns them. OpenAI Codex uses this for
1129
- // native remote compaction prefixes, and stateful Responses providers may
1130
- // use it for continuation anchors.
1332
+ // payloads; the originating provider owns them. Stateful Responses
1333
+ // providers may use it for continuation anchors.
1131
1334
  let providerState = opts.providerState ?? undefined;
1132
1335
  const throwIfAborted = () => {
1133
1336
  if (signal?.aborted) {
@@ -1140,17 +1343,34 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1140
1343
  }
1141
1344
  };
1142
1345
  const sessionRef = opts.session || null;
1346
+ const loopUsageMetricsEpoch = () => Number(sessionRef?.usageMetricsEpoch) || 0;
1347
+ const loopUsageMetricsTurnId = () => Number(sessionRef?.usageMetricsTurnId) || 0;
1348
+ // Sub-agent (worker/heavy-worker/reviewer/debugger/explore/…) sessions
1349
+ // drop mid-turn assistant preamble text outright. Only the final
1350
+ // <final-answer> reply is consumed by Lead, so any "Now let me…" prose
1351
+ // that precedes a tool call is pure noise — both for live surfacing AND
1352
+ // for the agent's own history (where it re-enters context as input
1353
+ // tokens on every later turn). Drop it at the runtime, no model-side rule:
1354
+ // - streaming : opts.onTextDelta suppressed (token-by-token preamble)
1355
+ // - buffered : opts.onAssistantText skipped (response.content below)
1356
+ // - history : tool-call turn content blanked before messages.push
1357
+ // Reasoning/thinking deltas, tool calls, and the final answer are kept.
1358
+ const suppressMidTurnText = isAgentOwner(sessionRef);
1359
+ if (suppressMidTurnText) opts.onTextDelta = undefined;
1143
1360
  const pushToolResultMessage = (message) => {
1144
1361
  messages.push(message);
1145
1362
  try { opts.onToolResult?.(message); } catch {}
1146
1363
  };
1147
- const drainSteeringIntoMessages = (stage = 'mid-turn') => {
1364
+ const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
1148
1365
  if (typeof opts.drainSteering !== 'function') return false;
1149
1366
  let steerMsgs = [];
1150
1367
  try { steerMsgs = opts.drainSteering(sessionId) || []; }
1151
1368
  catch { steerMsgs = []; }
1152
1369
  const merged = mergeSteeringEntries(steerMsgs);
1153
1370
  if (!merged) return false;
1371
+ if (typeof options.beforeAppend === 'function') {
1372
+ try { options.beforeAppend(); } catch { /* best-effort hook */ }
1373
+ }
1154
1374
  messages.push({ role: 'user', content: merged.content });
1155
1375
  try { opts.onSteerMessage?.(merged.text || steeringContentText(merged.content)); } catch {}
1156
1376
  if (sessionId) {
@@ -1158,11 +1378,29 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1158
1378
  }
1159
1379
  return true;
1160
1380
  };
1381
+ const pushIntermediateAssistantResponse = (resp) => {
1382
+ if (!resp) return false;
1383
+ const content = typeof resp.content === 'string' ? resp.content : (resp.content == null ? '' : String(resp.content));
1384
+ const reasoningContent = typeof resp.reasoningContent === 'string' && resp.reasoningContent
1385
+ ? resp.reasoningContent
1386
+ : '';
1387
+ const reasoningItems = Array.isArray(resp.reasoningItems) && resp.reasoningItems.length
1388
+ ? resp.reasoningItems
1389
+ : null;
1390
+ if (!content && !reasoningContent && !reasoningItems) return false;
1391
+ messages.push({
1392
+ role: 'assistant',
1393
+ content,
1394
+ ...(reasoningItems ? { reasoningItems } : {}),
1395
+ ...(reasoningContent ? { reasoningContent } : {}),
1396
+ });
1397
+ return true;
1398
+ };
1161
1399
  const maxLoopIterations = Number.isFinite(sessionRef?.maxLoopIterations)
1162
1400
  ? sessionRef.maxLoopIterations
1163
1401
  : MAX_LOOP_ITERATIONS;
1164
1402
  // Tool execution must use the session cwd even when the caller omitted the
1165
- // legacy positional cwd argument. Bridge workers always carry their cwd on
1403
+ // legacy positional cwd argument. Agent workers always carry their cwd on
1166
1404
  // sessionRef; falling through to pwd()/process.cwd() resolves relatives
1167
1405
  // against the host/plugin root instead of the worker workspace.
1168
1406
  cwd = cwd || sessionRef?.cwd || undefined;
@@ -1172,6 +1410,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1172
1410
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
1173
1411
  break;
1174
1412
  }
1413
+ // Drain queued steering/prompts BEFORE the
1414
+ // pre-send compact check. The compact decision must see the exact
1415
+ // message set that the next provider.send would receive, including
1416
+ // tool results plus any queued user input/notifications.
1417
+ drainSteeringIntoMessages('pre-send');
1175
1418
  const compactPolicy = resolveWorkerCompactPolicy(sessionRef, tools);
1176
1419
  if (compactPolicy?.auto) {
1177
1420
  // Snapshot pre-compact shape so compact_meta can record the actual
@@ -1179,11 +1422,25 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1179
1422
  // a best-effort JSON.stringify length — close enough to the
1180
1423
  // payload we hand the provider for prefix-cache analysis.
1181
1424
  const beforeCount = messages.length;
1182
- let beforeBytes = null;
1183
- try { beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { beforeBytes = null; }
1425
+ // beforeBytes is only ever read inside the shouldCompact telemetry
1426
+ // branches below. Computing it eagerly serialized the ENTIRE message
1427
+ // array (Buffer.byteLength(JSON.stringify(messages))) on every loop
1428
+ // iteration — including the common no-compact path — which grows
1429
+ // linearly with transcript size and was a real per-iteration drag.
1430
+ // Defer it to a memoized lazy getter so the no-compact path pays
1431
+ // nothing and the compact path still gets an exact byte count once.
1432
+ let _beforeBytes;
1433
+ let _beforeBytesComputed = false;
1434
+ const getBeforeBytes = () => {
1435
+ if (_beforeBytesComputed) return _beforeBytes;
1436
+ _beforeBytesComputed = true;
1437
+ try { _beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { _beforeBytes = null; }
1438
+ return _beforeBytes;
1439
+ };
1184
1440
  const messageTokensEst = estimateMessagesTokensSafe(messages);
1185
- const pressureTokens = compactPressureTokens(messageTokensEst, compactPolicy, sessionRef);
1186
- const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, sessionRef);
1441
+ const reactivePending = reactiveOverflowRetryPending === true;
1442
+ const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, { forceReactive: reactivePending });
1443
+ const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, { reactivePending });
1187
1444
  const compactBudgetTokens = shouldCompact
1188
1445
  ? (compactTargetBudget({ ...compactPolicy, pressureTokens }) || compactPolicy.boundaryTokens)
1189
1446
  : compactPolicy.boundaryTokens;
@@ -1196,18 +1453,27 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1196
1453
  });
1197
1454
  } else {
1198
1455
  try { opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
1456
+ const compactStartedAt = Date.now();
1457
+ // A pending reactive-overflow retry makes THIS compact pass the
1458
+ // recovery from a provider overflow refusal, not the proactive
1459
+ // pressure trigger. Tag the emitted events so telemetry can tell
1460
+ // them apart, then clear the one-shot flag.
1461
+ const compactTrigger = reactiveOverflowRetryPending ? 'reactive' : 'auto';
1462
+ reactiveOverflowRetryPending = false;
1199
1463
  rememberCompactTelemetry(sessionRef, compactPolicy, {
1200
1464
  stage: 'compacting',
1201
1465
  beforeTokens: messageTokensEst,
1202
1466
  afterTokens: messageTokensEst,
1203
1467
  pressureTokens,
1468
+ trigger: compactTrigger,
1204
1469
  });
1205
1470
  let compacted;
1206
- let remoteCompactResult = null;
1207
1471
  let pruneCount = 0;
1208
1472
  let summaryChanged = false;
1209
1473
  let semanticCompactResult = null;
1210
1474
  let semanticCompactError = null;
1475
+ let recallFastTrackResult = null;
1476
+ let recallFastTrackError = null;
1211
1477
  try {
1212
1478
  let compactInputMessages = messages;
1213
1479
  if (compactPolicy.prune) {
@@ -1217,12 +1483,32 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1217
1483
  pruneCount = countPrunedToolOutputs(messages, pruned);
1218
1484
  compactInputMessages = pruned;
1219
1485
  }
1220
- // Pre-send compaction replaces destructive drop: older
1221
- // non-system history is condensed into one summary message.
1222
- // The current turn and tool pairing stay intact; if those
1223
- // mandatory parts cannot fit, compactMessages throws instead
1224
- // of silently discarding user-visible context.
1225
- if (compactPolicy.semantic) {
1486
+ if (compactPolicy.recallFastTrack) {
1487
+ try {
1488
+ recallFastTrackResult = await runRecallFastTrackCompact({
1489
+ sessionRef,
1490
+ messages: compactInputMessages,
1491
+ compactBudgetTokens,
1492
+ compactPolicy,
1493
+ sessionId,
1494
+ signal,
1495
+ });
1496
+ const recallMessages = Array.isArray(recallFastTrackResult?.messages)
1497
+ ? recallFastTrackResult.messages
1498
+ : null;
1499
+ if (!recallMessages) throw new Error('recall-fasttrack compact produced no messages');
1500
+ compacted = recallMessages;
1501
+ } catch (recallErr) {
1502
+ recallFastTrackError = recallErr;
1503
+ try {
1504
+ process.stderr.write(
1505
+ `[loop] recall-fasttrack compact failed (sess=${sessionId || 'unknown'}): ` +
1506
+ `${recallErr?.message || recallErr}\n`,
1507
+ );
1508
+ } catch { /* best-effort */ }
1509
+ throw recallErr;
1510
+ }
1511
+ } else if (compactPolicy.semantic) {
1226
1512
  try {
1227
1513
  semanticCompactResult = await semanticCompactMessages(
1228
1514
  provider,
@@ -1247,10 +1533,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1247
1533
  const semanticMessages = Array.isArray(semanticCompactResult?.messages)
1248
1534
  ? semanticCompactResult.messages
1249
1535
  : null;
1250
- if (semanticMessages && (semanticCompactResult?.semantic === true
1251
- || messagesArrayChanged(compactInputMessages, semanticMessages))) {
1252
- compacted = semanticMessages;
1253
- }
1536
+ if (!semanticMessages) throw new Error('semantic compact produced no messages');
1537
+ compacted = semanticMessages;
1254
1538
  if (semanticCompactResult?.usage) {
1255
1539
  lastUsage = addUsage(lastUsage, semanticCompactResult.usage);
1256
1540
  if (!firstTurnUsage) firstTurnUsage = normalizeUsage(semanticCompactResult.usage);
@@ -1259,6 +1543,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1259
1543
  opts.onUsageDelta({
1260
1544
  sessionId,
1261
1545
  iterationIndex: iterations + 1,
1546
+ usageMetricsTurnId: loopUsageMetricsTurnId(),
1547
+ usageMetricsEpoch: loopUsageMetricsEpoch(),
1262
1548
  deltaInput: semanticCompactResult.usage.inputTokens || 0,
1263
1549
  deltaOutput: semanticCompactResult.usage.outputTokens || 0,
1264
1550
  deltaCachedRead: semanticCompactResult.usage.cachedTokens || 0,
@@ -1274,127 +1560,46 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1274
1560
  try {
1275
1561
  process.stderr.write(
1276
1562
  `[loop] semantic compact failed (sess=${sessionId || 'unknown'}): ` +
1277
- `${semanticErr?.message || semanticErr}; falling back to deterministic compact\n`,
1278
- );
1279
- } catch { /* best-effort */ }
1280
- }
1281
- }
1282
- if (!compacted) {
1283
- try {
1284
- compacted = compactMessages(compactInputMessages, compactBudgetTokens, {
1285
- reserveTokens: compactPolicy.reserveTokens,
1286
- force: true,
1287
- });
1288
- } catch (deterministicErr) {
1289
- // Deterministic (and any prior semantic) compaction
1290
- // failed because system + the entire current turn is
1291
- // mandatory and overflows the budget. For bridge
1292
- // workers, fall back to the narrow active-turn
1293
- // compactor, which shrinks older same-turn tool
1294
- // outputs / drops older same-turn groups while
1295
- // preserving system + task user + the latest
1296
- // group(s) and tool pairing. It still throws (and we
1297
- // surface overflow below) when even that floor cannot
1298
- // fit, so overflow/cancellation behavior is preserved.
1299
- if (!compactPolicy.activeTurnFallback) throw deterministicErr;
1300
- compacted = compactActiveTurn(compactInputMessages, compactBudgetTokens, {
1301
- reserveTokens: compactPolicy.reserveTokens,
1302
- force: true,
1303
- });
1304
- try {
1305
- process.stderr.write(
1306
- `[loop] active-turn fallback compaction (sess=${sessionId || 'unknown'}): ` +
1307
- `${deterministicErr?.message || deterministicErr}\n`,
1563
+ `${semanticErr?.message || semanticErr}\n`,
1308
1564
  );
1309
1565
  } catch { /* best-effort */ }
1566
+ throw semanticErr;
1310
1567
  }
1568
+ } else {
1569
+ throw new Error(`compact type ${compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE} is unavailable for auto compact`);
1311
1570
  }
1312
1571
  summaryChanged = messagesArrayChanged(compactInputMessages, compacted);
1313
- if (summaryChanged && providerRemoteCompactEnabled(provider, opts)) {
1314
- const compactInput = splitMessagesForRemoteCompact(messages);
1315
- if (compactInput) {
1316
- try {
1317
- remoteCompactResult = await provider.remoteCompactMessages(
1318
- compactInput,
1319
- model,
1320
- tools,
1321
- {
1322
- ...opts,
1323
- thinkingBudgetTokens: undefined,
1324
- xaiReasoningEffort: undefined,
1325
- reasoningEffort: undefined,
1326
- effort: 'low',
1327
- providerState,
1328
- iteration: iterations + 1,
1329
- remoteCompact: true,
1330
- onToolCall: undefined,
1331
- onStreamDelta: undefined,
1332
- },
1333
- );
1334
- if (remoteCompactResult?.providerState !== undefined) {
1335
- markRemoteCompactFallback(compacted, provider?.name);
1336
- }
1337
- if (remoteCompactResult?.usage) {
1338
- lastUsage = addUsage(lastUsage, remoteCompactResult.usage);
1339
- if (!firstTurnUsage) firstTurnUsage = normalizeUsage(remoteCompactResult.usage);
1340
- if (sessionId && opts.onUsageDelta) {
1341
- try {
1342
- opts.onUsageDelta({
1343
- sessionId,
1344
- iterationIndex: iterations + 1,
1345
- deltaInput: remoteCompactResult.usage.inputTokens || 0,
1346
- deltaOutput: remoteCompactResult.usage.outputTokens || 0,
1347
- deltaCachedRead: remoteCompactResult.usage.cachedTokens || 0,
1348
- deltaCacheWrite: remoteCompactResult.usage.cacheWriteTokens || 0,
1349
- source: 'remote_compact',
1350
- ts: Date.now(),
1351
- });
1352
- } catch { /* best-effort */ }
1353
- }
1354
- }
1355
- } catch (remoteErr) {
1356
- try {
1357
- process.stderr.write(
1358
- `[loop] remote compact failed (sess=${sessionId || 'unknown'}): ` +
1359
- `${remoteErr?.message || remoteErr}; falling back to local summary\n`,
1360
- );
1361
- } catch { /* best-effort */ }
1362
- traceBridgeCompact({
1363
- sessionId,
1364
- iteration: iterations + 1,
1365
- stage: 'remote_compact',
1366
- prune_count: pruneCount,
1367
- compact_changed: false,
1368
- input_prefix_hash: messagePrefixHash(messages),
1369
- before_count: beforeCount,
1370
- after_count: beforeCount,
1371
- before_bytes: beforeBytes,
1372
- after_bytes: beforeBytes,
1373
- context_window: compactPolicy.contextWindow,
1374
- budget_tokens: compactPolicy.boundaryTokens,
1375
- target_budget_tokens: compactBudgetTokens,
1376
- reserve_tokens: compactPolicy.reserveTokens,
1377
- message_tokens_est: messageTokensEst,
1378
- provider: sessionRef.provider,
1379
- model: sessionRef.model || model,
1380
- error: remoteErr && remoteErr.message ? remoteErr.message : String(remoteErr),
1381
- error_code: 'REMOTE_COMPACT_FAILED',
1382
- });
1383
- }
1384
- }
1385
- }
1386
1572
  } catch (compactErr) {
1387
- traceBridgeCompact({
1573
+ const compactFailMsg = compactErr && compactErr.message ? compactErr.message : String(compactErr);
1574
+ const semanticFailMsg = semanticCompactError?.message || null;
1575
+ const recallFailMsg = recallFastTrackError?.message || null;
1576
+ const compactFailCode = compactErr?.code
1577
+ || (compactErr?.name === 'AgentContextOverflowError' ? 'AGENT_CONTEXT_OVERFLOW' : null)
1578
+ || 'compact_failed';
1579
+ rememberCompactTelemetry(sessionRef, compactPolicy, {
1580
+ stage: 'overflow_failed',
1581
+ beforeTokens: messageTokensEst,
1582
+ afterTokens: messageTokensEst,
1583
+ pressureTokens,
1584
+ trigger: compactTrigger,
1585
+ semanticError: semanticFailMsg,
1586
+ recallFastTrackError: recallFailMsg,
1587
+ compactError: semanticFailMsg || recallFailMsg || compactFailMsg,
1588
+ pruneCount,
1589
+ durationMs: Date.now() - compactStartedAt,
1590
+ });
1591
+ traceAgentCompact({
1388
1592
  sessionId,
1389
1593
  iteration: iterations + 1,
1390
1594
  stage: 'pre_send',
1595
+ trigger: compactTrigger,
1391
1596
  prune_count: pruneCount,
1392
1597
  compact_changed: false,
1393
1598
  input_prefix_hash: messagePrefixHash(messages),
1394
1599
  before_count: beforeCount,
1395
1600
  after_count: messages.length,
1396
- before_bytes: beforeBytes,
1397
- after_bytes: beforeBytes,
1601
+ before_bytes: getBeforeBytes(),
1602
+ after_bytes: getBeforeBytes(),
1398
1603
  context_window: compactPolicy.contextWindow,
1399
1604
  budget_tokens: compactPolicy.boundaryTokens,
1400
1605
  target_budget_tokens: compactBudgetTokens,
@@ -1402,10 +1607,31 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1402
1607
  message_tokens_est: messageTokensEst,
1403
1608
  provider: sessionRef.provider,
1404
1609
  model: sessionRef.model || model,
1610
+ error: compactFailMsg,
1611
+ error_code: compactFailCode,
1612
+ });
1613
+ emitCompactEvent(opts, {
1614
+ sessionId,
1615
+ stage: 'pre_send',
1616
+ trigger: compactTrigger,
1617
+ status: 'failed',
1618
+ compactType: compactEventType(compactPolicy),
1619
+ beforeTokens: messageTokensEst,
1620
+ afterTokens: messageTokensEst,
1621
+ beforeMessages: beforeCount,
1622
+ afterMessages: messages.length,
1623
+ pressureTokens,
1624
+ triggerTokens: compactPolicy.triggerTokens,
1625
+ boundaryTokens: compactPolicy.boundaryTokens,
1626
+ targetBudgetTokens: compactBudgetTokens,
1627
+ reserveTokens: compactPolicy.reserveTokens,
1628
+ semantic: compactPolicy.semantic === true,
1629
+ recallFastTrack: compactPolicy.recallFastTrack === true,
1630
+ pruneCount,
1631
+ durationMs: Date.now() - compactStartedAt,
1405
1632
  error: compactErr && compactErr.message ? compactErr.message : String(compactErr),
1406
- error_code: 'BRIDGE_CONTEXT_OVERFLOW',
1407
1633
  });
1408
- throw bridgeContextOverflowError({
1634
+ throw agentContextOverflowError({
1409
1635
  stage: 'pre_send',
1410
1636
  sessionId,
1411
1637
  sessionRef,
@@ -1420,47 +1646,50 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1420
1646
  if (compactChanged) {
1421
1647
  messages.length = 0;
1422
1648
  messages.push(...compacted);
1423
- if (remoteCompactResult?.providerState !== undefined) {
1424
- providerState = remoteCompactResult.providerState;
1425
- } else {
1426
- // Compacting/pruning the transcript invalidates the
1427
- // server-side conversation anchor (xAI Responses /
1428
- // Codex WS rely on previous_response_id which points
1429
- // at a now-mutated prefix). Drop providerState so the
1430
- // next send starts a fresh chain instead of triggering
1431
- // silent cache miss or hard mismatch.
1432
- providerState = undefined;
1433
- }
1649
+ // Compacting/pruning the transcript invalidates the
1650
+ // server-side conversation anchor (xAI Responses / openai-oauth
1651
+ // WS rely on previous_response_id which points at a
1652
+ // now-mutated prefix). Drop providerState so the next send
1653
+ // starts a fresh chain.
1654
+ providerState = undefined;
1434
1655
  // Compaction shrank the transcript, so prior turns no
1435
1656
  // longer pressure the window — reset the iteration counter
1436
1657
  // so a steadily-compacting long task isn't killed by the
1437
1658
  // cap, while a non-compacting tight loop still hits it.
1438
1659
  iterations = 0;
1660
+ // New loop epoch so persistIterationMetrics idempotency keys do not
1661
+ // collide when iteration indices restart at 1 (incl. iter 1 → iter 1).
1662
+ if (sessionRef) bumpUsageMetricsEpoch(sessionRef);
1439
1663
  }
1440
1664
  const afterTokens = estimateMessagesTokensSafe(messages);
1665
+ const compactDurationMs = Date.now() - compactStartedAt;
1441
1666
  rememberCompactTelemetry(sessionRef, compactPolicy, {
1442
1667
  stage: 'pre_send',
1443
1668
  beforeTokens: messageTokensEst,
1444
1669
  afterTokens,
1445
1670
  pressureTokens,
1446
1671
  compactChanged,
1447
- remoteCompact: remoteCompactResult?.providerState !== undefined,
1448
1672
  semanticCompact: semanticCompactResult?.semantic === true,
1449
1673
  semanticError: semanticCompactError?.message || null,
1674
+ recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1675
+ recallFastTrackError: recallFastTrackError?.message || null,
1676
+ compactError: null,
1450
1677
  pruneCount,
1678
+ durationMs: compactDurationMs,
1451
1679
  });
1452
1680
  let afterBytes = null;
1453
1681
  try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
1454
- traceBridgeCompact({
1682
+ traceAgentCompact({
1455
1683
  sessionId,
1456
1684
  iteration: iterations + 1,
1457
1685
  stage: 'pre_send',
1686
+ trigger: compactTrigger,
1458
1687
  prune_count: pruneCount,
1459
1688
  compact_changed: compactChanged || summaryChanged,
1460
1689
  input_prefix_hash: messagePrefixHash(messages),
1461
1690
  before_count: beforeCount,
1462
1691
  after_count: messages.length,
1463
- before_bytes: beforeBytes,
1692
+ before_bytes: getBeforeBytes(),
1464
1693
  after_bytes: afterBytes,
1465
1694
  context_window: compactPolicy.contextWindow,
1466
1695
  budget_tokens: compactPolicy.boundaryTokens,
@@ -1470,14 +1699,29 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1470
1699
  provider: sessionRef.provider,
1471
1700
  model: sessionRef.model || model,
1472
1701
  });
1702
+ emitCompactEvent(opts, {
1703
+ sessionId,
1704
+ stage: 'pre_send',
1705
+ trigger: compactTrigger,
1706
+ status: compactChanged || summaryChanged || pruneCount > 0 ? 'compacted' : 'no_change',
1707
+ compactType: compactEventType(compactPolicy),
1708
+ beforeTokens: messageTokensEst,
1709
+ afterTokens,
1710
+ beforeMessages: beforeCount,
1711
+ afterMessages: messages.length,
1712
+ pressureTokens,
1713
+ triggerTokens: compactPolicy.triggerTokens,
1714
+ boundaryTokens: compactPolicy.boundaryTokens,
1715
+ targetBudgetTokens: compactBudgetTokens,
1716
+ reserveTokens: compactPolicy.reserveTokens,
1717
+ changed: compactChanged || summaryChanged,
1718
+ semantic: semanticCompactResult?.semantic === true,
1719
+ recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1720
+ pruneCount,
1721
+ durationMs: compactDurationMs,
1722
+ });
1473
1723
  }
1474
1724
  }
1475
- // A pre-send compaction pass can take long enough for the user (or a
1476
- // background completion notification) to enqueue more input. Drain once
1477
- // immediately before provider.send so that input is included in the very
1478
- // next request instead of sitting in the queue until after the model has
1479
- // already answered the pre-compaction prompt.
1480
- drainSteeringIntoMessages('pre-send');
1481
1725
  const nextIteration = iterations + 1;
1482
1726
  opts.iteration = nextIteration;
1483
1727
  opts.providerState = providerState;
@@ -1512,6 +1756,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1512
1756
  let _mutationEpoch = 0;
1513
1757
  const startEagerTool = (call) => {
1514
1758
  if (!call?.id || pending.has(call.id) || !isEagerDispatchable(call.name, tools)) return null;
1759
+ // Never eager-execute a call whose arguments failed to parse
1760
+ // (invalid-args marker). It has no usable arguments; the serial
1761
+ // body handles it via the invalid-args feedback path.
1762
+ if (isInvalidToolArgsMarker(call.arguments)) return null;
1515
1763
  const _sig = _intraTurnSig(call.name, call.arguments);
1516
1764
  if (_eagerInFlightSigs.has(_sig)) return null;
1517
1765
  // Repeat-failure guard also gates eager dispatch (reviewer-flagged):
@@ -1532,20 +1780,55 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1532
1780
  _eagerInFlightSigs.set(_sig, call.id);
1533
1781
  entry.promise = (async () => {
1534
1782
  try {
1535
- const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
1536
- if (permBlocked !== null) return { ok: true, value: permBlocked };
1537
- return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal }) };
1783
+ return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval }) };
1538
1784
  } catch (error) {
1539
1785
  return { ok: false, error };
1540
1786
  }
1541
1787
  })()
1542
- .finally(() => {
1788
+ .then((settled) => {
1543
1789
  entry.endedAt = Date.now();
1790
+ // EARLY UI-ONLY NOTIFY (completion-order, NOT history).
1791
+ // The serial result-collection loop below `await`s each
1792
+ // eager promise strictly in CALL order, so a fast call[1]
1793
+ // that settles before a slow call[0] cannot surface its
1794
+ // tool card completion until call[0] resolves. Fire
1795
+ // onToolResult here — the instant THIS eager tool settles —
1796
+ // so parallel cards complete independently in the order they
1797
+ // actually finish.
1798
+ //
1799
+ // This message is NOT pushed into `messages`: provider
1800
+ // history ordering stays exactly call-order. The serial loop
1801
+ // still builds the REAL tool_result and pushes it via
1802
+ // pushToolResultMessage (which fires onToolResult AGAIN for
1803
+ // the same toolCallId in call order — the TUI dedupes by id,
1804
+ // so the duplicate notify is harmless). __earlyNotify marks
1805
+ // this as the pre-history, UI-only signal.
1806
+ //
1807
+ // Only genuinely-executed eager promises reach here:
1808
+ // startEagerTool never creates an entry for dedup /
1809
+ // repeat-failure-guard / pre-dispatch-deny / invalid-args
1810
+ // calls (they return null above), so those `continue`-before-
1811
+ // execution stub paths can never early-notify (contract #5).
1812
+ try {
1813
+ const _earlyContent = settled && settled.ok
1814
+ ? (typeof settled.value === 'string'
1815
+ ? settled.value
1816
+ : (settled.value == null ? '' : String(settled.value)))
1817
+ : `Error: ${settled && settled.error instanceof Error ? settled.error.message : String(settled && settled.error)}`;
1818
+ opts.onToolResult?.({
1819
+ role: 'tool',
1820
+ toolCallId: call.id,
1821
+ content: _earlyContent,
1822
+ isError: !(settled && settled.ok),
1823
+ __earlyNotify: true,
1824
+ });
1825
+ } catch { /* best-effort — UI notify must never break the eager path */ }
1544
1826
  // Intentionally do NOT delete _sig here — see the block
1545
1827
  // comment above. The sig must outlive promise settlement
1546
1828
  // so a later same-turn streaming duplicate stays blocked
1547
1829
  // at the _eagerInFlightSigs.has(_sig) guard until the turn
1548
1830
  // boundary recreates the Map.
1831
+ return settled;
1549
1832
  });
1550
1833
  pending.set(call.id, entry);
1551
1834
  return entry;
@@ -1567,11 +1850,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1567
1850
  if (_streamEagerBlocked) return;
1568
1851
  startEagerTool(call);
1569
1852
  };
1570
- // Repair any dangling assistant tool_use left over from a prior
1571
- // abort/error path before the provider sees the transcript. No-op
1572
- // on the healthy iteration cycle (every assistant tool_use is
1573
- // followed by tool results in the same loop body below).
1574
- _ensureTranscriptPairing(messages, sessionId);
1853
+ // Reattach separated tool results, then drop only truly dangling
1854
+ // assistant/orphan pairs before the provider sees the transcript.
1855
+ repairTranscriptBeforeProviderSend(messages, sessionId);
1575
1856
  // Strip soft-warn markers from prior tool results before the next
1576
1857
  // send. Marker bytes (Tool-budget(xN), Same-file reads(xN), etc.)
1577
1858
  // mutate every turn with dynamic counters, so leaving them in the
@@ -1589,145 +1870,59 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1589
1870
  try {
1590
1871
  response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
1591
1872
  } catch (sendErr) {
1592
- // Context-window-exceeded is a deterministic refusal: the request is
1593
- // simply too large. Retry ONCE with a stricter budget using the
1594
- // same summary-based compaction path. It never falls back to
1595
- // destructive trim/drop: if system + current turn + compact marker
1596
- // cannot fit, surface overflow. Unrelated errors (network, stall,
1597
- // auth, etc.) re-throw untouched they are handled by the
1598
- // provider/bridge retry layers.
1873
+ // Context-window-exceeded is a deterministic refusal from the API.
1874
+ // Recover context overflow reactively by compacting and retrying
1875
+ // in the same active turn. MixDog's proactive estimator can miss a
1876
+ // provider-specific overhead spike, so do one reactive retry by
1877
+ // marking the live session over-threshold and looping back through
1878
+ // the normal pre-send auto-compact path. If compaction/retry still
1879
+ // fails, surface the overflow normally.
1599
1880
  if (
1600
1881
  !isContextOverflowError(sendErr)
1601
1882
  || !(sessionRef && typeof sessionRef.contextWindow === 'number')
1602
1883
  ) {
1603
1884
  throw sendErr;
1604
1885
  }
1605
- const overflowPolicy = resolveWorkerCompactPolicy(sessionRef, sendTools.length ? sendTools : tools);
1606
- const overflowBase = overflowPolicy?.boundaryTokens || sessionRef.contextWindow;
1607
- const overflowStrictBudget = Math.floor(overflowBase * OVERFLOW_RETRY_COMPACT_PERCENT);
1608
- const overflowBudget = Math.max(1, Math.min(
1609
- overflowBase,
1610
- overflowPolicy?.triggerTokens || overflowBase,
1611
- overflowStrictBudget || overflowBase,
1612
- ));
1613
- const overflowReserve = overflowPolicy?.reserveTokens || estimateRequestReserveTokens(sendTools.length ? sendTools : tools);
1614
- const beforeCount = messages.length;
1615
- let beforeBytes = null;
1616
- try { beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { beforeBytes = null; }
1617
- const messageTokensEst = estimateMessagesTokensSafe(messages);
1618
- let recompacted;
1619
- try {
1620
- recompacted = compactMessages(messages, overflowBudget, { reserveTokens: overflowReserve, force: true });
1621
- } catch (compactErr) {
1622
- // Same narrow active-turn fallback as the pre-send path: when
1623
- // system + the whole current turn overflow even the stricter
1624
- // overflow-retry budget, shrink older same-turn tool outputs /
1625
- // drop older same-turn groups before surfacing overflow. Throws
1626
- // (caught below) when even the floor cannot fit, preserving the
1627
- // overflow error behavior.
1628
- if (overflowPolicy?.activeTurnFallback) {
1629
- try {
1630
- recompacted = compactActiveTurn(messages, overflowBudget, { reserveTokens: overflowReserve, force: true });
1631
- try {
1632
- process.stderr.write(
1633
- `[loop] active-turn fallback compaction on overflow retry ` +
1634
- `(sess=${sessionId || 'unknown'} iter=${nextIteration}): ` +
1635
- `${compactErr?.message || compactErr}\n`,
1636
- );
1637
- } catch { /* best-effort */ }
1638
- } catch { recompacted = undefined; }
1639
- }
1640
- if (!recompacted) {
1641
- traceBridgeCompact({
1642
- sessionId,
1643
- iteration: nextIteration,
1644
- stage: 'overflow_retry',
1645
- prune_count: 0,
1646
- compact_changed: false,
1647
- input_prefix_hash: messagePrefixHash(messages),
1648
- before_count: beforeCount,
1649
- after_count: messages.length,
1650
- before_bytes: beforeBytes,
1651
- after_bytes: beforeBytes,
1652
- context_window: overflowPolicy?.contextWindow || sessionRef.contextWindow,
1653
- budget_tokens: overflowBudget,
1654
- reserve_tokens: overflowReserve,
1655
- message_tokens_est: messageTokensEst,
1656
- provider: sessionRef.provider,
1657
- model: sessionRef.model || model,
1658
- error: compactErr && compactErr.message ? compactErr.message : String(compactErr),
1659
- error_code: 'BRIDGE_CONTEXT_OVERFLOW',
1660
- });
1661
- throw bridgeContextOverflowError({
1662
- stage: 'overflow_retry',
1663
- sessionId,
1664
- sessionRef,
1665
- model,
1666
- budgetTokens: overflowBudget,
1667
- reserveTokens: overflowReserve,
1668
- messageTokensEst,
1669
- }, compactErr);
1670
- }
1886
+ const compactPolicyForRetry = resolveWorkerCompactPolicy(sessionRef, sendTools.length ? sendTools : tools);
1887
+ if (!contextOverflowRetryUsed && compactPolicyForRetry?.auto) {
1888
+ contextOverflowRetryUsed = true;
1889
+ // Mark the next pre-send compact as REACTIVE (driven by a
1890
+ // provider overflow refusal) rather than the normal proactive
1891
+ // pressure trigger, so the compact event/telemetry the loop
1892
+ // emits on the retry is distinguishable downstream.
1893
+ reactiveOverflowRetryPending = true;
1894
+ opts.onToolCall = undefined;
1895
+ try {
1896
+ process.stderr.write(
1897
+ `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
1898
+ `reactive compact retry messages=${messages.length}\n`,
1899
+ );
1900
+ } catch { /* best-effort */ }
1901
+ continue;
1671
1902
  }
1672
- const compactChanged = messagesArrayChanged(messages, recompacted);
1673
- const pruneCount = Math.max(beforeCount - recompacted.length, 0);
1674
- messages.length = 0;
1675
- messages.push(...recompacted);
1676
- let afterBytes = null;
1677
- try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
1678
- traceBridgeCompact({
1679
- sessionId,
1680
- iteration: nextIteration,
1681
- stage: 'overflow_retry',
1682
- prune_count: pruneCount,
1683
- compact_changed: compactChanged,
1684
- input_prefix_hash: messagePrefixHash(messages),
1685
- before_count: beforeCount,
1686
- after_count: messages.length,
1687
- before_bytes: beforeBytes,
1688
- after_bytes: afterBytes,
1689
- context_window: overflowPolicy?.contextWindow || sessionRef.contextWindow,
1690
- budget_tokens: overflowBudget,
1691
- reserve_tokens: overflowReserve,
1692
- message_tokens_est: messageTokensEst,
1693
- provider: sessionRef.provider,
1694
- model: sessionRef.model || model,
1695
- });
1696
- rememberCompactTelemetry(sessionRef, overflowPolicy, {
1697
- stage: 'overflow_retry',
1698
- beforeTokens: messageTokensEst,
1699
- afterTokens: estimateMessagesTokensSafe(messages),
1700
- compactChanged,
1701
- pruneCount,
1702
- });
1703
- // The transcript prefix changed; the server-side conversation anchor
1704
- // (previous_response_id / WS continuation) is now invalid. Drop
1705
- // providerState so the retry starts a fresh chain instead of
1706
- // tripping a silent cache miss or hard mismatch.
1707
- providerState = undefined;
1708
- opts.providerState = undefined;
1709
- // Drop eager-dispatch state before the retry send. A tool_use
1710
- // streamed by the failed first send could otherwise orphan its
1711
- // eager result or be double-dispatched; force the retry's tools
1712
- // through the serial post-send path with a clean matching slate.
1713
- opts.onToolCall = undefined;
1714
- pending.clear();
1715
- _eagerInFlightSigs.clear();
1716
1903
  try {
1717
1904
  process.stderr.write(
1718
1905
  `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
1719
- `retrying once at budget=${overflowBudget} reserve=${overflowReserve} ` +
1720
- `messages=${messages.length}\n`,
1906
+ `surfacing overflow after reactive compact retry messages=${messages.length}\n`,
1721
1907
  );
1722
1908
  } catch { /* best-effort */ }
1723
- response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
1909
+ throw agentContextOverflowError({
1910
+ stage: 'send',
1911
+ sessionId,
1912
+ sessionRef,
1913
+ model,
1914
+ budgetTokens: sessionRef.contextWindow,
1915
+ reserveTokens: compactPolicyForRetry?.reserveTokens,
1916
+ messageTokensEst: estimateMessagesTokensSafe(messages),
1917
+ }, sendErr);
1724
1918
  }
1725
1919
  opts.onToolCall = undefined;
1920
+ contextOverflowRetryUsed = false;
1726
1921
  // Capture opaque state for the next turn (may be undefined — that's
1727
1922
  // the stateless contract for providers that don't use continuation).
1728
1923
  providerState = response?.providerState ?? undefined;
1729
1924
  iterations = nextIteration;
1730
- traceBridgeLoop({
1925
+ traceAgentLoop({
1731
1926
  sessionId,
1732
1927
  iteration: iterations,
1733
1928
  sendMs: Date.now() - sendStartedAt,
@@ -1754,12 +1949,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1754
1949
  // signal) — bail before processing any of its output.
1755
1950
  throwIfAborted();
1756
1951
  // Incremental metric persistence (fix A): push per-iteration token delta
1757
- // immediately so watchdog / bridge type=list sees live totals mid-turn.
1952
+ // immediately so watchdog / agent type=list sees live totals mid-turn.
1758
1953
  if (sessionId && opts.onUsageDelta && response.usage) {
1759
1954
  try {
1760
1955
  opts.onUsageDelta({
1761
1956
  sessionId,
1762
1957
  iterationIndex: iterations,
1958
+ usageMetricsTurnId: loopUsageMetricsTurnId(),
1959
+ source: 'provider_send',
1960
+ usageMetricsEpoch: loopUsageMetricsEpoch(),
1763
1961
  deltaInput: response.usage.inputTokens || 0,
1764
1962
  deltaOutput: response.usage.outputTokens || 0,
1765
1963
  deltaPrompt: response.usage.promptTokens || 0,
@@ -1772,8 +1970,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1772
1970
  });
1773
1971
  } catch { /* best-effort — never break the loop */ }
1774
1972
  }
1775
- // No tool calls. For PUBLIC bridge agents, the bridge contract
1776
- // (rules/bridge/00-common.md) requires either a tool call or a
1973
+ // No tool calls. For PUBLIC agents, the agent contract
1974
+ // (rules/agent/00-common.md) requires either a tool call or a
1777
1975
  // `<final-answer>` wrapped reply.
1778
1976
  // A text-only turn without those tags violates the contract (e.g.
1779
1977
  // Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
@@ -1799,6 +1997,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1799
1997
  const isHidden = HIDDEN_ROLE_NAMES.has(sessionRole);
1800
1998
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
1801
1999
  const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
2000
+ // A user/schedule notification can arrive while provider.send() is
2001
+ // returning a terminal no-tool response. Drain once before accepting
2002
+ // it as final so the queued input is handled in the same active turn
2003
+ // instead of waiting for post-turn TUI drain. If the model already
2004
+ // produced assistant text, persist that as an intermediate assistant
2005
+ // message before appending the steered user message.
2006
+ if (drainSteeringIntoMessages('final-pre-send', {
2007
+ beforeAppend: () => pushIntermediateAssistantResponse(response),
2008
+ })) {
2009
+ continue;
2010
+ }
1802
2011
  if (!hasContent && !isHidden) {
1803
2012
  if (contractNudges >= 1) break;
1804
2013
  contractNudges += 1;
@@ -1815,6 +2024,16 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1815
2024
  }
1816
2025
  const calls = response.toolCalls;
1817
2026
  toolCallsTotal += calls.length;
2027
+ // Surface any mid-turn assistant text (preamble that precedes a tool
2028
+ // call) to the UI. Providers that stream text via onTextDelta already
2029
+ // rendered it; providers that return the text only in response.content
2030
+ // (no deltas) would otherwise show nothing before the tool card. The
2031
+ // engine de-dups against already-streamed text, so emitting here is
2032
+ // safe for both paths. Sub-agent sessions suppress it entirely
2033
+ // (suppressMidTurnText) — Lead only consumes the final answer.
2034
+ if (!suppressMidTurnText && typeof response.content === 'string' && response.content.trim()) {
2035
+ try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
2036
+ }
1818
2037
  // Per-turn batch shape — one row per assistant turn so trace
1819
2038
  // consumers can derive multi-tool adoption ratio without scanning
1820
2039
  // every assistant message body.
@@ -1824,11 +2043,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1824
2043
  // OpenAI Responses API replay payload (encrypted_content blobs);
1825
2044
  // providers that ignore it just see an extra field and drop it,
1826
2045
  // openai-oauth.convertMessagesToResponsesInput emits matching
1827
- // type:'reasoning' input items on the next turn to keep the Codex
2046
+ // type:'reasoning' input items on the next turn to keep the openai-oauth
1828
2047
  // server-side cache prefix stable.
1829
2048
  const _assistantTurnMsg = {
1830
2049
  role: 'assistant',
1831
- content: response.content || '',
2050
+ // Sub-agent tool-call turns carry only mid-turn preamble in
2051
+ // response.content (the real result rides the later final-answer
2052
+ // turn). Blank it so it never accumulates as input tokens.
2053
+ content: suppressMidTurnText ? '' : (response.content || ''),
1832
2054
  toolCalls: compactToolCallsForHistory(calls),
1833
2055
  ...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
1834
2056
  ? { reasoningItems: response.reasoningItems }
@@ -1909,7 +2131,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1909
2131
  let toolStartedAt;
1910
2132
  let toolEndedAt;
1911
2133
  const toolKind = getToolKind(call.name);
1912
- // Cross-turn read dedup. Mirrors Anthropic Claude Code's
2134
+ // Cross-turn read dedup. Mirrors a reference agent's
1913
2135
  // fileReadCache.ts: if the path's stat tuple (mtime/size/ino/dev)
1914
2136
  // is unchanged since a prior read in THIS session, return the cached
1915
2137
  // body instead of executing. Both scalar and array/object-array path
@@ -1924,19 +2146,35 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1924
2146
  let _scopedCacheHit = null;
1925
2147
  let _executeOk = false;
1926
2148
  let _resultKind = 'normal';
1927
- if (sessionId && _isReadTool(call.name)) {
2149
+ // Invalid-args guard (native convergence): the provider parser tags
2150
+ // a tool call whose arguments JSON could not be parsed with an
2151
+ // invalid-args marker instead of throwing or swallowing to {}.
2152
+ // Such a call must NOT execute — there are no usable arguments and
2153
+ // permission/cache checks are meaningless. Skip straight to the
2154
+ // error-feedback path so the model gets an is_error tool_result and
2155
+ // re-issues the call with valid JSON in the same turn.
2156
+ const _invalidArgs = isInvalidToolArgsMarker(call.arguments);
2157
+ if (_invalidArgs) {
2158
+ // no cache lookup for an un-parseable call
2159
+ } else if (sessionId && _isReadTool(call.name)) {
1928
2160
  _readCacheHit = tryReadCached({ sessionId, args: call.arguments, cwd });
1929
2161
  } else if (sessionId && _isScopedCacheableTool(call.name)) {
1930
2162
  _scopedCacheHit = tryScopedToolCached({ sessionId, toolName: _stripMcpPrefix(call.name), args: call.arguments, cwd });
1931
2163
  }
1932
2164
  try {
1933
- if (_readCacheHit !== null) {
2165
+ if (_invalidArgs) {
2166
+ toolStartedAt = Date.now();
2167
+ toolEndedAt = toolStartedAt;
2168
+ result = formatInvalidToolArgsResult(call);
2169
+ _resultKind = 'error';
2170
+ _executeOk = false;
2171
+ } else if (_readCacheHit !== null) {
1934
2172
  toolStartedAt = Date.now();
1935
2173
  toolEndedAt = toolStartedAt;
1936
2174
  const _body = _readCacheHit.content;
1937
2175
  // Return the cached body byte-for-byte instead of a
1938
2176
  // human-readable cache marker. The marker made public
1939
- // bridge agents treat a successful cached read as a
2177
+ // agents treat a successful cached read as a
1940
2178
  // meta instruction and repeat the same read loop.
1941
2179
  result = _body;
1942
2180
  _resultKind = 'cache-hit';
@@ -1975,39 +2213,32 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1975
2213
  }
1976
2214
  } else {
1977
2215
  toolStartedAt = Date.now();
1978
- // Runtime permission guard. Schema profiles may hide
2216
+ // Runtime pre-dispatch deny. Schema profiles may hide
1979
2217
  // tools for routing efficiency, but this remains the
1980
- // safety boundary for any tool_use that still reaches
1981
- // the loop. _preDispatchDeny is the SHARED helper used
1982
- // by both the eager dispatch path (startEagerTool) and
1983
- // this serial path — keeps the bridge-owned control-
1984
- // plane reject, role guards, wrapper guards, and
1985
- // permission guards consistent across both paths.
2218
+ // control-plane boundary for any tool_use that still
2219
+ // reaches the loop. _preDispatchDeny is the SHARED helper
2220
+ // used by both the eager dispatch path (startEagerTool)
2221
+ // and this serial path — keeps the agent-owned control-
2222
+ // plane reject and no-tool role guards consistent across
2223
+ // both paths.
1986
2224
  const _denyMsg = _preDispatchDeny(call, toolKind, sessionRef);
1987
2225
  if (_denyMsg !== null) {
1988
2226
  result = _denyMsg;
1989
2227
  toolEndedAt = Date.now();
1990
2228
  _resultKind = 'error';
1991
2229
  } else {
1992
- const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
1993
- if (permBlocked !== null) {
1994
- result = permBlocked;
1995
- toolEndedAt = Date.now();
2230
+ result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval });
2231
+ toolEndedAt = Date.now();
2232
+ // Boundary: tool-return string convention → structural kind.
2233
+ // The only prefix check in this codebase; downstream layers
2234
+ // operate on _resultKind.
2235
+ if (classifyResultKind(result) === 'error') {
1996
2236
  _resultKind = 'error';
2237
+ _executeOk = false;
1997
2238
  } else {
1998
- result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal });
1999
- toolEndedAt = Date.now();
2000
- // Boundary: tool-return string convention → structural kind.
2001
- // The only prefix check in this codebase; downstream layers
2002
- // operate on _resultKind.
2003
- if (classifyResultKind(result) === 'error') {
2004
- _resultKind = 'error';
2005
- _executeOk = false;
2006
- } else {
2007
- _executeOk = true;
2008
- }
2009
- // _resultKind stays 'normal' when tool returned a non-error string.
2239
+ _executeOk = true;
2010
2240
  }
2241
+ // _resultKind stays 'normal' when tool returned a non-error string.
2011
2242
  }
2012
2243
  }
2013
2244
  } // close: else branch of _readCacheHit check
@@ -2150,6 +2381,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2150
2381
  // failure push a synthetic Error: tool result for this call.id
2151
2382
  // and skip the cache writes for it.
2152
2383
  let _postProcessOk = true;
2384
+ let _nativeToolSearch = null;
2153
2385
  try {
2154
2386
  // Offload thresholds are keyed by BARE tool name
2155
2387
  // (INLINE_THRESHOLD_BY_TOOL: grep=20k, bash=30k, read=Infinity, ...),
@@ -2157,9 +2389,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2157
2389
  // Otherwise an mcp__..__grep name misses its 20k grep cap and
2158
2390
  // silently falls back to the 50k default — per-tool limits ignored.
2159
2391
  const _toolBare = _stripMcpPrefix(call.name);
2392
+ _nativeToolSearch = parseNativeToolSearchPayload(call.name, result);
2393
+ if (_nativeToolSearch?.summary) result = _nativeToolSearch.summary;
2160
2394
  result = await maybeOffloadToolResult(sessionId, call.id, _toolBare, result);
2161
2395
  result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
2162
- traceBridgeTool({
2396
+ traceAgentTool({
2163
2397
  sessionId,
2164
2398
  iteration: iterations,
2165
2399
  toolName: call.name,
@@ -2203,6 +2437,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2203
2437
  content: result,
2204
2438
  toolCallId: call.id,
2205
2439
  toolKind: _resultKind,
2440
+ ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
2206
2441
  });
2207
2442
  } catch (postErr) {
2208
2443
  _postProcessOk = false;
@@ -2211,7 +2446,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2211
2446
  // too for a clean retry (mirrors the failed-exec path above).
2212
2447
  if (call?.id) restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
2213
2448
  const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
2214
- traceBridgeToolFailure({
2449
+ traceAgentToolFailure({
2215
2450
  sessionId,
2216
2451
  iteration: iterations,
2217
2452
  toolName: call.name,
@@ -2242,18 +2477,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2242
2477
  // discard the rest of the batch and skip the next provider.send.
2243
2478
  throwIfAborted();
2244
2479
  }
2245
- // ── Mid-turn steering (Claude Code / pi agent-loop parity) ──
2246
- // Before re-sending with the tool results, drain any user / `bridge
2247
- // type=send` messages that arrived WHILE this tool batch was in
2248
- // flight and inject them as user turns. This is the difference
2249
- // between "steer" (interrupt now) and "followUp" (wait for the turn
2250
- // to finish): previously every injected message was held until the
2251
- // entire askSession turn completed (manager.mjs _pendingTail drain),
2252
- // so a user typing mid-task only got picked up after the agent had
2253
- // already run to completion. Mirrors pi/agent-loop.ts:253 +
2254
- // 182-190 — steering messages land right before the next assistant
2255
- // response so the model sees them on its very next iteration.
2256
- drainSteeringIntoMessages('mid-turn');
2480
+ // Mid-turn steering is drained at the next loop's pre-send point,
2481
+ // AFTER any auto-compact pass. Draining here would put the steering
2482
+ // user turn after the fresh tool results before compaction runs; then
2483
+ // semantic/recall compaction would treat those fresh tool results as
2484
+ // prior history before the model sees them.
2257
2485
  // About to re-send with tool results — transition back to connecting for the next turn.
2258
2486
  if (sessionId) updateSessionStage(sessionId, 'connecting');
2259
2487
  }