mixdog 0.8.0 → 0.9.0

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 (285) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  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 +344 -0
  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 +1223 -95
  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 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2325 -450
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +118 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. 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
@@ -38,13 +43,9 @@ function _stripMcpPrefix(name) {
38
43
  function _isReadTool(name) {
39
44
  return _stripMcpPrefix(name) === 'read';
40
45
  }
41
- function _isScalarWriteEditTool(name) {
42
- const n = _stripMcpPrefix(name);
43
- return n === 'write' || n === 'edit';
44
- }
45
46
  function _isMutationTool(name) {
46
47
  const n = _stripMcpPrefix(name);
47
- return n === 'apply_patch' || n === 'write' || n === 'edit';
48
+ return n === 'apply_patch';
48
49
  }
49
50
  const SCOPED_CACHEABLE_TOOLS = new Set([
50
51
  'code_graph',
@@ -52,6 +53,13 @@ const SCOPED_CACHEABLE_TOOLS = new Set([
52
53
  'list',
53
54
  'glob',
54
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
+ }
55
63
  function _isScopedCacheableTool(name) {
56
64
  const n = _stripMcpPrefix(name);
57
65
  return SCOPED_CACHEABLE_TOOLS.has(n);
@@ -83,27 +91,25 @@ function _intraTurnSig(name, args) {
83
91
  return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
84
92
  }
85
93
 
86
- // Shared pre-dispatch deny — single source of truth for role/scope/permission
87
- // rejects. Called by BOTH the eager dispatch path (startEagerTool) and the
88
- // serial dispatch path (executeTool body). Returns null when the call is
89
- // allowed to proceed; otherwise returns the Error string the serial path
90
- // would emit. The eager caller ignores the message body and just treats
91
- // 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".
92
100
  //
93
- // Predicates are kept in the same order as the legacy serial branch so a
94
- // bridge-owned control-plane tool fails on _bridgeOwned+_controlPlaneTool
95
- // FIRST (not on permission/wrapper checks) matches the prior wording.
96
- // 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
97
104
  // owner/host control surfaces: session management, the ENTIRE channels module
98
105
  // (Discord messaging, schedules, webhook/config, channel-bridge toggle,
99
106
  // command injection), or host input injection. Explicit name list (no imports)
100
107
  // keeps this hot-path gate dependency-free; add new owner/channel tools here.
101
108
  const WORKER_DENIED_TOOLS = new Set([
102
- // session control-plane — unified into the single `bridge` tool
109
+ // session control-plane — unified into the single `agent` tool
103
110
  // (type=spawn|send|close|list). Denying the one name blocks all worker
104
- // session control. Legacy names kept for defense-in-depth against any
105
- // stale catalog entry that still advertises them.
106
- 'bridge', 'close_session', 'list_sessions', 'create_session',
111
+ // session control.
112
+ 'agent',
107
113
  // channels module (owner/Discord-facing)
108
114
  'reply', 'react', 'edit_message', 'download_attachment', 'fetch',
109
115
  'schedule_status', 'trigger_schedule', 'schedule_control',
@@ -114,29 +120,16 @@ const WORKER_DENIED_TOOLS = new Set([
114
120
  function _preDispatchDeny(call, toolKind, sessionRef) {
115
121
  const name = call?.name;
116
122
  if (typeof name !== 'string' || !name) return null;
117
- const _bridgeOwned = sessionRef?.scope?.startsWith?.('bridge:') || sessionRef?.owner === 'bridge';
123
+ const _agentOwned = sessionRef?.scope?.startsWith?.('agent:')
124
+ || isAgentOwner(sessionRef);
118
125
  const _controlPlaneTool = WORKER_DENIED_TOOLS.has(name);
119
- if (_bridgeOwned && _controlPlaneTool) {
120
- 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.`;
121
128
  }
122
129
  const noToolRole = sessionRef?.role === 'cycle1-agent' || sessionRef?.role === 'cycle2-agent';
123
130
  if (noToolRole) {
124
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).`;
125
132
  }
126
- if (isBlockedHiddenWrapperCall(name, sessionRef)) {
127
- 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.`;
128
- }
129
- const effectivePermission = effectiveToolPermission(sessionRef);
130
- const permissionBlocked = isBlockedByPermission(name, toolKind, effectivePermission);
131
- if (permissionBlocked && effectivePermission === 'mcp') {
132
- return `Error: tool "${name}" is not available on this session (permission=mcp). Use MCP/internal retrieval tools only.`;
133
- }
134
- if (permissionBlocked && effectivePermission === 'read') {
135
- return `Error: tool "${name}" is not available on this session (permission=read). Use Mixdog MCP read/grep/glob/recall/search/explore instead.`;
136
- }
137
- if (permissionBlocked && effectivePermission && typeof effectivePermission === 'object') {
138
- return `Error: tool "${name}" is not permitted on this session by the role's allow/deny permission policy.`;
139
- }
140
133
  return null;
141
134
  }
142
135
  /** Exported for smoke tests — same runtime deny as the agent loop. */
@@ -146,26 +139,12 @@ export function preDispatchDenyForSession(sessionRef, call, toolKind = 'builtin'
146
139
  import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
147
140
 
148
141
 
149
- import { isHiddenRole } from '../internal-roles.mjs';
150
- import { createRequire } from 'module';
151
142
  import { readFileSync as _readFileSync } from 'fs';
152
143
  import { fileURLToPath } from 'url';
153
144
  import { dirname, resolve as resolvePath, isAbsolute } from 'path';
154
- // Load the CJS permission evaluator. The hooks/ directory lives above
155
- // src/runtime/agent/orchestrator/session/, so we walk up from __dirname.
156
- const _require = createRequire(import.meta.url);
157
- const _hooksLib = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
158
- const { evaluatePermission: _evaluatePermission } = _require(_hooksLib);
159
145
  const MCP_TOOL_PREFIX = 'mcp__plugin_mixdog_mixdog__';
160
146
  const COMPACT_SAFETY_PERCENT = 1.00;
161
147
  const COMPACT_BUFFER_MAX_WINDOW_FRACTION = 0.25;
162
- // Stricter budget used for the one-shot retry after a provider rejects a send
163
- // with a context-window-exceeded error. 0.60×contextWindow forces older
164
- // non-system history into a tighter compact summary when the pre-send estimate
165
- // under-counted provider-side bytes (tool schemas, framing,
166
- // provider-internal token accounting). Used exactly once per send; see the
167
- // retry block around provider.send below.
168
- const OVERFLOW_RETRY_COMPACT_PERCENT = 0.60;
169
148
 
170
149
  function estimateMessagesTokensSafe(messages) {
171
150
  try { return estimateMessagesTokens(messages); }
@@ -233,18 +212,18 @@ function mergeSteeringEntries(entries) {
233
212
  return { content: parts, text: displayText || steeringContentText(parts), count: normalized.length };
234
213
  }
235
214
 
236
- class BridgeContextOverflowError extends Error {
215
+ class AgentContextOverflowError extends Error {
237
216
  constructor({ stage, sessionId, provider, model, contextWindow, budgetTokens, reserveTokens, messageTokensEst }, cause) {
238
217
  const target = [provider, model].filter(Boolean).join('/') || 'target model';
239
218
  const causeMsg = cause && cause.message ? `: ${cause.message}` : '';
240
219
  super(
241
- `bridge context overflow (${target}, stage=${stage || 'compact'}): ` +
220
+ `agent context overflow (${target}, stage=${stage || 'compact'}): ` +
242
221
  `latest turn cannot fit target context budget=${budgetTokens ?? 'unknown'} ` +
243
222
  `reserve=${reserveTokens ?? 'unknown'} contextWindow=${contextWindow ?? 'unknown'} ` +
244
223
  `messageTokensEst=${messageTokensEst ?? 'unknown'}${causeMsg}`,
245
224
  );
246
- this.name = 'BridgeContextOverflowError';
247
- this.code = 'BRIDGE_CONTEXT_OVERFLOW';
225
+ this.name = 'AgentContextOverflowError';
226
+ this.code = 'AGENT_CONTEXT_OVERFLOW';
248
227
  this.sessionId = sessionId || null;
249
228
  this.provider = provider || null;
250
229
  this.model = model || null;
@@ -256,8 +235,8 @@ class BridgeContextOverflowError extends Error {
256
235
  }
257
236
  }
258
237
 
259
- function bridgeContextOverflowError({ stage, sessionId, sessionRef, model, budgetTokens, reserveTokens, messageTokensEst }, cause) {
260
- return new BridgeContextOverflowError({
238
+ function agentContextOverflowError({ stage, sessionId, sessionRef, model, budgetTokens, reserveTokens, messageTokensEst }, cause) {
239
+ return new AgentContextOverflowError({
261
240
  stage,
262
241
  sessionId,
263
242
  provider: sessionRef?.provider || null,
@@ -270,7 +249,7 @@ function bridgeContextOverflowError({ stage, sessionId, sessionRef, model, budge
270
249
  }
271
250
 
272
251
  // Cache-hit results always inline the cached body. The earlier size-gated
273
- // `[cache-hit-ref]` branch confused bridge agents whose context did not
252
+ // `[cache-hit-ref]` branch confused agents whose context did not
274
253
  // contain the referenced prior tool_result, triggering shell-cat detours.
275
254
  // Hard iteration ceiling for every agent loop. Reset to 0 whenever the
276
255
  // transcript is compacted (see the trim block below): a long task that keeps
@@ -370,30 +349,22 @@ function _ensureTranscriptPairing(msgs, sessionId) {
370
349
  }
371
350
  }
372
351
 
373
- // Write-class tools that a permission=read session must not execute. The
374
- // schema still advertises them to keep one unified shard; this runtime set
375
- // is the fail-safe reject at call time.
376
- const READ_BLOCKED_TOOLS = new Set([
377
- 'shell', 'bash_session',
378
- 'write',
379
- 'edit',
380
- 'apply_patch',
381
- ]);
382
- const MCP_ONLY_ALLOWED_KINDS = new Set(['mcp', 'internal', 'skill']);
383
- // Wrappers that hidden retrieval roles back. Hidden roles MUST NOT call
384
- // these or they spawn another hidden agent of the same kind — nested chain
385
- // + token burn. Block at call time; the role's rule prompt also says so.
386
- const RETRIEVAL_WRAPPERS = new Set(['recall', 'search', 'explore']);
387
- // Hidden roles that may call specific retrieval wrappers. Default policy
388
- // blocks all hidden→wrapper calls; roles listed here have a documented
389
- // need:
390
- // - scheduler-task / webhook-handler: state-changing agents whose
391
- // tasks routinely require both reach-back into past context
392
- // (`recall`) and fresh external info (`search`).
393
- const HIDDEN_ROLE_WRAPPER_ALLOWLIST = {
394
- 'scheduler-task': new Set(['recall', 'search']),
395
- 'webhook-handler': new Set(['recall', 'search']),
396
- };
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
+
397
368
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
398
369
  // to execute during SSE parsing so tool work overlaps with the rest of the
399
370
  // stream. Writes, bash, MCP and skills stay serial after send() returns.
@@ -402,94 +373,6 @@ function isEagerDispatchable(name, tools) {
402
373
  const def = tools.find(t => t?.name === name);
403
374
  return def?.annotations?.readOnlyHint === true;
404
375
  }
405
- // ── Bridge-worker permission enforcement ──────────────────────────────────────
406
- // Mirrors the PreToolUse hook evaluation for tool calls that originate inside a
407
- // bridge worker session. Worker dispatch previously bypassed the hook pipeline
408
- // entirely; this guard closes that gap by running the same evaluator inline.
409
- //
410
- // `ask` is treated as deny here — forwarding `ask` decisions to the channel
411
- // UI approval flow needs bidirectional prompt plumbing that does not exist.
412
- function _checkWorkerPermission(toolName, toolInput, sessionRef) {
413
- const bareToolName = _stripMcpPrefix(toolName);
414
- if (sessionRef?.owner === 'bridge' && bareToolName === 'shell') {
415
- const cmdClass = classifyBashFileLookupCommand(toolInput?.command);
416
- if (cmdClass) {
417
- 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.`;
418
- }
419
- const gitClass = classifyBridgeWorkerGitMutationCommand(toolInput?.command);
420
- if (gitClass) {
421
- return `Error: bridge worker git operation blocked (${gitClass}). Git operations are deferred to Lead.`;
422
- }
423
- }
424
- // Even when no explicit permissionMode is propagated to the worker, run
425
- // the evaluator under the most restrictive baseline ('default') so the
426
- // bypass-proof hard-deny patterns (UNC paths, /etc, C:/Windows, etc.)
427
- // and the user's settings.json deny rules still apply. Previously a
428
- // missing permissionMode short-circuited to null and the worker
429
- // ran ungated — a model could dispatch a bridge to read or write
430
- // protected paths even when the same call would have been denied for
431
- // the parent. Callers that genuinely need bypassPermissions can still
432
- // forward it explicitly via session-builder; this only closes the
433
- // silent default-to-bypass path.
434
- const permissionMode = sessionRef?.permissionMode || 'default';
435
- // Prefix bare mixdog tool names so the evaluator path-logic handles them correctly.
436
- const fullName = toolName.startsWith(MCP_TOOL_PREFIX) || toolName.startsWith('mcp__')
437
- ? toolName
438
- : `${MCP_TOOL_PREFIX}${toolName}`;
439
- const projectDir = sessionRef?.cwd || undefined;
440
- const userCwd = sessionRef?.cwd || undefined;
441
- try {
442
- const { decision, reason } = _evaluatePermission({
443
- toolName: fullName,
444
- toolInput: toolInput || {},
445
- permissionMode,
446
- projectDir,
447
- userCwd,
448
- });
449
- if (decision === 'deny' || decision === 'ask') {
450
- return `Error: tool "${toolName}" blocked by permission evaluator (decision=${decision}): ${reason}`;
451
- }
452
- } catch (err) {
453
- // Evaluator errors must not crash the loop — log and allow.
454
- try { process.stderr.write(`[permission-evaluator] error: ${err?.message}\n`); } catch {}
455
- }
456
- return null;
457
- }
458
- function effectiveToolPermission(sessionRef) {
459
- return sessionRef?.toolPermission || sessionRef?.permission || null;
460
- }
461
- function isBlockedByPermission(toolName, toolKind, permission) {
462
- if (permission === 'mcp') return !MCP_ONLY_ALLOWED_KINDS.has(toolKind);
463
- if (permission === 'read') return READ_BLOCKED_TOOLS.has(toolName);
464
- // Object-form {allow,deny} permission (role template / profile). The
465
- // schema-level intersection in createSession only narrows the ADVERTISED
466
- // tool list; it is not a runtime execution boundary. Enforce the same
467
- // allow/deny here as the fail-safe so a tool call for a non-advertised
468
- // (denied / out-of-allow) tool is rejected at dispatch time, matching
469
- // the string-form ('read'/'mcp') guards. Names are compared bare +
470
- // lowercased to mirror createSession's allow/deny set construction.
471
- if (permission && typeof permission === 'object') {
472
- const name = String(_stripMcpPrefix(toolName) || '').toLowerCase();
473
- const deny = Array.isArray(permission.deny) && permission.deny.length > 0
474
- ? permission.deny.map(n => String(n).toLowerCase())
475
- : null;
476
- if (deny && deny.includes(name)) return true;
477
- const allow = Array.isArray(permission.allow) && permission.allow.length > 0
478
- ? permission.allow.map(n => String(n).toLowerCase())
479
- : null;
480
- if (allow && !allow.includes(name)) return true;
481
- return false;
482
- }
483
- return false;
484
- }
485
- function isBlockedHiddenWrapperCall(toolName, sessionRef) {
486
- if (!RETRIEVAL_WRAPPERS.has(toolName)) return false;
487
- if (sessionRef?.owner !== 'bridge') return false;
488
- if (!isHiddenRole(sessionRef?.role)) return false;
489
- const allow = HIDDEN_ROLE_WRAPPER_ALLOWLIST[sessionRef.role];
490
- if (allow && allow.has(toolName)) return false;
491
- return true;
492
- }
493
376
  function messagesArrayChanged(before, after) {
494
377
  if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
495
378
  if (before.length !== after.length) return true;
@@ -528,38 +411,6 @@ function addUsage(total, usage) {
528
411
  }
529
412
  return next;
530
413
  }
531
- function splitMessagesForRemoteCompact(messages) {
532
- if (!Array.isArray(messages)) return null;
533
- const system = messages.filter(m => m?.role === 'system');
534
- const nonSystem = messages.filter(m => m?.role !== 'system');
535
- let turnStart = -1;
536
- for (let i = nonSystem.length - 1; i >= 0; i -= 1) {
537
- if (nonSystem[i]?.role === 'user') {
538
- turnStart = i;
539
- break;
540
- }
541
- }
542
- if (turnStart <= 0) return null;
543
- const oldHistory = nonSystem.slice(0, turnStart);
544
- if (!oldHistory.length) return null;
545
- return [...system, ...oldHistory];
546
- }
547
- function markRemoteCompactFallback(messages, providerName) {
548
- if (!Array.isArray(messages)) return false;
549
- for (const m of messages) {
550
- if (m?.role !== 'user' || typeof m.content !== 'string') continue;
551
- if (!m.content.startsWith(SUMMARY_PREFIX)) continue;
552
- m._mixdogRemoteCompactFallback = 'openai-codex';
553
- m._mixdogRemoteCompactProvider = providerName || 'openai-oauth';
554
- return true;
555
- }
556
- return false;
557
- }
558
- function providerRemoteCompactEnabled(provider, opts) {
559
- if (opts?.remoteCompact === false) return false;
560
- if (process.env.MIXDOG_REMOTE_COMPACT === '0') return false;
561
- return typeof provider?.remoteCompactMessages === 'function';
562
- }
563
414
  function positiveTokenInt(value) {
564
415
  const n = Number(value);
565
416
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
@@ -573,35 +424,187 @@ function envTokenInt(name) {
573
424
  return positiveTokenInt(process.env[name]);
574
425
  }
575
426
  function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
576
- const env = process.env.MIXDOG_BRIDGE_COMPACT_SEMANTIC;
577
- 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);
578
428
  if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
579
429
  if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on') return true;
580
- // OpenCode keeps compaction as a session concern. For Mixdog, default the
581
- // semantic agent only on bridge-owned workers so direct user sessions and
582
- // narrow tests do not pay an extra provider call unless they opt in.
583
- 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;
584
535
  }
585
536
  function resolveCompactBufferRatio(cfg = {}) {
586
- return normalizeCompactionBufferRatio(
587
- cfg.bufferPercent
588
- ?? cfg.bufferPct
589
- ?? cfg.bufferRatio
590
- ?? cfg.bufferFraction
591
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_PERCENT
592
- ?? process.env.MIXDOG_BRIDGE_COMPACT_BUFFER_RATIO,
593
- 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],
594
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
+ };
595
597
  }
596
598
  function resolveCompactBufferTokens(boundaryTokens, cfg = {}) {
597
- const configured = positiveTokenInt(cfg.bufferTokens ?? cfg.buffer)
598
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_BUFFER_TOKENS')
599
- || 0;
600
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;
601
604
  if (!boundary) return configured || DEFAULT_COMPACTION_BUFFER_TOKENS;
602
605
  return compactionBufferTokensForBoundary(boundary, {
603
606
  explicitTokens: configured,
604
- ratio: resolveCompactBufferRatio(cfg),
607
+ ratio: resolveCompactBufferRatio(effectiveCfg),
605
608
  maxRatio: COMPACT_BUFFER_MAX_WINDOW_FRACTION,
606
609
  });
607
610
  }
@@ -613,7 +616,7 @@ function resolveCompactTargetRatio(cfg = {}) {
613
616
  ?? cfg.targetPct
614
617
  ?? cfg.targetRatio
615
618
  ?? cfg.targetFraction
616
- ?? process.env.MIXDOG_BRIDGE_COMPACT_TARGET_PERCENT
619
+ ?? process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
617
620
  ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
618
621
  ?? COMPACT_TARGET_RATIO;
619
622
  const n = Number(raw);
@@ -624,15 +627,15 @@ function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
624
627
  const boundary = positiveTokenInt(boundaryTokens);
625
628
  if (!boundary) return null;
626
629
  const explicit = positiveTokenInt(cfg.targetTokens ?? cfg.target)
627
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_TARGET_TOKENS')
630
+ || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_TOKENS')
628
631
  || envTokenInt('MIXDOG_COMPACT_TARGET_TOKENS');
629
632
  if (explicit) return Math.max(1, Math.min(boundary, explicit));
630
633
  const minTarget = Math.min(boundary, positiveTokenInt(cfg.targetMinTokens ?? cfg.minTargetTokens)
631
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_TARGET_MIN_TOKENS')
634
+ || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MIN_TOKENS')
632
635
  || envTokenInt('MIXDOG_COMPACT_TARGET_MIN_TOKENS')
633
636
  || COMPACT_TARGET_MIN_TOKENS);
634
637
  const maxTarget = Math.min(boundary, positiveTokenInt(cfg.targetMaxTokens ?? cfg.maxTargetTokens)
635
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_TARGET_MAX_TOKENS')
638
+ || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MAX_TOKENS')
636
639
  || envTokenInt('MIXDOG_COMPACT_TARGET_MAX_TOKENS')
637
640
  || COMPACT_TARGET_MAX_TOKENS);
638
641
  const byRatio = Math.max(1, Math.floor(boundary * resolveCompactTargetRatio(cfg)));
@@ -640,13 +643,13 @@ function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
640
643
  }
641
644
  function resolveCompactKeepTokens(cfg = {}) {
642
645
  return positiveTokenInt(cfg.keepTokens ?? cfg.keep?.tokens ?? cfg.preserveRecentTokens)
643
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_KEEP_TOKENS')
646
+ || envTokenInt('MIXDOG_AGENT_COMPACT_KEEP_TOKENS')
644
647
  || DEFAULT_COMPACTION_KEEP_TOKENS;
645
648
  }
646
649
  function resolveWorkerCompactPolicy(sessionRef, tools) {
647
650
  if (!sessionRef) return null;
648
651
  const cfg = sessionRef.compaction || {};
649
- const auto = cfg.auto !== false && envFlag('MIXDOG_BRIDGE_COMPACT_AUTO', true);
652
+ const auto = cfg.auto !== false && envFlag('MIXDOG_AGENT_COMPACT_AUTO', true);
650
653
  if (!auto) return { auto: false };
651
654
  const contextWindow = positiveTokenInt(sessionRef.contextWindow ?? cfg.contextWindow);
652
655
  const explicitBoundary = positiveTokenInt(sessionRef.compactBoundaryTokens ?? cfg.boundaryTokens);
@@ -656,28 +659,33 @@ function resolveWorkerCompactPolicy(sessionRef, tools) {
656
659
  : (explicitBoundary || contextWindow || autoLimit);
657
660
  if (!boundaryTokens) return null;
658
661
  const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
659
- 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;
660
673
  const bufferTokens = autoTriggerTokens
661
674
  ? Math.max(0, compactBoundaryTokens - autoTriggerTokens)
662
675
  : resolveCompactBufferTokens(compactBoundaryTokens, cfg);
663
676
  const bufferRatio = compactBoundaryTokens ? (bufferTokens / compactBoundaryTokens) : resolveCompactBufferRatio(cfg);
664
677
  const triggerTokens = autoTriggerTokens || Math.max(1, compactBoundaryTokens - bufferTokens);
665
678
  const configuredReserve = positiveTokenInt(cfg.reservedTokens)
666
- || envTokenInt('MIXDOG_BRIDGE_COMPACT_RESERVED_TOKENS')
679
+ || envTokenInt('MIXDOG_AGENT_COMPACT_RESERVED_TOKENS')
667
680
  || 0;
668
681
  const requestReserve = estimateRequestReserveTokens(tools);
669
682
  const keepTokens = resolveCompactKeepTokens(cfg);
683
+ const compactType = resolveCompactTypeSetting(sessionRef, cfg);
670
684
  return {
671
685
  auto: true,
672
- prune: cfg.prune === true || envFlag('MIXDOG_BRIDGE_COMPACT_PRUNE', false),
673
- // Narrow active-turn fallback (bridge/worker only). When system +
674
- // current turn alone overflow the budget, shrink older same-turn tool
675
- // outputs / drop older same-turn groups before declaring overflow,
676
- // preserving system + task user + the latest group(s) and tool
677
- // pairing. Defaults on for workers; disable via env to restore the
678
- // strict no-fallback behavior.
679
- activeTurnFallback: cfg.activeTurnFallback !== false
680
- && envFlag('MIXDOG_BRIDGE_COMPACT_ACTIVE_TURN_FALLBACK', true),
686
+ type: compactType,
687
+ compactType,
688
+ prune: cfg.prune === true || envFlag('MIXDOG_AGENT_COMPACT_PRUNE', false),
681
689
  boundaryTokens: compactBoundaryTokens,
682
690
  triggerTokens,
683
691
  bufferTokens,
@@ -687,35 +695,30 @@ function resolveWorkerCompactPolicy(sessionRef, tools) {
687
695
  effectiveContextWindowPercent: Number.isFinite(Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent))
688
696
  ? Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent)
689
697
  : null,
690
- autoCompactTokenLimit: positiveTokenInt(sessionRef.autoCompactTokenLimit ?? cfg.autoCompactTokenLimit),
691
- semantic: resolveSemanticCompactSetting(sessionRef, cfg),
692
- semanticTimeoutMs: positiveTokenInt(cfg.timeoutMs) || envTokenInt('MIXDOG_BRIDGE_COMPACT_TIMEOUT_MS') || 30_000,
693
- 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,
694
703
  keepTokens,
695
- 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,
696
705
  reserveTokens: requestReserve + configuredReserve,
697
706
  requestReserveTokens: requestReserve,
698
707
  configuredReserveTokens: configuredReserve,
699
708
  };
700
709
  }
701
- function latestProviderContextTokens(sessionRef) {
702
- const tokens = positiveTokenInt(sessionRef?.lastContextTokens);
703
- if (!tokens) return 0;
704
- const compactAt = positiveTokenInt(sessionRef?.compaction?.lastChangedAt ?? sessionRef?.compaction?.lastCompactAt) || 0;
705
- const usageAt = positiveTokenInt(sessionRef?.lastContextTokensUpdatedAt) || 0;
706
- // Legacy sessions did not stamp usage. Treat that usage as usable until a
707
- // new compaction boundary appears; after compaction, only post-compaction
708
- // provider usage may pressure the next auto-compact decision.
709
- if (compactAt > 0 && usageAt > 0 && usageAt <= compactAt) return 0;
710
- if (compactAt > 0 && usageAt === 0) return 0;
711
- return tokens;
712
- }
713
- function compactPressureTokens(messageTokensEst, policy, sessionRef) {
714
- const estimated = messageTokensEst === null
715
- ? null
716
- : Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
717
- const providerReported = latestProviderContextTokens(sessionRef);
718
- 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;
719
722
  }
720
723
  function compactTargetBudget(policy) {
721
724
  const boundary = positiveTokenInt(policy?.boundaryTokens);
@@ -724,10 +727,11 @@ function compactTargetBudget(policy) {
724
727
  const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
725
728
  return Math.max(1, Math.min(boundary, targetEffective + reserve));
726
729
  }
727
- function shouldCompactForSession(messageTokensEst, policy, sessionRef) {
730
+ function shouldCompactForSession(messageTokensEst, policy, { forceReactive = false } = {}) {
728
731
  if (!policy?.auto || !policy.boundaryTokens) return false;
732
+ if (forceReactive) return true;
729
733
  if (messageTokensEst === null) return true;
730
- return compactPressureTokens(messageTokensEst, policy, sessionRef) >= (policy.triggerTokens || policy.boundaryTokens);
734
+ return compactPressureTokens(messageTokensEst, policy) >= (policy.triggerTokens || policy.boundaryTokens);
731
735
  }
732
736
  function countPrunedToolOutputs(before, after) {
733
737
  if (!Array.isArray(before) || !Array.isArray(after)) return 0;
@@ -760,7 +764,10 @@ function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
760
764
  rawContextWindow: policy.rawContextWindow || null,
761
765
  effectiveContextWindowPercent: policy.effectiveContextWindowPercent ?? null,
762
766
  autoCompactTokenLimit: policy.autoCompactTokenLimit || null,
767
+ type: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
768
+ compactType: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
763
769
  semantic: policy.semantic === true ? 'auto' : false,
770
+ recallFastTrack: policy.recallFastTrack === true,
764
771
  semanticModel: policy.semanticModel || null,
765
772
  semanticTimeoutMs: policy.semanticTimeoutMs || null,
766
773
  tailTurns: policy.tailTurns || null,
@@ -770,12 +777,26 @@ function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
770
777
  lastBeforeTokens: meta.beforeTokens ?? null,
771
778
  lastAfterTokens: meta.afterTokens ?? null,
772
779
  lastPressureTokens: meta.pressureTokens ?? null,
780
+ currentEstimatedTokens: meta.pressureTokens ?? prev.currentEstimatedTokens ?? null,
781
+ lastApiRequestTokens: positiveTokenInt(sessionRef?.lastContextTokens) || prev.lastApiRequestTokens || null,
773
782
  lastStage: meta.stage || prev.lastStage || null,
774
783
  lastChanged: changed,
775
- lastRemote: meta.remoteCompact === true,
784
+ lastTrigger: meta.trigger || prev.lastTrigger || null,
776
785
  lastSemantic: meta.semanticCompact === true,
777
- 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),
778
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,
779
800
  compactCount: (prev.compactCount || 0) + (changed ? 1 : 0),
780
801
  };
781
802
  if (changed) {
@@ -786,13 +807,32 @@ function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
786
807
  }
787
808
  sessionRef.contextWindow = policy.contextWindow || sessionRef.contextWindow;
788
809
  sessionRef.rawContextWindow = policy.rawContextWindow || sessionRef.rawContextWindow;
789
- sessionRef.autoCompactTokenLimit = policy.autoCompactTokenLimit || sessionRef.autoCompactTokenLimit || null;
790
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
+ }
791
821
  if (policy.effectiveContextWindowPercent !== null) {
792
822
  sessionRef.effectiveContextWindowPercent = policy.effectiveContextWindowPercent;
793
823
  }
794
824
  }
795
- 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']);
796
836
  const SPECIAL_TOOL_NAMES = new Set(['bash_session', 'apply_patch', 'code_graph']);
797
837
  const BASH_SESSION_HEADER_RE = /\[session: ([^\]\r\n]+)\]/;
798
838
  const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
@@ -908,12 +948,55 @@ function buildSkillsListResponse(cwd) {
908
948
  function viewSkill(cwd, name) {
909
949
  if (!name) return 'Error: skill name is required';
910
950
  const content = loadSkillContent(name, cwd);
911
- 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>`;
912
953
  }
913
- function executeSkill(cwd, name, _args) {
914
- if (!name) return 'Error: skill name is required';
915
- const content = loadSkillContent(name, cwd);
916
- 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
+ }
917
1000
  }
918
1001
  function extractBashSessionId(result) {
919
1002
  if (typeof result !== 'string') return null;
@@ -921,8 +1004,8 @@ function extractBashSessionId(result) {
921
1004
  return match ? match[1] : null;
922
1005
  }
923
1006
 
924
- export function buildBridgeBashSessionArgs(args, sessionRef) {
925
- if (sessionRef?.owner !== 'bridge') return null;
1007
+ export function buildAgentBashSessionArgs(args, sessionRef) {
1008
+ if (!isAgentOwner(sessionRef)) return null;
926
1009
  // run_in_background is a detached one-shot job, incompatible with the
927
1010
  // persistent bash session. Fall through to the background-job path
928
1011
  // (executeBuiltinTool -> startBackgroundShellJob) so the worker gets a
@@ -944,6 +1027,51 @@ export function buildBridgeBashSessionArgs(args, sessionRef) {
944
1027
  return routedArgs;
945
1028
  }
946
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
+
947
1075
  function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
948
1076
  if (executeOpts.scopedCacheOutcome) {
949
1077
  if (sessionRef && toolCallId) {
@@ -972,14 +1100,20 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
972
1100
  const toolOpts = scopedCacheOutcome
973
1101
  ? { ...executeOpts, scopedCacheOutcome }
974
1102
  : executeOpts;
975
- const notifyFn = (text) => {
976
- if (!callerSessionId) return;
977
- try { enqueuePendingMessage(callerSessionId, String(text || '')); } catch { /* best effort */ }
978
- };
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
+ };
979
1113
  const completionToolOpts = {
980
1114
  ...toolOpts,
981
1115
  sessionId: callerSessionId,
982
- callerSessionId,
1116
+ callerSessionId: notificationSessionId || callerSessionId,
983
1117
  routingSessionId: callerSessionId,
984
1118
  clientHostPid: sessionRef?.clientHostPid,
985
1119
  notifyFn,
@@ -987,6 +1121,9 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
987
1121
  const beforeToolHook = typeof executeOpts.beforeToolHook === 'function'
988
1122
  ? executeOpts.beforeToolHook
989
1123
  : sessionRef?.beforeToolHook;
1124
+ const toolApprovalHook = typeof executeOpts.toolApprovalHook === 'function'
1125
+ ? executeOpts.toolApprovalHook
1126
+ : sessionRef?.toolApprovalHook;
990
1127
  if (beforeToolHook) {
991
1128
  try {
992
1129
  const decision = await beforeToolHook({
@@ -1001,6 +1138,23 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1001
1138
  const reason = decision?.reason ? `: ${decision.reason}` : '';
1002
1139
  return `Error: tool "${name}" denied by hook${reason}`;
1003
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
+ }
1004
1158
  if ((action === 'modify' || action === 'rewrite') && decision?.args && typeof decision.args === 'object' && !Array.isArray(decision.args)) {
1005
1159
  args = decision.args;
1006
1160
  }
@@ -1008,15 +1162,19 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1008
1162
  // Hooks are policy extensions. A broken hook must not wedge the agent loop.
1009
1163
  }
1010
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
+ }
1011
1172
  if (name === 'skills_list') {
1012
1173
  return buildSkillsListResponse(cwd);
1013
1174
  }
1014
1175
  if (name === 'skill_view') {
1015
1176
  return viewSkill(cwd, args?.name);
1016
1177
  }
1017
- if (name === 'skill_execute') {
1018
- return executeSkill(cwd, args?.name, args?.args);
1019
- }
1020
1178
  if (isMcpTool(name)) {
1021
1179
  // 24h trace data shows ~24% of external MCP calls are cwd-sensitive
1022
1180
  // (bash / grep / read / list / glob etc.) but the worker session's
@@ -1029,10 +1187,10 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1029
1187
  const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
1030
1188
  return executeMcpTool(name, finalArgs);
1031
1189
  }
1032
- if (isCodeGraphTool(name)) {
1190
+ if (name === 'code_graph') {
1033
1191
  // cwd chain: args.cwd (caller-explicit) → session cwd → undefined (handler throws)
1034
1192
  const graphCwd = (typeof args?.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
1035
- return executeCodeGraphTool(name, args, graphCwd, null, toolOpts);
1193
+ return executeCodeGraphToolLazy(name, args, graphCwd, null, toolOpts);
1036
1194
  }
1037
1195
  if (isInternalTool(name)) {
1038
1196
  // callerSessionId propagates into server.mjs dispatchTool so that
@@ -1048,14 +1206,14 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1048
1206
  });
1049
1207
  }
1050
1208
  if (name === 'shell') {
1051
- const routedArgs = buildBridgeBashSessionArgs(args, sessionRef);
1209
+ const routedArgs = buildAgentBashSessionArgs(args, sessionRef);
1052
1210
  if (!routedArgs) {
1053
1211
  // clientHostPid scopes background shell-jobs to the dispatching
1054
- // terminal's claude.exe pid (bridge sessions store it on sessionRef);
1212
+ // terminal's claude.exe pid (agent sessions store it on sessionRef);
1055
1213
  // without it resolveJobOwnerHostPid falls back to the daemon-global env.
1056
1214
  return executeBuiltinTool(name, args, cwd, completionToolOpts);
1057
1215
  }
1058
- // 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
1059
1217
  // persistent child process. getSessionAbortSignal is imported at top of
1060
1218
  // loop.mjs from manager.mjs; callerSessionId identifies the controller.
1061
1219
  let _bashAbortSignal = null;
@@ -1079,7 +1237,8 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1079
1237
  return result;
1080
1238
  }
1081
1239
  if (name === 'apply_patch') {
1082
- return executePatchTool(name, args, cwd, { sessionId: callerSessionId });
1240
+ const patchArgs = typeof args === 'string' ? { patch: args } : args;
1241
+ return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId });
1083
1242
  }
1084
1243
  if (isBuiltinTool(name)) {
1085
1244
  // clientHostPid threaded for the same per-terminal job-scope reason as
@@ -1087,6 +1246,23 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1087
1246
  return executeBuiltinTool(name, args, cwd, completionToolOpts);
1088
1247
  }
1089
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;
1090
1266
  }
1091
1267
  /**
1092
1268
  * Agent loop: send → tool_call → execute → re-send → repeat until text.
@@ -1115,6 +1291,22 @@ const INCOMPLETE_STOP_REASONS = new Set([
1115
1291
  'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
1116
1292
  ]);
1117
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
+
1118
1310
  export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
1119
1311
  let iterations = 0;
1120
1312
  let toolCallsTotal = 0;
@@ -1122,6 +1314,12 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1122
1314
  let firstTurnUsage;
1123
1315
  let response;
1124
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;
1125
1323
  const opts = sendOpts || {};
1126
1324
  const sessionId = opts.sessionId || null;
1127
1325
  const signal = opts.signal || null;
@@ -1131,9 +1329,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1131
1329
  ? tools.find(tool => tool?.name === forcedFirstTool)
1132
1330
  : null;
1133
1331
  // Opaque providerState passthrough. The loop never inspects provider-native
1134
- // payloads; the originating provider owns them. OpenAI Codex uses this for
1135
- // native remote compaction prefixes, and stateful Responses providers may
1136
- // use it for continuation anchors.
1332
+ // payloads; the originating provider owns them. Stateful Responses
1333
+ // providers may use it for continuation anchors.
1137
1334
  let providerState = opts.providerState ?? undefined;
1138
1335
  const throwIfAborted = () => {
1139
1336
  if (signal?.aborted) {
@@ -1146,17 +1343,34 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1146
1343
  }
1147
1344
  };
1148
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;
1149
1360
  const pushToolResultMessage = (message) => {
1150
1361
  messages.push(message);
1151
1362
  try { opts.onToolResult?.(message); } catch {}
1152
1363
  };
1153
- const drainSteeringIntoMessages = (stage = 'mid-turn') => {
1364
+ const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
1154
1365
  if (typeof opts.drainSteering !== 'function') return false;
1155
1366
  let steerMsgs = [];
1156
1367
  try { steerMsgs = opts.drainSteering(sessionId) || []; }
1157
1368
  catch { steerMsgs = []; }
1158
1369
  const merged = mergeSteeringEntries(steerMsgs);
1159
1370
  if (!merged) return false;
1371
+ if (typeof options.beforeAppend === 'function') {
1372
+ try { options.beforeAppend(); } catch { /* best-effort hook */ }
1373
+ }
1160
1374
  messages.push({ role: 'user', content: merged.content });
1161
1375
  try { opts.onSteerMessage?.(merged.text || steeringContentText(merged.content)); } catch {}
1162
1376
  if (sessionId) {
@@ -1164,11 +1378,29 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1164
1378
  }
1165
1379
  return true;
1166
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
+ };
1167
1399
  const maxLoopIterations = Number.isFinite(sessionRef?.maxLoopIterations)
1168
1400
  ? sessionRef.maxLoopIterations
1169
1401
  : MAX_LOOP_ITERATIONS;
1170
1402
  // Tool execution must use the session cwd even when the caller omitted the
1171
- // legacy positional cwd argument. Bridge workers always carry their cwd on
1403
+ // legacy positional cwd argument. Agent workers always carry their cwd on
1172
1404
  // sessionRef; falling through to pwd()/process.cwd() resolves relatives
1173
1405
  // against the host/plugin root instead of the worker workspace.
1174
1406
  cwd = cwd || sessionRef?.cwd || undefined;
@@ -1178,6 +1410,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1178
1410
  process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
1179
1411
  break;
1180
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');
1181
1418
  const compactPolicy = resolveWorkerCompactPolicy(sessionRef, tools);
1182
1419
  if (compactPolicy?.auto) {
1183
1420
  // Snapshot pre-compact shape so compact_meta can record the actual
@@ -1185,11 +1422,25 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1185
1422
  // a best-effort JSON.stringify length — close enough to the
1186
1423
  // payload we hand the provider for prefix-cache analysis.
1187
1424
  const beforeCount = messages.length;
1188
- let beforeBytes = null;
1189
- 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
+ };
1190
1440
  const messageTokensEst = estimateMessagesTokensSafe(messages);
1191
- const pressureTokens = compactPressureTokens(messageTokensEst, compactPolicy, sessionRef);
1192
- 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 });
1193
1444
  const compactBudgetTokens = shouldCompact
1194
1445
  ? (compactTargetBudget({ ...compactPolicy, pressureTokens }) || compactPolicy.boundaryTokens)
1195
1446
  : compactPolicy.boundaryTokens;
@@ -1202,18 +1453,27 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1202
1453
  });
1203
1454
  } else {
1204
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;
1205
1463
  rememberCompactTelemetry(sessionRef, compactPolicy, {
1206
1464
  stage: 'compacting',
1207
1465
  beforeTokens: messageTokensEst,
1208
1466
  afterTokens: messageTokensEst,
1209
1467
  pressureTokens,
1468
+ trigger: compactTrigger,
1210
1469
  });
1211
1470
  let compacted;
1212
- let remoteCompactResult = null;
1213
1471
  let pruneCount = 0;
1214
1472
  let summaryChanged = false;
1215
1473
  let semanticCompactResult = null;
1216
1474
  let semanticCompactError = null;
1475
+ let recallFastTrackResult = null;
1476
+ let recallFastTrackError = null;
1217
1477
  try {
1218
1478
  let compactInputMessages = messages;
1219
1479
  if (compactPolicy.prune) {
@@ -1223,12 +1483,32 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1223
1483
  pruneCount = countPrunedToolOutputs(messages, pruned);
1224
1484
  compactInputMessages = pruned;
1225
1485
  }
1226
- // Pre-send compaction replaces destructive drop: older
1227
- // non-system history is condensed into one summary message.
1228
- // The current turn and tool pairing stay intact; if those
1229
- // mandatory parts cannot fit, compactMessages throws instead
1230
- // of silently discarding user-visible context.
1231
- 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) {
1232
1512
  try {
1233
1513
  semanticCompactResult = await semanticCompactMessages(
1234
1514
  provider,
@@ -1253,10 +1533,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1253
1533
  const semanticMessages = Array.isArray(semanticCompactResult?.messages)
1254
1534
  ? semanticCompactResult.messages
1255
1535
  : null;
1256
- if (semanticMessages && (semanticCompactResult?.semantic === true
1257
- || messagesArrayChanged(compactInputMessages, semanticMessages))) {
1258
- compacted = semanticMessages;
1259
- }
1536
+ if (!semanticMessages) throw new Error('semantic compact produced no messages');
1537
+ compacted = semanticMessages;
1260
1538
  if (semanticCompactResult?.usage) {
1261
1539
  lastUsage = addUsage(lastUsage, semanticCompactResult.usage);
1262
1540
  if (!firstTurnUsage) firstTurnUsage = normalizeUsage(semanticCompactResult.usage);
@@ -1265,6 +1543,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1265
1543
  opts.onUsageDelta({
1266
1544
  sessionId,
1267
1545
  iterationIndex: iterations + 1,
1546
+ usageMetricsTurnId: loopUsageMetricsTurnId(),
1547
+ usageMetricsEpoch: loopUsageMetricsEpoch(),
1268
1548
  deltaInput: semanticCompactResult.usage.inputTokens || 0,
1269
1549
  deltaOutput: semanticCompactResult.usage.outputTokens || 0,
1270
1550
  deltaCachedRead: semanticCompactResult.usage.cachedTokens || 0,
@@ -1280,127 +1560,46 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1280
1560
  try {
1281
1561
  process.stderr.write(
1282
1562
  `[loop] semantic compact failed (sess=${sessionId || 'unknown'}): ` +
1283
- `${semanticErr?.message || semanticErr}; falling back to deterministic compact\n`,
1284
- );
1285
- } catch { /* best-effort */ }
1286
- }
1287
- }
1288
- if (!compacted) {
1289
- try {
1290
- compacted = compactMessages(compactInputMessages, compactBudgetTokens, {
1291
- reserveTokens: compactPolicy.reserveTokens,
1292
- force: true,
1293
- });
1294
- } catch (deterministicErr) {
1295
- // Deterministic (and any prior semantic) compaction
1296
- // failed because system + the entire current turn is
1297
- // mandatory and overflows the budget. For bridge
1298
- // workers, fall back to the narrow active-turn
1299
- // compactor, which shrinks older same-turn tool
1300
- // outputs / drops older same-turn groups while
1301
- // preserving system + task user + the latest
1302
- // group(s) and tool pairing. It still throws (and we
1303
- // surface overflow below) when even that floor cannot
1304
- // fit, so overflow/cancellation behavior is preserved.
1305
- if (!compactPolicy.activeTurnFallback) throw deterministicErr;
1306
- compacted = compactActiveTurn(compactInputMessages, compactBudgetTokens, {
1307
- reserveTokens: compactPolicy.reserveTokens,
1308
- force: true,
1309
- });
1310
- try {
1311
- process.stderr.write(
1312
- `[loop] active-turn fallback compaction (sess=${sessionId || 'unknown'}): ` +
1313
- `${deterministicErr?.message || deterministicErr}\n`,
1563
+ `${semanticErr?.message || semanticErr}\n`,
1314
1564
  );
1315
1565
  } catch { /* best-effort */ }
1566
+ throw semanticErr;
1316
1567
  }
1568
+ } else {
1569
+ throw new Error(`compact type ${compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE} is unavailable for auto compact`);
1317
1570
  }
1318
1571
  summaryChanged = messagesArrayChanged(compactInputMessages, compacted);
1319
- if (summaryChanged && providerRemoteCompactEnabled(provider, opts)) {
1320
- const compactInput = splitMessagesForRemoteCompact(messages);
1321
- if (compactInput) {
1322
- try {
1323
- remoteCompactResult = await provider.remoteCompactMessages(
1324
- compactInput,
1325
- model,
1326
- tools,
1327
- {
1328
- ...opts,
1329
- thinkingBudgetTokens: undefined,
1330
- xaiReasoningEffort: undefined,
1331
- reasoningEffort: undefined,
1332
- effort: 'low',
1333
- providerState,
1334
- iteration: iterations + 1,
1335
- remoteCompact: true,
1336
- onToolCall: undefined,
1337
- onStreamDelta: undefined,
1338
- },
1339
- );
1340
- if (remoteCompactResult?.providerState !== undefined) {
1341
- markRemoteCompactFallback(compacted, provider?.name);
1342
- }
1343
- if (remoteCompactResult?.usage) {
1344
- lastUsage = addUsage(lastUsage, remoteCompactResult.usage);
1345
- if (!firstTurnUsage) firstTurnUsage = normalizeUsage(remoteCompactResult.usage);
1346
- if (sessionId && opts.onUsageDelta) {
1347
- try {
1348
- opts.onUsageDelta({
1349
- sessionId,
1350
- iterationIndex: iterations + 1,
1351
- deltaInput: remoteCompactResult.usage.inputTokens || 0,
1352
- deltaOutput: remoteCompactResult.usage.outputTokens || 0,
1353
- deltaCachedRead: remoteCompactResult.usage.cachedTokens || 0,
1354
- deltaCacheWrite: remoteCompactResult.usage.cacheWriteTokens || 0,
1355
- source: 'remote_compact',
1356
- ts: Date.now(),
1357
- });
1358
- } catch { /* best-effort */ }
1359
- }
1360
- }
1361
- } catch (remoteErr) {
1362
- try {
1363
- process.stderr.write(
1364
- `[loop] remote compact failed (sess=${sessionId || 'unknown'}): ` +
1365
- `${remoteErr?.message || remoteErr}; falling back to local summary\n`,
1366
- );
1367
- } catch { /* best-effort */ }
1368
- traceBridgeCompact({
1369
- sessionId,
1370
- iteration: iterations + 1,
1371
- stage: 'remote_compact',
1372
- prune_count: pruneCount,
1373
- compact_changed: false,
1374
- input_prefix_hash: messagePrefixHash(messages),
1375
- before_count: beforeCount,
1376
- after_count: beforeCount,
1377
- before_bytes: beforeBytes,
1378
- after_bytes: beforeBytes,
1379
- context_window: compactPolicy.contextWindow,
1380
- budget_tokens: compactPolicy.boundaryTokens,
1381
- target_budget_tokens: compactBudgetTokens,
1382
- reserve_tokens: compactPolicy.reserveTokens,
1383
- message_tokens_est: messageTokensEst,
1384
- provider: sessionRef.provider,
1385
- model: sessionRef.model || model,
1386
- error: remoteErr && remoteErr.message ? remoteErr.message : String(remoteErr),
1387
- error_code: 'REMOTE_COMPACT_FAILED',
1388
- });
1389
- }
1390
- }
1391
- }
1392
1572
  } catch (compactErr) {
1393
- 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({
1394
1592
  sessionId,
1395
1593
  iteration: iterations + 1,
1396
1594
  stage: 'pre_send',
1595
+ trigger: compactTrigger,
1397
1596
  prune_count: pruneCount,
1398
1597
  compact_changed: false,
1399
1598
  input_prefix_hash: messagePrefixHash(messages),
1400
1599
  before_count: beforeCount,
1401
1600
  after_count: messages.length,
1402
- before_bytes: beforeBytes,
1403
- after_bytes: beforeBytes,
1601
+ before_bytes: getBeforeBytes(),
1602
+ after_bytes: getBeforeBytes(),
1404
1603
  context_window: compactPolicy.contextWindow,
1405
1604
  budget_tokens: compactPolicy.boundaryTokens,
1406
1605
  target_budget_tokens: compactBudgetTokens,
@@ -1408,10 +1607,31 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1408
1607
  message_tokens_est: messageTokensEst,
1409
1608
  provider: sessionRef.provider,
1410
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,
1411
1632
  error: compactErr && compactErr.message ? compactErr.message : String(compactErr),
1412
- error_code: 'BRIDGE_CONTEXT_OVERFLOW',
1413
1633
  });
1414
- throw bridgeContextOverflowError({
1634
+ throw agentContextOverflowError({
1415
1635
  stage: 'pre_send',
1416
1636
  sessionId,
1417
1637
  sessionRef,
@@ -1426,47 +1646,50 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1426
1646
  if (compactChanged) {
1427
1647
  messages.length = 0;
1428
1648
  messages.push(...compacted);
1429
- if (remoteCompactResult?.providerState !== undefined) {
1430
- providerState = remoteCompactResult.providerState;
1431
- } else {
1432
- // Compacting/pruning the transcript invalidates the
1433
- // server-side conversation anchor (xAI Responses /
1434
- // Codex WS rely on previous_response_id which points
1435
- // at a now-mutated prefix). Drop providerState so the
1436
- // next send starts a fresh chain instead of triggering
1437
- // silent cache miss or hard mismatch.
1438
- providerState = undefined;
1439
- }
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;
1440
1655
  // Compaction shrank the transcript, so prior turns no
1441
1656
  // longer pressure the window — reset the iteration counter
1442
1657
  // so a steadily-compacting long task isn't killed by the
1443
1658
  // cap, while a non-compacting tight loop still hits it.
1444
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);
1445
1663
  }
1446
1664
  const afterTokens = estimateMessagesTokensSafe(messages);
1665
+ const compactDurationMs = Date.now() - compactStartedAt;
1447
1666
  rememberCompactTelemetry(sessionRef, compactPolicy, {
1448
1667
  stage: 'pre_send',
1449
1668
  beforeTokens: messageTokensEst,
1450
1669
  afterTokens,
1451
1670
  pressureTokens,
1452
1671
  compactChanged,
1453
- remoteCompact: remoteCompactResult?.providerState !== undefined,
1454
1672
  semanticCompact: semanticCompactResult?.semantic === true,
1455
1673
  semanticError: semanticCompactError?.message || null,
1674
+ recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
1675
+ recallFastTrackError: recallFastTrackError?.message || null,
1676
+ compactError: null,
1456
1677
  pruneCount,
1678
+ durationMs: compactDurationMs,
1457
1679
  });
1458
1680
  let afterBytes = null;
1459
1681
  try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
1460
- traceBridgeCompact({
1682
+ traceAgentCompact({
1461
1683
  sessionId,
1462
1684
  iteration: iterations + 1,
1463
1685
  stage: 'pre_send',
1686
+ trigger: compactTrigger,
1464
1687
  prune_count: pruneCount,
1465
1688
  compact_changed: compactChanged || summaryChanged,
1466
1689
  input_prefix_hash: messagePrefixHash(messages),
1467
1690
  before_count: beforeCount,
1468
1691
  after_count: messages.length,
1469
- before_bytes: beforeBytes,
1692
+ before_bytes: getBeforeBytes(),
1470
1693
  after_bytes: afterBytes,
1471
1694
  context_window: compactPolicy.contextWindow,
1472
1695
  budget_tokens: compactPolicy.boundaryTokens,
@@ -1476,14 +1699,29 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1476
1699
  provider: sessionRef.provider,
1477
1700
  model: sessionRef.model || model,
1478
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
+ });
1479
1723
  }
1480
1724
  }
1481
- // A pre-send compaction pass can take long enough for the user (or a
1482
- // background completion notification) to enqueue more input. Drain once
1483
- // immediately before provider.send so that input is included in the very
1484
- // next request instead of sitting in the queue until after the model has
1485
- // already answered the pre-compaction prompt.
1486
- drainSteeringIntoMessages('pre-send');
1487
1725
  const nextIteration = iterations + 1;
1488
1726
  opts.iteration = nextIteration;
1489
1727
  opts.providerState = providerState;
@@ -1518,6 +1756,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1518
1756
  let _mutationEpoch = 0;
1519
1757
  const startEagerTool = (call) => {
1520
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;
1521
1763
  const _sig = _intraTurnSig(call.name, call.arguments);
1522
1764
  if (_eagerInFlightSigs.has(_sig)) return null;
1523
1765
  // Repeat-failure guard also gates eager dispatch (reviewer-flagged):
@@ -1538,20 +1780,55 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1538
1780
  _eagerInFlightSigs.set(_sig, call.id);
1539
1781
  entry.promise = (async () => {
1540
1782
  try {
1541
- const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
1542
- if (permBlocked !== null) return { ok: true, value: permBlocked };
1543
- 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 }) };
1544
1784
  } catch (error) {
1545
1785
  return { ok: false, error };
1546
1786
  }
1547
1787
  })()
1548
- .finally(() => {
1788
+ .then((settled) => {
1549
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 */ }
1550
1826
  // Intentionally do NOT delete _sig here — see the block
1551
1827
  // comment above. The sig must outlive promise settlement
1552
1828
  // so a later same-turn streaming duplicate stays blocked
1553
1829
  // at the _eagerInFlightSigs.has(_sig) guard until the turn
1554
1830
  // boundary recreates the Map.
1831
+ return settled;
1555
1832
  });
1556
1833
  pending.set(call.id, entry);
1557
1834
  return entry;
@@ -1573,11 +1850,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1573
1850
  if (_streamEagerBlocked) return;
1574
1851
  startEagerTool(call);
1575
1852
  };
1576
- // Repair any dangling assistant tool_use left over from a prior
1577
- // abort/error path before the provider sees the transcript. No-op
1578
- // on the healthy iteration cycle (every assistant tool_use is
1579
- // followed by tool results in the same loop body below).
1580
- _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);
1581
1856
  // Strip soft-warn markers from prior tool results before the next
1582
1857
  // send. Marker bytes (Tool-budget(xN), Same-file reads(xN), etc.)
1583
1858
  // mutate every turn with dynamic counters, so leaving them in the
@@ -1595,145 +1870,59 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1595
1870
  try {
1596
1871
  response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
1597
1872
  } catch (sendErr) {
1598
- // Context-window-exceeded is a deterministic refusal: the request is
1599
- // simply too large. Retry ONCE with a stricter budget using the
1600
- // same summary-based compaction path. It never falls back to
1601
- // destructive trim/drop: if system + current turn + compact marker
1602
- // cannot fit, surface overflow. Unrelated errors (network, stall,
1603
- // auth, etc.) re-throw untouched they are handled by the
1604
- // 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.
1605
1880
  if (
1606
1881
  !isContextOverflowError(sendErr)
1607
1882
  || !(sessionRef && typeof sessionRef.contextWindow === 'number')
1608
1883
  ) {
1609
1884
  throw sendErr;
1610
1885
  }
1611
- const overflowPolicy = resolveWorkerCompactPolicy(sessionRef, sendTools.length ? sendTools : tools);
1612
- const overflowBase = overflowPolicy?.boundaryTokens || sessionRef.contextWindow;
1613
- const overflowStrictBudget = Math.floor(overflowBase * OVERFLOW_RETRY_COMPACT_PERCENT);
1614
- const overflowBudget = Math.max(1, Math.min(
1615
- overflowBase,
1616
- overflowPolicy?.triggerTokens || overflowBase,
1617
- overflowStrictBudget || overflowBase,
1618
- ));
1619
- const overflowReserve = overflowPolicy?.reserveTokens || estimateRequestReserveTokens(sendTools.length ? sendTools : tools);
1620
- const beforeCount = messages.length;
1621
- let beforeBytes = null;
1622
- try { beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { beforeBytes = null; }
1623
- const messageTokensEst = estimateMessagesTokensSafe(messages);
1624
- let recompacted;
1625
- try {
1626
- recompacted = compactMessages(messages, overflowBudget, { reserveTokens: overflowReserve, force: true });
1627
- } catch (compactErr) {
1628
- // Same narrow active-turn fallback as the pre-send path: when
1629
- // system + the whole current turn overflow even the stricter
1630
- // overflow-retry budget, shrink older same-turn tool outputs /
1631
- // drop older same-turn groups before surfacing overflow. Throws
1632
- // (caught below) when even the floor cannot fit, preserving the
1633
- // overflow error behavior.
1634
- if (overflowPolicy?.activeTurnFallback) {
1635
- try {
1636
- recompacted = compactActiveTurn(messages, overflowBudget, { reserveTokens: overflowReserve, force: true });
1637
- try {
1638
- process.stderr.write(
1639
- `[loop] active-turn fallback compaction on overflow retry ` +
1640
- `(sess=${sessionId || 'unknown'} iter=${nextIteration}): ` +
1641
- `${compactErr?.message || compactErr}\n`,
1642
- );
1643
- } catch { /* best-effort */ }
1644
- } catch { recompacted = undefined; }
1645
- }
1646
- if (!recompacted) {
1647
- traceBridgeCompact({
1648
- sessionId,
1649
- iteration: nextIteration,
1650
- stage: 'overflow_retry',
1651
- prune_count: 0,
1652
- compact_changed: false,
1653
- input_prefix_hash: messagePrefixHash(messages),
1654
- before_count: beforeCount,
1655
- after_count: messages.length,
1656
- before_bytes: beforeBytes,
1657
- after_bytes: beforeBytes,
1658
- context_window: overflowPolicy?.contextWindow || sessionRef.contextWindow,
1659
- budget_tokens: overflowBudget,
1660
- reserve_tokens: overflowReserve,
1661
- message_tokens_est: messageTokensEst,
1662
- provider: sessionRef.provider,
1663
- model: sessionRef.model || model,
1664
- error: compactErr && compactErr.message ? compactErr.message : String(compactErr),
1665
- error_code: 'BRIDGE_CONTEXT_OVERFLOW',
1666
- });
1667
- throw bridgeContextOverflowError({
1668
- stage: 'overflow_retry',
1669
- sessionId,
1670
- sessionRef,
1671
- model,
1672
- budgetTokens: overflowBudget,
1673
- reserveTokens: overflowReserve,
1674
- messageTokensEst,
1675
- }, compactErr);
1676
- }
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;
1677
1902
  }
1678
- const compactChanged = messagesArrayChanged(messages, recompacted);
1679
- const pruneCount = Math.max(beforeCount - recompacted.length, 0);
1680
- messages.length = 0;
1681
- messages.push(...recompacted);
1682
- let afterBytes = null;
1683
- try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
1684
- traceBridgeCompact({
1685
- sessionId,
1686
- iteration: nextIteration,
1687
- stage: 'overflow_retry',
1688
- prune_count: pruneCount,
1689
- compact_changed: compactChanged,
1690
- input_prefix_hash: messagePrefixHash(messages),
1691
- before_count: beforeCount,
1692
- after_count: messages.length,
1693
- before_bytes: beforeBytes,
1694
- after_bytes: afterBytes,
1695
- context_window: overflowPolicy?.contextWindow || sessionRef.contextWindow,
1696
- budget_tokens: overflowBudget,
1697
- reserve_tokens: overflowReserve,
1698
- message_tokens_est: messageTokensEst,
1699
- provider: sessionRef.provider,
1700
- model: sessionRef.model || model,
1701
- });
1702
- rememberCompactTelemetry(sessionRef, overflowPolicy, {
1703
- stage: 'overflow_retry',
1704
- beforeTokens: messageTokensEst,
1705
- afterTokens: estimateMessagesTokensSafe(messages),
1706
- compactChanged,
1707
- pruneCount,
1708
- });
1709
- // The transcript prefix changed; the server-side conversation anchor
1710
- // (previous_response_id / WS continuation) is now invalid. Drop
1711
- // providerState so the retry starts a fresh chain instead of
1712
- // tripping a silent cache miss or hard mismatch.
1713
- providerState = undefined;
1714
- opts.providerState = undefined;
1715
- // Drop eager-dispatch state before the retry send. A tool_use
1716
- // streamed by the failed first send could otherwise orphan its
1717
- // eager result or be double-dispatched; force the retry's tools
1718
- // through the serial post-send path with a clean matching slate.
1719
- opts.onToolCall = undefined;
1720
- pending.clear();
1721
- _eagerInFlightSigs.clear();
1722
1903
  try {
1723
1904
  process.stderr.write(
1724
1905
  `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
1725
- `retrying once at budget=${overflowBudget} reserve=${overflowReserve} ` +
1726
- `messages=${messages.length}\n`,
1906
+ `surfacing overflow after reactive compact retry messages=${messages.length}\n`,
1727
1907
  );
1728
1908
  } catch { /* best-effort */ }
1729
- 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);
1730
1918
  }
1731
1919
  opts.onToolCall = undefined;
1920
+ contextOverflowRetryUsed = false;
1732
1921
  // Capture opaque state for the next turn (may be undefined — that's
1733
1922
  // the stateless contract for providers that don't use continuation).
1734
1923
  providerState = response?.providerState ?? undefined;
1735
1924
  iterations = nextIteration;
1736
- traceBridgeLoop({
1925
+ traceAgentLoop({
1737
1926
  sessionId,
1738
1927
  iteration: iterations,
1739
1928
  sendMs: Date.now() - sendStartedAt,
@@ -1760,12 +1949,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1760
1949
  // signal) — bail before processing any of its output.
1761
1950
  throwIfAborted();
1762
1951
  // Incremental metric persistence (fix A): push per-iteration token delta
1763
- // immediately so watchdog / bridge type=list sees live totals mid-turn.
1952
+ // immediately so watchdog / agent type=list sees live totals mid-turn.
1764
1953
  if (sessionId && opts.onUsageDelta && response.usage) {
1765
1954
  try {
1766
1955
  opts.onUsageDelta({
1767
1956
  sessionId,
1768
1957
  iterationIndex: iterations,
1958
+ usageMetricsTurnId: loopUsageMetricsTurnId(),
1959
+ source: 'provider_send',
1960
+ usageMetricsEpoch: loopUsageMetricsEpoch(),
1769
1961
  deltaInput: response.usage.inputTokens || 0,
1770
1962
  deltaOutput: response.usage.outputTokens || 0,
1771
1963
  deltaPrompt: response.usage.promptTokens || 0,
@@ -1778,8 +1970,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1778
1970
  });
1779
1971
  } catch { /* best-effort — never break the loop */ }
1780
1972
  }
1781
- // No tool calls. For PUBLIC bridge agents, the bridge contract
1782
- // (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
1783
1975
  // `<final-answer>` wrapped reply.
1784
1976
  // A text-only turn without those tags violates the contract (e.g.
1785
1977
  // Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
@@ -1805,6 +1997,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1805
1997
  const isHidden = HIDDEN_ROLE_NAMES.has(sessionRole);
1806
1998
  const stopReason = response.stopReason ?? response.stop_reason ?? null;
1807
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
+ }
1808
2011
  if (!hasContent && !isHidden) {
1809
2012
  if (contractNudges >= 1) break;
1810
2013
  contractNudges += 1;
@@ -1821,6 +2024,16 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1821
2024
  }
1822
2025
  const calls = response.toolCalls;
1823
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
+ }
1824
2037
  // Per-turn batch shape — one row per assistant turn so trace
1825
2038
  // consumers can derive multi-tool adoption ratio without scanning
1826
2039
  // every assistant message body.
@@ -1830,11 +2043,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1830
2043
  // OpenAI Responses API replay payload (encrypted_content blobs);
1831
2044
  // providers that ignore it just see an extra field and drop it,
1832
2045
  // openai-oauth.convertMessagesToResponsesInput emits matching
1833
- // 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
1834
2047
  // server-side cache prefix stable.
1835
2048
  const _assistantTurnMsg = {
1836
2049
  role: 'assistant',
1837
- 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 || ''),
1838
2054
  toolCalls: compactToolCallsForHistory(calls),
1839
2055
  ...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
1840
2056
  ? { reasoningItems: response.reasoningItems }
@@ -1848,9 +2064,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1848
2064
  //
1849
2065
  // Intra-turn duplicate suppression: when an LLM emits two tool_use
1850
2066
  // blocks with identical (name, args) inside the SAME assistant turn,
1851
- // re-executing wastes tokens. Restricted to tools with
1852
- // `readOnlyHint:true` (= isEagerDispatchable) — bash/write/edit/
1853
- // apply_patch may be intentional repeats with distinct side effects.
2067
+ // re-executing wastes tokens. Restricted to tools with
2068
+ // `readOnlyHint:true` (= isEagerDispatchable) — bash/apply_patch
2069
+ // may be intentional repeats with distinct side effects.
1854
2070
  // Pre-pass identifies duplicates BEFORE startEagerRun so eager
1855
2071
  // dispatch also skips them, not just the for-body.
1856
2072
  const _duplicateCallIds = new Set();
@@ -1915,7 +2131,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1915
2131
  let toolStartedAt;
1916
2132
  let toolEndedAt;
1917
2133
  const toolKind = getToolKind(call.name);
1918
- // Cross-turn read dedup. Mirrors Anthropic Claude Code's
2134
+ // Cross-turn read dedup. Mirrors a reference agent's
1919
2135
  // fileReadCache.ts: if the path's stat tuple (mtime/size/ino/dev)
1920
2136
  // is unchanged since a prior read in THIS session, return the cached
1921
2137
  // body instead of executing. Both scalar and array/object-array path
@@ -1930,19 +2146,35 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1930
2146
  let _scopedCacheHit = null;
1931
2147
  let _executeOk = false;
1932
2148
  let _resultKind = 'normal';
1933
- 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)) {
1934
2160
  _readCacheHit = tryReadCached({ sessionId, args: call.arguments, cwd });
1935
2161
  } else if (sessionId && _isScopedCacheableTool(call.name)) {
1936
2162
  _scopedCacheHit = tryScopedToolCached({ sessionId, toolName: _stripMcpPrefix(call.name), args: call.arguments, cwd });
1937
2163
  }
1938
2164
  try {
1939
- 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) {
1940
2172
  toolStartedAt = Date.now();
1941
2173
  toolEndedAt = toolStartedAt;
1942
2174
  const _body = _readCacheHit.content;
1943
2175
  // Return the cached body byte-for-byte instead of a
1944
2176
  // human-readable cache marker. The marker made public
1945
- // bridge agents treat a successful cached read as a
2177
+ // agents treat a successful cached read as a
1946
2178
  // meta instruction and repeat the same read loop.
1947
2179
  result = _body;
1948
2180
  _resultKind = 'cache-hit';
@@ -1981,39 +2213,32 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1981
2213
  }
1982
2214
  } else {
1983
2215
  toolStartedAt = Date.now();
1984
- // Runtime permission guard. Schema profiles may hide
2216
+ // Runtime pre-dispatch deny. Schema profiles may hide
1985
2217
  // tools for routing efficiency, but this remains the
1986
- // safety boundary for any tool_use that still reaches
1987
- // the loop. _preDispatchDeny is the SHARED helper used
1988
- // by both the eager dispatch path (startEagerTool) and
1989
- // this serial path — keeps the bridge-owned control-
1990
- // plane reject, role guards, wrapper guards, and
1991
- // 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.
1992
2224
  const _denyMsg = _preDispatchDeny(call, toolKind, sessionRef);
1993
2225
  if (_denyMsg !== null) {
1994
2226
  result = _denyMsg;
1995
2227
  toolEndedAt = Date.now();
1996
2228
  _resultKind = 'error';
1997
2229
  } else {
1998
- const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
1999
- if (permBlocked !== null) {
2000
- result = permBlocked;
2001
- 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') {
2002
2236
  _resultKind = 'error';
2237
+ _executeOk = false;
2003
2238
  } else {
2004
- result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal });
2005
- toolEndedAt = Date.now();
2006
- // Boundary: tool-return string convention → structural kind.
2007
- // The only prefix check in this codebase; downstream layers
2008
- // operate on _resultKind.
2009
- if (classifyResultKind(result) === 'error') {
2010
- _resultKind = 'error';
2011
- _executeOk = false;
2012
- } else {
2013
- _executeOk = true;
2014
- }
2015
- // _resultKind stays 'normal' when tool returned a non-error string.
2239
+ _executeOk = true;
2016
2240
  }
2241
+ // _resultKind stays 'normal' when tool returned a non-error string.
2017
2242
  }
2018
2243
  }
2019
2244
  } // close: else branch of _readCacheHit check
@@ -2038,11 +2263,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2038
2263
  }
2039
2264
  }
2040
2265
  // A failed executed call keeps its FULL argument body in history so the
2041
- // model can retry against the original (a large apply_patch `patch` /
2042
- // edit `old_string` would otherwise be hidden behind a
2266
+ // model can retry against the original (a large apply_patch `patch`
2267
+ // would otherwise be hidden behind a
2043
2268
  // `[mixdog compacted …]` placeholder). Restored IMMEDIATELY — not at end
2044
2269
  // of loop — so an abort or post-processing throw after this point cannot
2045
- // leave a failed edit compacted. Cache-safe: _assistantTurnMsg is not
2270
+ // leave a failed patch compacted. Cache-safe: _assistantTurnMsg is not
2046
2271
  // transmitted until the next provider.send. Early-continue paths (dedup /
2047
2272
  // repeat-failure-guard) never reach here and stay compacted.
2048
2273
  if ((!_executeOk || _resultKind === 'error') && call?.id) {
@@ -2059,10 +2284,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2059
2284
  if (sessionId && _executeOk && _resultKind === 'normal') {
2060
2285
  const _toolBare = _stripMcpPrefix(call.name);
2061
2286
  if (_readCacheHit === null && _isReadTool(call.name)) {
2062
- // Post-edit advisory: handle BOTH scalar and array forms
2287
+ // Post-patch advisory: handle BOTH scalar and array forms
2063
2288
  // of args.path. The array form (path:[a,b,c] or
2064
2289
  // path:[{path:a},{path:b}]) was a coverage gap in R1 —
2065
- // an LLM that edits X then reads [X,Y] should still see
2290
+ // an LLM that patches X then reads [X,Y] should still see
2066
2291
  // the advisory for X.
2067
2292
  const _argsPath = call.arguments?.path;
2068
2293
  const _pathList = [];
@@ -2116,59 +2341,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2116
2341
  } else {
2117
2342
  clearScopedToolsForSession(sessionId);
2118
2343
  }
2119
- } else if (_isScalarWriteEditTool(call.name)) {
2120
- // Scalar `args.path` only: precise invalidate + advisory mark.
2121
- // Array-form (`edits[]`/`writes[]`): the tool may have partial-
2122
- // failed across paths and the result string aggregates;
2123
- // full-clear instead of falsely marking every path.
2124
- const _scalarPath = call.arguments?.path || call.arguments?.file_path;
2125
- const _hasArrayForm = Array.isArray(call.arguments?.edits)
2126
- || Array.isArray(call.arguments?.writes);
2127
- if (_hasArrayForm) {
2128
- clearReadDedupSession(sessionId);
2129
- clearScopedToolsForSession(sessionId);
2130
- // R20: array-form — walk each entry, extract its path,
2131
- // and invalidate the prefetch cache + mark post-edit for
2132
- // every distinct touched path. Falls back to the top-
2133
- // level `path` (or `file_path`) when an entry omits its
2134
- // own path. This covers both edit edits[] and write
2135
- // writes[] forms; entries without a resolvable path are
2136
- // silently skipped (their stat-validation safety net at
2137
- // next lookup still applies).
2138
- const _topPath = call.arguments?.path || call.arguments?.file_path;
2139
- const _entries = call.arguments?.edits || call.arguments?.writes || [];
2140
- const _seenPaths = new Set();
2141
- for (const _e of _entries) {
2142
- const _ep = _e?.path || _e?.file_path || _topPath;
2143
- if (typeof _ep === 'string' && _ep && !_seenPaths.has(_ep)) {
2144
- _seenPaths.add(_ep);
2145
- invalidatePathForSession(sessionId, _ep, cwd);
2146
- markPostEdit({ sessionId, path: _ep, cwd, toolName: _toolBare });
2147
- invalidatePrefetchCache(_ep, cwd);
2148
- }
2149
- }
2150
- if (_seenPaths.size > 0) {
2151
- clearScopedToolsForSessionPaths(sessionId, [..._seenPaths], cwd);
2152
- }
2153
- } else if (typeof _scalarPath === 'string') {
2154
- invalidatePathForSession(sessionId, _scalarPath, cwd);
2155
- markPostEdit({ sessionId, path: _scalarPath, cwd, toolName: _toolBare });
2156
- // R20: cross-dispatch prefetch cache invalidation.
2157
- invalidatePrefetchCache(_scalarPath, cwd);
2158
- // Targeted scoped-cache invalidation for the single touched path (D).
2159
- clearScopedToolsForSessionPaths(sessionId, [_scalarPath], cwd);
2160
- } else {
2161
- // No path extractable — full wipe fallback.
2162
- clearScopedToolsForSession(sessionId);
2163
- }
2164
2344
  }
2165
2345
  } // end _executeOk+_resultKind gate (scoped tool cache set)
2166
- // E: mutation tools (apply_patch / write / edit) must invalidate caches
2346
+ // E: mutation tools (apply_patch) must invalidate caches
2167
2347
  // even on returned-error/partial-fail — the file state is unknown after
2168
2348
  // an error exit, and some tools report failure as an Error: result string
2169
2349
  // rather than throwing.
2170
2350
  // This block runs unconditionally (not gated on _executeOk or _resultKind).
2171
- if (sessionId && (!_executeOk || _resultKind === 'error') && (_stripMcpPrefix(call.name) === 'apply_patch' || _isScalarWriteEditTool(call.name))) {
2351
+ if (sessionId && (!_executeOk || _resultKind === 'error') && _stripMcpPrefix(call.name) === 'apply_patch') {
2172
2352
  clearReadDedupSession(sessionId);
2173
2353
  }
2174
2354
  if (_isMutationTool(call.name)) {
@@ -2201,6 +2381,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2201
2381
  // failure push a synthetic Error: tool result for this call.id
2202
2382
  // and skip the cache writes for it.
2203
2383
  let _postProcessOk = true;
2384
+ let _nativeToolSearch = null;
2204
2385
  try {
2205
2386
  // Offload thresholds are keyed by BARE tool name
2206
2387
  // (INLINE_THRESHOLD_BY_TOOL: grep=20k, bash=30k, read=Infinity, ...),
@@ -2208,9 +2389,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2208
2389
  // Otherwise an mcp__..__grep name misses its 20k grep cap and
2209
2390
  // silently falls back to the 50k default — per-tool limits ignored.
2210
2391
  const _toolBare = _stripMcpPrefix(call.name);
2392
+ _nativeToolSearch = parseNativeToolSearchPayload(call.name, result);
2393
+ if (_nativeToolSearch?.summary) result = _nativeToolSearch.summary;
2211
2394
  result = await maybeOffloadToolResult(sessionId, call.id, _toolBare, result);
2212
2395
  result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
2213
- traceBridgeTool({
2396
+ traceAgentTool({
2214
2397
  sessionId,
2215
2398
  iteration: iterations,
2216
2399
  toolName: call.name,
@@ -2254,6 +2437,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2254
2437
  content: result,
2255
2438
  toolCallId: call.id,
2256
2439
  toolKind: _resultKind,
2440
+ ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
2257
2441
  });
2258
2442
  } catch (postErr) {
2259
2443
  _postProcessOk = false;
@@ -2262,7 +2446,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2262
2446
  // too for a clean retry (mirrors the failed-exec path above).
2263
2447
  if (call?.id) restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
2264
2448
  const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
2265
- traceBridgeToolFailure({
2449
+ traceAgentToolFailure({
2266
2450
  sessionId,
2267
2451
  iteration: iterations,
2268
2452
  toolName: call.name,
@@ -2293,18 +2477,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2293
2477
  // discard the rest of the batch and skip the next provider.send.
2294
2478
  throwIfAborted();
2295
2479
  }
2296
- // ── Mid-turn steering (Claude Code / pi agent-loop parity) ──
2297
- // Before re-sending with the tool results, drain any user / `bridge
2298
- // type=send` messages that arrived WHILE this tool batch was in
2299
- // flight and inject them as user turns. This is the difference
2300
- // between "steer" (interrupt now) and "followUp" (wait for the turn
2301
- // to finish): previously every injected message was held until the
2302
- // entire askSession turn completed (manager.mjs _pendingTail drain),
2303
- // so a user typing mid-task only got picked up after the agent had
2304
- // already run to completion. Mirrors pi/agent-loop.ts:253 +
2305
- // 182-190 — steering messages land right before the next assistant
2306
- // response so the model sees them on its very next iteration.
2307
- 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.
2308
2485
  // About to re-send with tool results — transition back to connecting for the next turn.
2309
2486
  if (sessionId) updateSessionStage(sessionId, 'connecting');
2310
2487
  }