mixdog 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (404) hide show
  1. package/.claude-plugin/marketplace.json +31 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/.gitattributes +34 -0
  4. package/.mcp.json +14 -0
  5. package/ARCHITECTURE.md +77 -0
  6. package/CHANGELOG.md +7 -0
  7. package/CONTRIBUTING.md +45 -0
  8. package/DATA-FLOW.md +79 -0
  9. package/LICENSE +21 -0
  10. package/README.md +389 -0
  11. package/SECURITY.md +138 -0
  12. package/UNINSTALL.md +112 -0
  13. package/agents/maintenance.md +5 -0
  14. package/agents/memory-classification.md +30 -0
  15. package/agents/scheduler-task.md +18 -0
  16. package/agents/webhook-handler.md +27 -0
  17. package/agents/worker.md +24 -0
  18. package/bin/bridge +133 -0
  19. package/bin/statusline-launcher.mjs +78 -0
  20. package/bin/statusline-lib.mjs +550 -0
  21. package/bin/statusline.mjs +607 -0
  22. package/bun.lock +802 -0
  23. package/commands/config.md +16 -0
  24. package/commands/doctor.md +13 -0
  25. package/commands/setup.md +17 -0
  26. package/defaults/cycle3-review-prompt.md +90 -0
  27. package/defaults/hidden-roles.json +65 -0
  28. package/defaults/memory-chunk-prompt.md +63 -0
  29. package/defaults/memory-promote-prompt.md +135 -0
  30. package/defaults/mixdog-config.template.json +27 -0
  31. package/defaults/user-workflow.json +8 -0
  32. package/defaults/user-workflow.md +12 -0
  33. package/hooks/hooks.json +73 -0
  34. package/hooks/lib/active-instance.cjs +77 -0
  35. package/hooks/lib/permission-evaluator.cjs +411 -0
  36. package/hooks/lib/permission-route.cjs +63 -0
  37. package/hooks/lib/permission-rules.cjs +170 -0
  38. package/hooks/lib/settings-loader.cjs +116 -0
  39. package/hooks/post-tool-use.cjs +84 -0
  40. package/hooks/pre-mcp-sandbox.cjs +158 -0
  41. package/hooks/pre-tool-subagent.cjs +253 -0
  42. package/hooks/session-start.cjs +1372 -0
  43. package/hooks/turn-timer.cjs +82 -0
  44. package/lib/claude-md-writer.cjs +386 -0
  45. package/lib/config-cjs.cjs +61 -0
  46. package/lib/hook-pipe-path.cjs +10 -0
  47. package/lib/keychain-cjs.cjs +263 -0
  48. package/lib/plugin-paths.cjs +61 -0
  49. package/lib/rules-builder.cjs +241 -0
  50. package/lib/text-utils.cjs +61 -0
  51. package/native/README.md +117 -0
  52. package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
  53. package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
  54. package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
  55. package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
  56. package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
  57. package/package.json +107 -0
  58. package/prompts/code-review.txt +16 -0
  59. package/prompts/security-audit.txt +17 -0
  60. package/rules/bridge/00-common.md +39 -0
  61. package/rules/bridge/20-skip-protocol.md +18 -0
  62. package/rules/bridge/30-explorer.md +33 -0
  63. package/rules/bridge/40-cycle1-agent.md +52 -0
  64. package/rules/bridge/41-cycle2-agent.md +62 -0
  65. package/rules/bridge/42-cycle3-agent.md +44 -0
  66. package/rules/lead/00-tool-lead.md +61 -0
  67. package/rules/lead/01-general.md +23 -0
  68. package/rules/lead/02-channels.md +49 -0
  69. package/rules/lead/03-team.md +27 -0
  70. package/rules/lead/04-workflow.md +20 -0
  71. package/rules/shared/00-language.md +14 -0
  72. package/rules/shared/01-tool.md +138 -0
  73. package/scripts/bootstrap.mjs +184 -0
  74. package/scripts/bridge-unify-smoke.mjs +308 -0
  75. package/scripts/build-runtime-linux.sh +348 -0
  76. package/scripts/build-runtime-macos.sh +217 -0
  77. package/scripts/build-runtime-windows.ps1 +242 -0
  78. package/scripts/builtin-utils-smoke.mjs +392 -0
  79. package/scripts/check-json.mjs +45 -0
  80. package/scripts/check-syntax-changed.mjs +102 -0
  81. package/scripts/check-syntax.mjs +58 -0
  82. package/scripts/code-graph-batch.test.mjs +33 -0
  83. package/scripts/config-preserve-smoke.mjs +180 -0
  84. package/scripts/doctor.mjs +484 -0
  85. package/scripts/edit-normalize-fuzz.mjs +130 -0
  86. package/scripts/edit-normalize-smoke.mjs +401 -0
  87. package/scripts/edit-operation-smoke.mjs +369 -0
  88. package/scripts/edit2-smoke.mjs +63 -0
  89. package/scripts/fuzzy-e2e.mjs +28 -0
  90. package/scripts/fuzzy-smoke.mjs +26 -0
  91. package/scripts/generate-runtime-manifest.mjs +166 -0
  92. package/scripts/guard-smoke.mjs +66 -0
  93. package/scripts/hidden-role-schema-smoke.mjs +162 -0
  94. package/scripts/hook-routing-smoke.mjs +29 -0
  95. package/scripts/inject-input.ps1 +204 -0
  96. package/scripts/io-complex-smoke.mjs +667 -0
  97. package/scripts/io-explore-bench.mjs +424 -0
  98. package/scripts/io-guardrails-smoke.mjs +205 -0
  99. package/scripts/io-mini-bench-baseline.json +11 -0
  100. package/scripts/io-mini-bench.mjs +216 -0
  101. package/scripts/io-route-harness.mjs +933 -0
  102. package/scripts/io-telemetry-report.mjs +691 -0
  103. package/scripts/mutation-bench.mjs +564 -0
  104. package/scripts/mutation-io-smoke.mjs +1081 -0
  105. package/scripts/native-patch-bridge-smoke.mjs +288 -0
  106. package/scripts/native-patch-smoke.mjs +304 -0
  107. package/scripts/patch-interior-context-smoke.mjs +49 -0
  108. package/scripts/patch-newline-utf8-smoke.mjs +157 -0
  109. package/scripts/perf-hook-smoke.mjs +71 -0
  110. package/scripts/permission-eval-smoke.mjs +426 -0
  111. package/scripts/prep-patch.mjs +53 -0
  112. package/scripts/prep-shim.mjs +96 -0
  113. package/scripts/provider-cache-smoke.mjs +687 -0
  114. package/scripts/report-runtime-health.mjs +132 -0
  115. package/scripts/run-mcp.mjs +1547 -0
  116. package/scripts/salvage-v4a-shatter.test.mjs +58 -0
  117. package/scripts/scoped-cache-io-smoke.mjs +103 -0
  118. package/scripts/shell-policy-round3-smoke.mjs +46 -0
  119. package/scripts/smoke-runtime-negative.ps1 +100 -0
  120. package/scripts/smoke-runtime-negative.sh +95 -0
  121. package/scripts/stall-policy-smoke.mjs +50 -0
  122. package/scripts/start-memory-worker.mjs +23 -0
  123. package/scripts/statusline-launcher-smoke.mjs +82 -0
  124. package/scripts/stress-atomic-write.mjs +1028 -0
  125. package/scripts/test-config-rmw-restore.mjs +122 -0
  126. package/scripts/test-fault-inject.mjs +164 -0
  127. package/scripts/test-large-file.mjs +174 -0
  128. package/scripts/tool-edge-smoke.mjs +209 -0
  129. package/scripts/uninstall.mjs +201 -0
  130. package/scripts/webhook-selfheal-smoke.mjs +29 -0
  131. package/scripts/write-overwrite-guard-smoke.mjs +56 -0
  132. package/server-main.mjs +3055 -0
  133. package/server.mjs +468 -0
  134. package/setup/config-merge.mjs +254 -0
  135. package/setup/install.mjs +120 -0
  136. package/setup/launch-core.mjs +507 -0
  137. package/setup/launch.mjs +101 -0
  138. package/setup/setup-server.mjs +3206 -0
  139. package/setup/setup.html +3693 -0
  140. package/skills/retro-skill-proposer/SKILL.md +92 -0
  141. package/skills/schedule-add/SKILL.md +77 -0
  142. package/skills/setup/SKILL.md +346 -0
  143. package/skills/webhook-add/SKILL.md +81 -0
  144. package/src/agent/bridge-stall-watchdog.mjs +337 -0
  145. package/src/agent/index.mjs +2138 -0
  146. package/src/agent/orchestrator/activity-bus.mjs +38 -0
  147. package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
  148. package/src/agent/orchestrator/bridge-retry.mjs +220 -0
  149. package/src/agent/orchestrator/bridge-trace.mjs +583 -0
  150. package/src/agent/orchestrator/cache-mtime.mjs +58 -0
  151. package/src/agent/orchestrator/config.mjs +358 -0
  152. package/src/agent/orchestrator/context/collect.mjs +651 -0
  153. package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
  154. package/src/agent/orchestrator/drain-registry.mjs +50 -0
  155. package/src/agent/orchestrator/explore-validator.mjs +8 -0
  156. package/src/agent/orchestrator/internal-roles.mjs +118 -0
  157. package/src/agent/orchestrator/internal-tools.mjs +88 -0
  158. package/src/agent/orchestrator/jobs.mjs +116 -0
  159. package/src/agent/orchestrator/mcp/client.mjs +364 -0
  160. package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
  161. package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
  162. package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
  163. package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
  164. package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
  165. package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
  166. package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
  167. package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
  168. package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
  169. package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
  170. package/src/agent/orchestrator/providers/registry.mjs +192 -0
  171. package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
  172. package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
  173. package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
  174. package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
  175. package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
  176. package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
  177. package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
  178. package/src/agent/orchestrator/session/cache/util.mjs +49 -0
  179. package/src/agent/orchestrator/session/loop.mjs +1478 -0
  180. package/src/agent/orchestrator/session/manager.mjs +1975 -0
  181. package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
  182. package/src/agent/orchestrator/session/result-classification.mjs +65 -0
  183. package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
  184. package/src/agent/orchestrator/session/store.mjs +624 -0
  185. package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
  186. package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
  187. package/src/agent/orchestrator/session/trim.mjs +491 -0
  188. package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
  189. package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
  190. package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
  191. package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
  192. package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
  193. package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
  194. package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
  195. package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
  196. package/src/agent/orchestrator/stall-policy.mjs +195 -0
  197. package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
  198. package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
  199. package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
  200. package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
  201. package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
  202. package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
  203. package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
  204. package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
  205. package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
  206. package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
  207. package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
  208. package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
  209. package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
  210. package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
  211. package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
  212. package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
  213. package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
  214. package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
  215. package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
  216. package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
  217. package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
  218. package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
  219. package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
  220. package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
  221. package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
  222. package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
  223. package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
  224. package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
  225. package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
  226. package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
  227. package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
  228. package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
  229. package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
  230. package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
  231. package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
  232. package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
  233. package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
  234. package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
  235. package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
  236. package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
  237. package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
  238. package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
  239. package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
  240. package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
  241. package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
  242. package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
  243. package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
  244. package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
  245. package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
  246. package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
  247. package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
  248. package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
  249. package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
  250. package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
  251. package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
  252. package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
  253. package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
  254. package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
  255. package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
  256. package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
  257. package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
  258. package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
  259. package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
  260. package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
  261. package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
  262. package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
  263. package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
  264. package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
  265. package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
  266. package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
  267. package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
  268. package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
  269. package/src/agent/orchestrator/tools/builtin.mjs +500 -0
  270. package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
  271. package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
  272. package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
  273. package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
  274. package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
  275. package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
  276. package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
  277. package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
  278. package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
  279. package/src/agent/orchestrator/tools/host-input.mjs +204 -0
  280. package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
  281. package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
  282. package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
  283. package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
  284. package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
  285. package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
  286. package/src/agent/orchestrator/tools/patch.mjs +2754 -0
  287. package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
  288. package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
  289. package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
  290. package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
  291. package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
  292. package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
  293. package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
  294. package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
  295. package/src/agent/orchestrator/workflow-store.mjs +93 -0
  296. package/src/agent/tool-defs.mjs +103 -0
  297. package/src/channels/backends/discord.mjs +784 -0
  298. package/src/channels/data/voice-runtime-manifest.json +138 -0
  299. package/src/channels/index.mjs +3229 -0
  300. package/src/channels/lib/cli-worker-host.mjs +12 -0
  301. package/src/channels/lib/config-lock.mjs +13 -0
  302. package/src/channels/lib/config.mjs +292 -0
  303. package/src/channels/lib/drop-trace.mjs +71 -0
  304. package/src/channels/lib/event-pipeline.mjs +81 -0
  305. package/src/channels/lib/event-queue.mjs +345 -0
  306. package/src/channels/lib/executor.mjs +168 -0
  307. package/src/channels/lib/format.mjs +188 -0
  308. package/src/channels/lib/holidays.mjs +138 -0
  309. package/src/channels/lib/hook-pipe-server.mjs +802 -0
  310. package/src/channels/lib/interaction-workflows.mjs +184 -0
  311. package/src/channels/lib/memory-client.mjs +149 -0
  312. package/src/channels/lib/output-forwarder.mjs +765 -0
  313. package/src/channels/lib/runtime-paths.mjs +479 -0
  314. package/src/channels/lib/scheduler.mjs +723 -0
  315. package/src/channels/lib/session-control.mjs +36 -0
  316. package/src/channels/lib/session-discovery.mjs +103 -0
  317. package/src/channels/lib/settings.mjs +11 -0
  318. package/src/channels/lib/state-file.mjs +68 -0
  319. package/src/channels/lib/status-snapshot.mjs +219 -0
  320. package/src/channels/lib/tool-format.mjs +140 -0
  321. package/src/channels/lib/transcript-discovery.mjs +195 -0
  322. package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
  323. package/src/channels/lib/webhook.mjs +1179 -0
  324. package/src/channels/lib/whisper-server.mjs +477 -0
  325. package/src/channels/tool-defs.mjs +170 -0
  326. package/src/daemon/host.mjs +118 -0
  327. package/src/daemon/mcp-transport.mjs +47 -0
  328. package/src/daemon/session.mjs +100 -0
  329. package/src/daemon/thin-client.mjs +71 -0
  330. package/src/daemon/transport.mjs +163 -0
  331. package/src/memory/data/runtime-manifest.json +40 -0
  332. package/src/memory/index.mjs +3305 -0
  333. package/src/memory/lib/agent-ipc.mjs +93 -0
  334. package/src/memory/lib/bridge-trace-queries.mjs +120 -0
  335. package/src/memory/lib/core-memory-store.mjs +330 -0
  336. package/src/memory/lib/embedding-provider.mjs +269 -0
  337. package/src/memory/lib/embedding-worker.mjs +323 -0
  338. package/src/memory/lib/llm-worker-host.mjs +17 -0
  339. package/src/memory/lib/memory-cycle.mjs +11 -0
  340. package/src/memory/lib/memory-cycle1.mjs +641 -0
  341. package/src/memory/lib/memory-cycle2.mjs +1284 -0
  342. package/src/memory/lib/memory-cycle3.mjs +540 -0
  343. package/src/memory/lib/memory-embed.mjs +299 -0
  344. package/src/memory/lib/memory-extraction.mjs +5 -0
  345. package/src/memory/lib/memory-maintenance-store.mjs +32 -0
  346. package/src/memory/lib/memory-ops-policy.mjs +190 -0
  347. package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
  348. package/src/memory/lib/memory-recall-read-query.mjs +7 -0
  349. package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
  350. package/src/memory/lib/memory-recall-store.mjs +621 -0
  351. package/src/memory/lib/memory-retrievers.mjs +112 -0
  352. package/src/memory/lib/memory-score.mjs +71 -0
  353. package/src/memory/lib/memory-text-utils.mjs +58 -0
  354. package/src/memory/lib/memory.mjs +412 -0
  355. package/src/memory/lib/model-profile.mjs +85 -0
  356. package/src/memory/lib/pg/adapter.mjs +308 -0
  357. package/src/memory/lib/pg/process.mjs +360 -0
  358. package/src/memory/lib/pg/supervisor.mjs +396 -0
  359. package/src/memory/lib/project-id-resolver.mjs +86 -0
  360. package/src/memory/lib/runtime-fetcher.mjs +442 -0
  361. package/src/memory/lib/trace-store.mjs +728 -0
  362. package/src/memory/tool-defs.mjs +79 -0
  363. package/src/search/index.mjs +1173 -0
  364. package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
  365. package/src/search/lib/backends/exa.mjs +50 -0
  366. package/src/search/lib/backends/firecrawl.mjs +61 -0
  367. package/src/search/lib/backends/gemini-api.mjs +83 -0
  368. package/src/search/lib/backends/grok-oauth.mjs +86 -0
  369. package/src/search/lib/backends/index.mjs +150 -0
  370. package/src/search/lib/backends/openai-api.mjs +144 -0
  371. package/src/search/lib/backends/openai-oauth.mjs +98 -0
  372. package/src/search/lib/backends/openai-web-search.mjs +76 -0
  373. package/src/search/lib/backends/tavily.mjs +55 -0
  374. package/src/search/lib/backends/xai-api.mjs +113 -0
  375. package/src/search/lib/cache.mjs +131 -0
  376. package/src/search/lib/config.mjs +192 -0
  377. package/src/search/lib/formatter.mjs +115 -0
  378. package/src/search/lib/provider-usage.mjs +67 -0
  379. package/src/search/lib/providers.mjs +47 -0
  380. package/src/search/lib/search-intent.mjs +109 -0
  381. package/src/search/lib/setup-handler.mjs +261 -0
  382. package/src/search/lib/state.mjs +201 -0
  383. package/src/search/lib/web-tools.mjs +1207 -0
  384. package/src/search/tool-defs.mjs +83 -0
  385. package/src/setup/defender-exclusion.mjs +183 -0
  386. package/src/shared/abort-controller.mjs +15 -0
  387. package/src/shared/atomic-file.mjs +420 -0
  388. package/src/shared/config.mjs +350 -0
  389. package/src/shared/daemon-recycle.mjs +108 -0
  390. package/src/shared/disable-claude-builtins.mjs +88 -0
  391. package/src/shared/err-text.mjs +12 -0
  392. package/src/shared/llm/cost.mjs +66 -0
  393. package/src/shared/llm/http-agent.mjs +123 -0
  394. package/src/shared/llm/index.mjs +41 -0
  395. package/src/shared/llm/pid-cleanup.mjs +27 -0
  396. package/src/shared/llm/usage-log.mjs +47 -0
  397. package/src/shared/plugin-paths.mjs +58 -0
  398. package/src/shared/schedules-store.mjs +70 -0
  399. package/src/shared/seed.mjs +119 -0
  400. package/src/shared/user-cwd.mjs +213 -0
  401. package/src/shared/user-data-guard.mjs +238 -0
  402. package/src/status/aggregator.mjs +584 -0
  403. package/src/status/server.mjs +413 -0
  404. package/tools.json +1653 -0
@@ -0,0 +1,1478 @@
1
+ import { classifyResultKind } from './result-classification.mjs';
2
+ import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
3
+ import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool } from '../tools/builtin.mjs';
4
+ import { executeBashSessionTool } from '../tools/bash-session.mjs';
5
+ import { executePatchTool } from '../tools/patch.mjs';
6
+ import { executeCodeGraphTool, isCodeGraphTool } from '../tools/code-graph.mjs';
7
+ import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
8
+ import { collectSkillsCached, loadSkillContent } from '../context/collect.mjs';
9
+ import { traceBridgeLoop, traceBridgeTool, traceBridgeTrim, estimateProviderPayloadBytes, messagePrefixHash } from '../bridge-trace.mjs';
10
+ import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal } from './manager.mjs';
11
+ import { trimMessages, estimateRequestReserveTokens } from './trim.mjs';
12
+ import { isContextOverflowError } from '../providers/retry-classifier.mjs';
13
+ import { classifyBashFileLookupCommand, stripSoftWarns } from '../tool-loop-guard.mjs';
14
+ import { maybeOffloadToolResult } from './tool-result-offload.mjs';
15
+ import { tryReadCached, setReadCached, invalidatePathForSession, markPostEdit, consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
16
+ import { createScopedCacheOutcome } from './cache/scoped-cache-outcome.mjs';
17
+ import { createHash } from 'crypto';
18
+
19
+ // Tool-name classification for cross-turn read dedup.
20
+ // Strips the MCP prefix so direct calls and MCP-wrapped calls share the
21
+ // same cache.
22
+ function _stripMcpPrefix(name) {
23
+ return typeof name === 'string' && name.startsWith(MCP_TOOL_PREFIX)
24
+ ? name.slice(MCP_TOOL_PREFIX.length) : name;
25
+ }
26
+ function _isReadTool(name) {
27
+ return _stripMcpPrefix(name) === 'read';
28
+ }
29
+ function _isScalarWriteEditTool(name) {
30
+ const n = _stripMcpPrefix(name);
31
+ return n === 'write' || n === 'edit';
32
+ }
33
+ function _isMutationTool(name) {
34
+ const n = _stripMcpPrefix(name);
35
+ return n === 'apply_patch' || n === 'write' || n === 'edit';
36
+ }
37
+ const SCOPED_CACHEABLE_TOOLS = new Set([
38
+ 'code_graph',
39
+ 'grep',
40
+ 'list',
41
+ 'glob',
42
+ ]);
43
+ function _isScopedCacheableTool(name) {
44
+ const n = _stripMcpPrefix(name);
45
+ return SCOPED_CACHEABLE_TOOLS.has(n);
46
+ }
47
+ function _isBashTool(name) {
48
+ const n = _stripMcpPrefix(name);
49
+ return n === 'bash' || n === 'bash_session';
50
+ }
51
+
52
+ // classifyResultKind is imported from result-classification.mjs at the top of
53
+ // this file; import it from there directly rather than via this module.
54
+
55
+ // Canonical signature for intra-turn duplicate detection. Sorting keys
56
+ // produces a stable hash regardless of arg-object key order. Anything
57
+ // non-serializable falls back to String(args) — still deterministic for
58
+ // the model's typical structured-arg shape.
59
+ function _canonicalArgs(args) {
60
+ if (args == null || typeof args !== 'object') {
61
+ try { return JSON.stringify(args); } catch { return String(args); }
62
+ }
63
+ try {
64
+ const keys = Object.keys(args).sort();
65
+ const sorted = {};
66
+ for (const k of keys) sorted[k] = args[k];
67
+ return JSON.stringify(sorted);
68
+ } catch { return String(args); }
69
+ }
70
+ function _intraTurnSig(name, args) {
71
+ return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
72
+ }
73
+
74
+ // Shared pre-dispatch deny — single source of truth for role/scope/permission
75
+ // rejects. Called by BOTH the eager dispatch path (startEagerTool) and the
76
+ // serial dispatch path (executeTool body). Returns null when the call is
77
+ // allowed to proceed; otherwise returns the Error string the serial path
78
+ // would emit. The eager caller ignores the message body and just treats
79
+ // non-null as "do not start eager".
80
+ //
81
+ // Predicates are kept in the same order as the legacy serial branch so a
82
+ // bridge-owned control-plane tool fails on _bridgeOwned+_controlPlaneTool
83
+ // FIRST (not on permission/wrapper checks) — matches the prior wording.
84
+ // Bridge workers are sandboxed to code/research tools. They must never reach
85
+ // owner/host control surfaces: session management, the ENTIRE channels module
86
+ // (Discord messaging, schedules, webhook/config, channel-bridge toggle,
87
+ // command injection), or host input injection. Explicit name list (no imports)
88
+ // keeps this hot-path gate dependency-free; add new owner/channel tools here.
89
+ const WORKER_DENIED_TOOLS = new Set([
90
+ // session control-plane — unified into the single `bridge` tool
91
+ // (type=spawn|send|close|list). Denying the one name blocks all worker
92
+ // session control. Legacy names kept for defense-in-depth against any
93
+ // stale catalog entry that still advertises them.
94
+ 'bridge', 'close_session', 'list_sessions', 'create_session',
95
+ // channels module (owner/Discord-facing)
96
+ 'reply', 'react', 'edit_message', 'download_attachment', 'fetch',
97
+ 'schedule_status', 'trigger_schedule', 'schedule_control',
98
+ 'activate_channel_bridge', 'reload_config', 'inject_command',
99
+ // host input injection
100
+ 'inject_input',
101
+ ]);
102
+ function _preDispatchDeny(call, toolKind, sessionRef) {
103
+ const name = call?.name;
104
+ if (typeof name !== 'string' || !name) return null;
105
+ const _bridgeOwned = sessionRef?.scope?.startsWith?.('bridge:') || sessionRef?.owner === 'bridge';
106
+ const _controlPlaneTool = WORKER_DENIED_TOOLS.has(name);
107
+ if (_bridgeOwned && _controlPlaneTool) {
108
+ return `Error: control-plane tool "${name}" is Lead-only and not available to bridge workers.`;
109
+ }
110
+ const noToolRole = sessionRef?.role === 'cycle1-agent' || sessionRef?.role === 'cycle2-agent';
111
+ if (noToolRole) {
112
+ 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).`;
113
+ }
114
+ if (isBlockedHiddenWrapperCall(name, sessionRef)) {
115
+ 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.`;
116
+ }
117
+ const effectivePermission = effectiveToolPermission(sessionRef);
118
+ const permissionBlocked = isBlockedByPermission(name, toolKind, effectivePermission);
119
+ if (permissionBlocked && effectivePermission === 'mcp') {
120
+ return `Error: tool "${name}" is not available on this session (permission=mcp). Use MCP/internal retrieval tools only.`;
121
+ }
122
+ if (permissionBlocked && effectivePermission === 'read') {
123
+ return `Error: tool "${name}" is not available on this session (permission=read). Use Mixdog MCP read/grep/glob/recall/search/explore instead.`;
124
+ }
125
+ if (permissionBlocked && effectivePermission && typeof effectivePermission === 'object') {
126
+ return `Error: tool "${name}" is not permitted on this session by the role's allow/deny permission policy.`;
127
+ }
128
+ return null;
129
+ }
130
+ /** Exported for smoke tests — same runtime deny as the agent loop. */
131
+ export function preDispatchDenyForSession(sessionRef, call, toolKind = 'builtin') {
132
+ return _preDispatchDeny(call, toolKind, sessionRef);
133
+ }
134
+ import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
135
+
136
+
137
+ import { isHiddenRole } from '../internal-roles.mjs';
138
+ import { createRequire } from 'module';
139
+ import { readFileSync as _readFileSync } from 'fs';
140
+ import { fileURLToPath } from 'url';
141
+ import { dirname, resolve as resolvePath, isAbsolute } from 'path';
142
+ // Load the CJS permission evaluator. The hooks/ directory lives two levels
143
+ // above src/agent/orchestrator/session/, so we walk up from __dirname.
144
+ const _require = createRequire(import.meta.url);
145
+ const _hooksLib = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
146
+ const { evaluatePermission: _evaluatePermission } = _require(_hooksLib);
147
+ const MCP_TOOL_PREFIX = 'mcp__plugin_mixdog_mixdog__';
148
+ const SAFETY_TRIM_PERCENT = 0.90;
149
+ // Stricter budget used for the one-shot retry after a provider rejects a send
150
+ // with a context-window-exceeded error. 0.60×contextWindow forces older
151
+ // non-system / non-latest turns to drop hard so the retry fits even when the
152
+ // pre-send estimate under-counted provider-side bytes (tool schemas, framing,
153
+ // provider-internal token accounting). Used exactly once per send; see the
154
+ // retry block around provider.send below.
155
+ const OVERFLOW_RETRY_TRIM_PERCENT = 0.60;
156
+
157
+ // Cache-hit results always inline the cached body. The earlier size-gated
158
+ // `[cache-hit-ref]` branch confused bridge workers whose context did not
159
+ // contain the referenced prior tool_result, triggering shell-cat detours.
160
+ // Hard iteration ceiling for every agent loop. Reset to 0 whenever the
161
+ // transcript is compacted (see the trim block below): a long task that keeps
162
+ // compacting can proceed past this count, while a tight NON-compacting loop
163
+ // still stops here and returns the accumulated transcript.
164
+ const MAX_LOOP_ITERATIONS = 200;
165
+ // Consecutive identical-AND-failing tool calls (same name+args, error result)
166
+ // tolerated across iterations before the loop refuses to re-execute and steers
167
+ // the model to change approach. Distinct from the hard iteration cap above:
168
+ // this catches tight deterministic-failure loops (e.g. a command that errors
169
+ // the same way every time) far earlier than 100 iterations.
170
+ const REPEAT_FAIL_LIMIT = 3;
171
+ const _HIDDEN_ROLES_JSON = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../defaults/hidden-roles.json');
172
+ let _hiddenRolesCache = null;
173
+ function _getHiddenRoles() {
174
+ if (_hiddenRolesCache) return _hiddenRolesCache;
175
+ try {
176
+ _hiddenRolesCache = JSON.parse(_readFileSync(_HIDDEN_ROLES_JSON, 'utf8'));
177
+ } catch { _hiddenRolesCache = { roles: [] }; }
178
+ return _hiddenRolesCache;
179
+ }
180
+ // Transcript pairing guard. Anthropic 400-rejects when an assistant message
181
+ // ends with tool_use blocks and the next message isn't tool results for
182
+ // those exact ids. abort/timeout/error race in the loop body can leave a
183
+ // dangling assistant tool_use at the tail (e.g. the structure_probe loop
184
+ // running 12 deep then aborting between push-assistant and push-tool).
185
+ // Strip any trailing assistant tool_use that has no matching tool result
186
+ // so provider.send sees a valid transcript instead of leaking the 400 to
187
+ // the user. Repair runs every iteration but is a no-op on healthy paths.
188
+ function _ensureTranscriptPairing(msgs, sessionId) {
189
+ // Walk backwards to find the last assistant message that emitted
190
+ // tool_use, then validate that every id has a matching tool result
191
+ // inside the CONTIGUOUS tool-message block immediately following it.
192
+ // Earlier guard splice'd the entire tail — which silently deleted any
193
+ // user prompt appended after the dangling assistant by manager.mjs:
194
+ // when the guard fired with shape
195
+ // [..., assistant{a,b}, tool{a}, user{new prompt}]
196
+ // the splice removed user{new prompt} along with the orphan suffix.
197
+ // Fix: remove only assistant + the contiguous tool block; preserve
198
+ // anything past it (user / system / next assistant) untouched.
199
+ let popped = 0;
200
+ while (msgs.length > 0) {
201
+ let lastAssistantIdx = -1;
202
+ for (let i = msgs.length - 1; i >= 0; i--) {
203
+ const m = msgs[i];
204
+ if (m?.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
205
+ lastAssistantIdx = i;
206
+ break;
207
+ }
208
+ }
209
+ if (lastAssistantIdx === -1) break;
210
+ // Collect the contiguous tool messages directly after this assistant.
211
+ // Anything past that block is unrelated (next user prompt, system
212
+ // marker, etc.) and must survive the repair.
213
+ let toolBlockEnd = lastAssistantIdx + 1;
214
+ while (toolBlockEnd < msgs.length && msgs[toolBlockEnd]?.role === 'tool') {
215
+ toolBlockEnd += 1;
216
+ }
217
+ const toolBlock = msgs.slice(lastAssistantIdx + 1, toolBlockEnd);
218
+ const ids = msgs[lastAssistantIdx].toolCalls.map(c => c.id);
219
+ const matched = ids.every(id => toolBlock.some(m => m.toolCallId === id));
220
+ if (matched) break;
221
+ const removed = toolBlockEnd - lastAssistantIdx;
222
+ msgs.splice(lastAssistantIdx, removed);
223
+ popped += removed;
224
+ }
225
+ // Second sweep — catch dangling tool results that survived the
226
+ // contiguous-block splice. Anthropic strict spec requires every
227
+ // tool result to sit in a contiguous block right after the
228
+ // assistant whose toolCalls produced it; a `[..., assistant{a,b},
229
+ // tool{a}, user, tool{b}]` shape leaves tool{b} orphaned even
230
+ // after assistant + tool{a} are repaired by the loop above.
231
+ // Walk back from each tool message to the nearest non-tool
232
+ // ancestor; if it is not an assistant whose toolCalls include
233
+ // this id, drop the orphan.
234
+ for (let i = msgs.length - 1; i >= 0; i--) {
235
+ const m = msgs[i];
236
+ if (m?.role !== 'tool') continue;
237
+ if (!m.toolCallId) {
238
+ msgs.splice(i, 1);
239
+ popped += 1;
240
+ continue;
241
+ }
242
+ let prevIdx = i - 1;
243
+ while (prevIdx >= 0 && msgs[prevIdx]?.role === 'tool') prevIdx--;
244
+ const anchor = prevIdx >= 0 ? msgs[prevIdx] : null;
245
+ const anchorOk = anchor?.role === 'assistant'
246
+ && Array.isArray(anchor.toolCalls)
247
+ && anchor.toolCalls.some(c => c.id === m.toolCallId);
248
+ if (!anchorOk) {
249
+ msgs.splice(i, 1);
250
+ popped += 1;
251
+ }
252
+ }
253
+ if (popped > 0 && sessionId) {
254
+ try { process.stderr.write(`[transcript-repair] sess=${sessionId} popped=${popped} dangling assistant tool_use\n`); } catch {}
255
+ }
256
+ }
257
+
258
+ // Write-class tools that a permission=read session must not execute. The
259
+ // schema still advertises them to keep one unified shard; this runtime set
260
+ // is the fail-safe reject at call time.
261
+ const READ_BLOCKED_TOOLS = new Set([
262
+ 'bash', 'bash_session',
263
+ 'write',
264
+ 'edit',
265
+ 'apply_patch',
266
+ ]);
267
+ const MCP_ONLY_ALLOWED_KINDS = new Set(['mcp', 'internal', 'skill']);
268
+ // Wrappers that hidden retrieval roles back. Hidden roles MUST NOT call
269
+ // these or they spawn another hidden agent of the same kind — nested chain
270
+ // + token burn. Block at call time; the role's rule prompt also says so.
271
+ const RETRIEVAL_WRAPPERS = new Set(['recall', 'search', 'explore']);
272
+ // Hidden roles that may call specific retrieval wrappers. Default policy
273
+ // blocks all hidden→wrapper calls; roles listed here have a documented
274
+ // need:
275
+ // - scheduler-task / webhook-handler: state-changing agents whose
276
+ // tasks routinely require both reach-back into past context
277
+ // (`recall`) and fresh external info (`search`).
278
+ const HIDDEN_ROLE_WRAPPER_ALLOWLIST = {
279
+ 'scheduler-task': new Set(['recall', 'search']),
280
+ 'webhook-handler': new Set(['recall', 'search']),
281
+ };
282
+ // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
283
+ // to execute during SSE parsing so tool work overlaps with the rest of the
284
+ // stream. Writes, bash, MCP and skills stay serial after send() returns.
285
+ function isEagerDispatchable(name, tools) {
286
+ if (!Array.isArray(tools)) return false;
287
+ const def = tools.find(t => t?.name === name);
288
+ return def?.annotations?.readOnlyHint === true;
289
+ }
290
+ // ── Bridge-worker permission enforcement ──────────────────────────────────────
291
+ // Mirrors the PreToolUse hook evaluation for tool calls that originate inside a
292
+ // bridge worker session. Worker dispatch previously bypassed the hook pipeline
293
+ // entirely; this guard closes that gap by running the same evaluator inline.
294
+ //
295
+ // `ask` is treated as deny here — forwarding `ask` decisions to the channel
296
+ // UI approval flow needs bidirectional prompt plumbing that does not exist.
297
+ function _checkWorkerPermission(toolName, toolInput, sessionRef) {
298
+ const bareToolName = _stripMcpPrefix(toolName);
299
+ if (sessionRef?.owner === 'bridge' && bareToolName === 'bash') {
300
+ const cmdClass = classifyBashFileLookupCommand(toolInput?.command);
301
+ if (cmdClass) {
302
+ return `Error: bridge worker bash file lookup blocked (${cmdClass}). Use Mixdog MCP read/grep/glob/list directly; bash is only for build/test/run/git-style commands.`;
303
+ }
304
+ }
305
+ // Even when no explicit permissionMode is propagated to the worker, run
306
+ // the evaluator under the most restrictive baseline ('default') so the
307
+ // bypass-proof hard-deny patterns (UNC paths, /etc, C:/Windows, etc.)
308
+ // and the user's settings.json deny rules still apply. Previously a
309
+ // missing permissionMode short-circuited to null and the worker
310
+ // ran ungated — a model could dispatch a bridge to read or write
311
+ // protected paths even when the same call would have been denied for
312
+ // the parent. Callers that genuinely need bypassPermissions can still
313
+ // forward it explicitly via session-builder; this only closes the
314
+ // silent default-to-bypass path.
315
+ const permissionMode = sessionRef?.permissionMode || 'default';
316
+ // Prefix bare mixdog tool names so the evaluator path-logic handles them correctly.
317
+ const fullName = toolName.startsWith(MCP_TOOL_PREFIX) || toolName.startsWith('mcp__')
318
+ ? toolName
319
+ : `${MCP_TOOL_PREFIX}${toolName}`;
320
+ const projectDir = sessionRef?.cwd || undefined;
321
+ const userCwd = sessionRef?.cwd || undefined;
322
+ try {
323
+ const { decision, reason } = _evaluatePermission({
324
+ toolName: fullName,
325
+ toolInput: toolInput || {},
326
+ permissionMode,
327
+ projectDir,
328
+ userCwd,
329
+ });
330
+ if (decision === 'deny' || decision === 'ask') {
331
+ return `Error: tool "${toolName}" blocked by permission evaluator (decision=${decision}): ${reason}`;
332
+ }
333
+ } catch (err) {
334
+ // Evaluator errors must not crash the loop — log and allow.
335
+ try { process.stderr.write(`[permission-evaluator] error: ${err?.message}\n`); } catch {}
336
+ }
337
+ return null;
338
+ }
339
+ function effectiveToolPermission(sessionRef) {
340
+ return sessionRef?.toolPermission || sessionRef?.permission || null;
341
+ }
342
+ function isBlockedByPermission(toolName, toolKind, permission) {
343
+ if (permission === 'mcp') return !MCP_ONLY_ALLOWED_KINDS.has(toolKind);
344
+ if (permission === 'read') return READ_BLOCKED_TOOLS.has(toolName);
345
+ // Object-form {allow,deny} permission (role template / profile). The
346
+ // schema-level intersection in createSession only narrows the ADVERTISED
347
+ // tool list; it is not a runtime execution boundary. Enforce the same
348
+ // allow/deny here as the fail-safe so a tool call for a non-advertised
349
+ // (denied / out-of-allow) tool is rejected at dispatch time, matching
350
+ // the string-form ('read'/'mcp') guards. Names are compared bare +
351
+ // lowercased to mirror createSession's allow/deny set construction.
352
+ if (permission && typeof permission === 'object') {
353
+ const name = String(_stripMcpPrefix(toolName) || '').toLowerCase();
354
+ const deny = Array.isArray(permission.deny) && permission.deny.length > 0
355
+ ? permission.deny.map(n => String(n).toLowerCase())
356
+ : null;
357
+ if (deny && deny.includes(name)) return true;
358
+ const allow = Array.isArray(permission.allow) && permission.allow.length > 0
359
+ ? permission.allow.map(n => String(n).toLowerCase())
360
+ : null;
361
+ if (allow && !allow.includes(name)) return true;
362
+ return false;
363
+ }
364
+ return false;
365
+ }
366
+ function isBlockedHiddenWrapperCall(toolName, sessionRef) {
367
+ if (!RETRIEVAL_WRAPPERS.has(toolName)) return false;
368
+ if (sessionRef?.owner !== 'bridge') return false;
369
+ if (!isHiddenRole(sessionRef?.role)) return false;
370
+ const allow = HIDDEN_ROLE_WRAPPER_ALLOWLIST[sessionRef.role];
371
+ if (allow && allow.has(toolName)) return false;
372
+ return true;
373
+ }
374
+ function messagesArrayChanged(before, after) {
375
+ if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
376
+ if (before.length !== after.length) return true;
377
+ for (let i = 0; i < before.length; i += 1) {
378
+ if (before[i] !== after[i]) return true;
379
+ }
380
+ return false;
381
+ }
382
+ const SKILL_TOOL_NAMES = new Set(['skills_list', 'skill_view', 'skill_execute']);
383
+ const SPECIAL_TOOL_NAMES = new Set(['bash_session', 'apply_patch', 'code_graph']);
384
+ const BASH_SESSION_HEADER_RE = /\[session: ([^\]\r\n]+)\]/;
385
+ const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
386
+ const STORED_TOOL_ARG_LONG_KEY_RE = /^(?:command|script)$/i;
387
+ const STORED_TOOL_ARG_BODY_LIMIT = 2_000;
388
+ const STORED_TOOL_ARG_LONG_LIMIT = 8_000;
389
+ const STORED_TOOL_ARG_PREVIEW_HEAD = 360;
390
+ const STORED_TOOL_ARG_PREVIEW_TAIL = 160;
391
+
392
+ function compactStoredToolArgString(value, key = '') {
393
+ if (typeof value !== 'string') return value;
394
+ const isBody = STORED_TOOL_ARG_BODY_KEY_RE.test(key);
395
+ const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
396
+ const limit = isBody ? STORED_TOOL_ARG_BODY_LIMIT : (isLong ? STORED_TOOL_ARG_LONG_LIMIT : Infinity);
397
+ if (value.length <= limit) return value;
398
+ const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
399
+ const head = value.slice(0, STORED_TOOL_ARG_PREVIEW_HEAD).replace(/\r\n/g, '\n');
400
+ const tail = value.slice(-STORED_TOOL_ARG_PREVIEW_TAIL).replace(/\r\n/g, '\n');
401
+ return `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}]\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
402
+ }
403
+
404
+ function compactStoredToolArgValue(value, key = '', depth = 0) {
405
+ if (value === null || value === undefined) return value;
406
+ if (typeof value === 'string') return compactStoredToolArgString(value, key);
407
+ if (typeof value !== 'object') return value;
408
+ if (depth >= 6) return Array.isArray(value) ? `[${value.length} items]` : '{...}';
409
+ if (Array.isArray(value)) {
410
+ return value.map((item) => compactStoredToolArgValue(item, key, depth + 1));
411
+ }
412
+ const out = {};
413
+ for (const [k, v] of Object.entries(value)) {
414
+ out[k] = compactStoredToolArgValue(v, k, depth + 1);
415
+ }
416
+ return out;
417
+ }
418
+
419
+ function compactToolCallsForHistory(calls) {
420
+ if (!Array.isArray(calls)) return calls;
421
+ return calls.map((call) => {
422
+ if (!call || typeof call !== 'object') return call;
423
+ return {
424
+ ...call,
425
+ arguments: compactStoredToolArgValue(call.arguments),
426
+ };
427
+ });
428
+ }
429
+
430
+ // Restore the FULL body of ONE tool call inside a history assistant message
431
+ // whose toolCalls were compacted at push time. Used for a failed edit call so
432
+ // the model sees the original patch/old_string on retry instead of a
433
+ // `[mixdog compacted …]` placeholder it cannot act on. Must run BEFORE the
434
+ // message is first transmitted so it never mutates an already-cached prefix
435
+ // (the prompt cache is content-prefix matched).
436
+ //
437
+ // Only the compactable body/long keys (patch, old_string, new_string, content,
438
+ // rewrite, command, script) are restored, and at ANY depth — compaction is
439
+ // recursive (compactStoredToolArgValue), so batch shapes like edits[].old_string
440
+ // or writes[].content carry nested compacted bodies too. Every other field
441
+ // (e.g. `path`, which a tool may mutate in place during execution) is taken from
442
+ // the compacted snapshot captured at push time, before any mutation. The
443
+ // compacted args tree is built fresh by compactToolCallsForHistory and is not
444
+ // shared with originalCalls, so rebuilding it here is safe.
445
+ function restoreToolCallBodyForId(assistantMsg, originalCalls, callId) {
446
+ if (!assistantMsg || !Array.isArray(assistantMsg.toolCalls) || !callId) return;
447
+ if (!Array.isArray(originalCalls)) return;
448
+ const tc = assistantMsg.toolCalls.find((t) => t && t.id === callId);
449
+ const orig = originalCalls.find((c) => c && c.id === callId);
450
+ if (!tc || !orig) return;
451
+ if (!tc.arguments || typeof tc.arguments !== 'object'
452
+ || !orig.arguments || typeof orig.arguments !== 'object') return;
453
+ tc.arguments = _restoreCompactedBodies(tc.arguments, orig.arguments, '');
454
+ }
455
+
456
+ // Recursively rebuild a compacted args tree: replace ONLY compactable body/long
457
+ // string fields (matched by key at any depth) with their full originals, and
458
+ // keep every other field from the compacted snapshot. tcVal and origVal share
459
+ // the same structure (compaction only shortens body strings), so the walk
460
+ // descends them in parallel; a missing or non-object origVal falls back to the
461
+ // compacted value rather than throwing.
462
+ function _restoreCompactedBodies(tcVal, origVal, key) {
463
+ if ((STORED_TOOL_ARG_BODY_KEY_RE.test(key) || STORED_TOOL_ARG_LONG_KEY_RE.test(key))
464
+ && typeof origVal === 'string') {
465
+ return origVal;
466
+ }
467
+ if (Array.isArray(tcVal) && Array.isArray(origVal)) {
468
+ return tcVal.map((item, i) => _restoreCompactedBodies(item, origVal[i], key));
469
+ }
470
+ if (tcVal && typeof tcVal === 'object' && origVal && typeof origVal === 'object') {
471
+ const out = {};
472
+ for (const k of Object.keys(tcVal)) {
473
+ out[k] = (k in origVal) ? _restoreCompactedBodies(tcVal[k], origVal[k], k) : tcVal[k];
474
+ }
475
+ return out;
476
+ }
477
+ return tcVal;
478
+ }
479
+ /**
480
+ * Execute a single tool call — routes to MCP or builtin.
481
+ */
482
+ function getToolKind(name) {
483
+ if (SKILL_TOOL_NAMES.has(name)) return 'skill';
484
+ if (SPECIAL_TOOL_NAMES.has(name)) return 'builtin';
485
+ if (isMcpTool(name)) return 'mcp';
486
+ if (isInternalTool(name)) return 'internal';
487
+ if (isBuiltinTool(name)) return 'builtin';
488
+ return 'builtin';
489
+ }
490
+ function buildSkillsListResponse(cwd) {
491
+ const skills = collectSkillsCached(cwd);
492
+ const entries = skills.map(s => ({ name: s.name, description: s.description || '' }));
493
+ return JSON.stringify({ skills: entries });
494
+ }
495
+ function viewSkill(cwd, name) {
496
+ if (!name) return 'Error: skill name is required';
497
+ const content = loadSkillContent(name, cwd);
498
+ return content || `Error: skill "${name}" not found`;
499
+ }
500
+ function executeSkill(cwd, name, _args) {
501
+ if (!name) return 'Error: skill name is required';
502
+ const content = loadSkillContent(name, cwd);
503
+ return content || `Error: skill "${name}" not found`;
504
+ }
505
+ function extractBashSessionId(result) {
506
+ if (typeof result !== 'string') return null;
507
+ const match = BASH_SESSION_HEADER_RE.exec(result);
508
+ return match ? match[1] : null;
509
+ }
510
+
511
+ export function buildBridgeBashSessionArgs(args, sessionRef) {
512
+ if (sessionRef?.owner !== 'bridge') return null;
513
+ // run_in_background is a detached one-shot job, incompatible with the
514
+ // persistent bash session. Fall through to the background-job path
515
+ // (executeBuiltinTool -> startBackgroundShellJob) so the worker gets a
516
+ // [job: ...] id that job_wait can resolve — otherwise the persistent
517
+ // session returns a [session: ...] header and job_wait reports "job not found".
518
+ if (args?.run_in_background === true) return null;
519
+ const routedArgs = { ...(args || {}) };
520
+ const explicitSessionId = typeof routedArgs.session_id === 'string' && routedArgs.session_id.trim()
521
+ ? routedArgs.session_id.trim()
522
+ : null;
523
+ const wantsPersistent = routedArgs.persistent === true || !!explicitSessionId;
524
+ if (!wantsPersistent) return null;
525
+ if (!explicitSessionId && sessionRef?.implicitBashSessionId) {
526
+ routedArgs.session_id = sessionRef.implicitBashSessionId;
527
+ } else if (explicitSessionId) {
528
+ routedArgs.session_id = explicitSessionId;
529
+ }
530
+ delete routedArgs.persistent;
531
+ return routedArgs;
532
+ }
533
+
534
+ function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
535
+ if (executeOpts.scopedCacheOutcome) {
536
+ if (sessionRef && toolCallId) {
537
+ if (!sessionRef._scopedCacheOutcomeByCallId) sessionRef._scopedCacheOutcomeByCallId = new Map();
538
+ sessionRef._scopedCacheOutcomeByCallId.set(toolCallId, executeOpts.scopedCacheOutcome);
539
+ }
540
+ return executeOpts.scopedCacheOutcome;
541
+ }
542
+ if (!callerSessionId || !toolCallId || !_isScopedCacheableTool(toolName)) return null;
543
+ const outcome = createScopedCacheOutcome();
544
+ if (sessionRef) {
545
+ if (!sessionRef._scopedCacheOutcomeByCallId) sessionRef._scopedCacheOutcomeByCallId = new Map();
546
+ sessionRef._scopedCacheOutcomeByCallId.set(toolCallId, outcome);
547
+ }
548
+ return outcome;
549
+ }
550
+
551
+ async function executeTool(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
552
+ const scopedCacheOutcome = _scopedCacheOutcomeForCall(
553
+ sessionRef,
554
+ executeOpts.toolCallId,
555
+ name,
556
+ callerSessionId,
557
+ executeOpts,
558
+ );
559
+ const toolOpts = scopedCacheOutcome
560
+ ? { ...executeOpts, scopedCacheOutcome }
561
+ : executeOpts;
562
+ if (name === 'skills_list') {
563
+ return buildSkillsListResponse(cwd);
564
+ }
565
+ if (name === 'skill_view') {
566
+ return viewSkill(cwd, args?.name);
567
+ }
568
+ if (name === 'skill_execute') {
569
+ return executeSkill(cwd, args?.name, args?.args);
570
+ }
571
+ if (isMcpTool(name)) {
572
+ // 24h trace data shows ~24% of external MCP calls are cwd-sensitive
573
+ // (bash / grep / read / list / glob etc.) but the worker session's
574
+ // cwd was previously dropped here. Inject cwd only when the tool's
575
+ // inputSchema declares the field — schemas without it would reject
576
+ // an unknown argument.
577
+ const needsCwdInjection = cwd
578
+ && mcpToolHasField(name, 'cwd')
579
+ && (args == null || args.cwd == null);
580
+ const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
581
+ return executeMcpTool(name, finalArgs);
582
+ }
583
+ if (isCodeGraphTool(name)) {
584
+ // cwd chain: args.cwd (caller-explicit) → session cwd → undefined (handler throws)
585
+ const graphCwd = (typeof args?.cwd === 'string' && args.cwd.trim()) ? args.cwd.trim() : cwd;
586
+ return executeCodeGraphTool(name, args, graphCwd, null, toolOpts);
587
+ }
588
+ if (isInternalTool(name)) {
589
+ // callerSessionId propagates into server.mjs dispatchTool so that
590
+ // dispatchAiWrapped can detect and reject recursive calls from a
591
+ // hidden-role session (recall/search/explore → self).
592
+ return executeInternalTool(name, args, { callerSessionId, callerCwd: cwd });
593
+ }
594
+ if (name === 'bash') {
595
+ const routedArgs = buildBridgeBashSessionArgs(args, sessionRef);
596
+ if (!routedArgs) {
597
+ // clientHostPid scopes background shell-jobs to the dispatching
598
+ // terminal's claude.exe pid (bridge sessions store it on sessionRef);
599
+ // without it resolveJobOwnerHostPid falls back to the daemon-global env.
600
+ return executeBuiltinTool(name, args, cwd, { sessionId: callerSessionId, clientHostPid: sessionRef?.clientHostPid, ...toolOpts });
601
+ }
602
+ // Thread the session's AbortSignal so bridge type=close can interrupt the
603
+ // persistent child process. getSessionAbortSignal is imported at top of
604
+ // loop.mjs from manager.mjs; callerSessionId identifies the controller.
605
+ let _bashAbortSignal = null;
606
+ try { _bashAbortSignal = getSessionAbortSignal(callerSessionId); } catch { /* ignore */ }
607
+ const result = await executeBashSessionTool('bash_session', routedArgs, cwd, {
608
+ sessionId: callerSessionId,
609
+ abortSignal: _bashAbortSignal,
610
+ });
611
+ const bashSid = extractBashSessionId(result);
612
+ if (bashSid) {
613
+ sessionRef.implicitBashSessionId = bashSid;
614
+ // Track all persistent bash sessions for bulk teardown on close.
615
+ if (sessionRef.allBashSessionIds) {
616
+ if (!sessionRef.allBashSessionIds.includes(bashSid)) {
617
+ sessionRef.allBashSessionIds.push(bashSid);
618
+ }
619
+ } else {
620
+ sessionRef.allBashSessionIds = [bashSid];
621
+ }
622
+ }
623
+ return result;
624
+ }
625
+ if (name === 'apply_patch') {
626
+ return executePatchTool(name, args, cwd, { sessionId: callerSessionId });
627
+ }
628
+ if (isBuiltinTool(name)) {
629
+ // clientHostPid threaded for the same per-terminal job-scope reason as
630
+ // the bash branch above (see resolveJobOwnerHostPid).
631
+ return executeBuiltinTool(name, args, cwd, { sessionId: callerSessionId, clientHostPid: sessionRef?.clientHostPid, ...toolOpts });
632
+ }
633
+ return formatUnknownBuiltinToolMessage(name, args, 'tool');
634
+ }
635
+ /**
636
+ * Agent loop: send → tool_call → execute → re-send → repeat until text.
637
+ * sendOpts may include:
638
+ * - `effort` (provider-specific)
639
+ * - `fast` (boolean)
640
+ * - `sessionId` — enables runtime liveness markers (optional)
641
+ * - `signal` — AbortSignal; checked at each iteration boundary and after each
642
+ * tool. When aborted, throws SessionClosedError so the ask
643
+ * wrapper can propagate a clean cancellation.
644
+ * - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
645
+ */
646
+ // Source of truth: defaults/hidden-roles.json (loaded via _getHiddenRoles
647
+ // above). Build the name Set eagerly at module load so HIDDEN_ROLE_NAMES
648
+ // stays in sync with the declarative registry — no hardcoded duplicate.
649
+ const HIDDEN_ROLE_NAMES = new Set(
650
+ (_getHiddenRoles().roles || []).map((r) => r && r.name).filter((n) => typeof n === 'string' && n.length > 0)
651
+ );
652
+
653
+ // Stop reasons that signal the turn was cut short mid-synthesis (token cap,
654
+ // provider pause). Empty content + one of these reasons means the worker
655
+ // was not done — re-prompt instead of accepting empty as final.
656
+ // Covers Anthropic (pause_turn, max_tokens), OpenAI (length), Gemini
657
+ // (MAX_TOKENS, OTHER), and case variants.
658
+ const INCOMPLETE_STOP_REASONS = new Set([
659
+ 'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
660
+ ]);
661
+
662
+ export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
663
+ let iterations = 0;
664
+ let toolCallsTotal = 0;
665
+ let lastUsage;
666
+ let firstTurnUsage;
667
+ let response;
668
+ let contractNudges = 0;
669
+ const opts = sendOpts || {};
670
+ const sessionId = opts.sessionId || null;
671
+ const signal = opts.signal || null;
672
+ const sessionRole = opts.session?.role;
673
+ const forcedFirstTool = opts.forcedFirstTool ?? null;
674
+ const forcedFirstToolDef = forcedFirstTool
675
+ ? tools.find(tool => tool?.name === forcedFirstTool)
676
+ : null;
677
+ // Opaque providerState passthrough. The loop never inspects it; only the
678
+ // originating provider does. Seed from sendOpts.providerState if the
679
+ // manager restored one. No provider currently emits state (Codex OAuth is
680
+ // stateless per contract); field remains undefined end-to-end for now.
681
+ let providerState = opts.providerState ?? undefined;
682
+ const throwIfAborted = () => {
683
+ if (signal?.aborted) {
684
+ const reason = signal.reason instanceof Error ? signal.reason : null;
685
+ // Preserve any structured abort reason (SessionClosedError,
686
+ // StreamStalledAbortError, etc.). Fallback to SessionClosedError
687
+ // when the reason is not an Error instance.
688
+ if (reason) throw reason;
689
+ throw new SessionClosedError(sessionId || 'unknown', 'agent loop aborted');
690
+ }
691
+ };
692
+ const sessionRef = opts.session || null;
693
+ const maxLoopIterations = Number.isFinite(sessionRef?.maxLoopIterations)
694
+ ? sessionRef.maxLoopIterations
695
+ : MAX_LOOP_ITERATIONS;
696
+ // Tool execution must use the session cwd even when the caller omitted the
697
+ // legacy positional cwd argument. Bridge workers always carry their cwd on
698
+ // sessionRef; falling through to pwd()/process.cwd() resolves relatives
699
+ // against the host/plugin root instead of the worker workspace.
700
+ cwd = cwd || sessionRef?.cwd || undefined;
701
+ while (true) {
702
+ throwIfAborted();
703
+ if (iterations >= maxLoopIterations) {
704
+ process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
705
+ break;
706
+ }
707
+ if (sessionRef && typeof sessionRef.contextWindow === 'number') {
708
+ const safetyBudget = Math.floor(sessionRef.contextWindow * SAFETY_TRIM_PERCENT);
709
+ // Reserve headroom for the tool schemas + provider request framing
710
+ // that ride alongside `messages` but are invisible to the chars/4
711
+ // message estimate. Without this the budget is optimistic: a
712
+ // transcript that "fits" by message tokens can still overflow once
713
+ // the provider serializes N tool definitions into the same request.
714
+ const reserveTokens = estimateRequestReserveTokens(tools);
715
+ // Snapshot pre-trim shape so trim_meta can record the actual
716
+ // mutation (or no-op) for prefix-mutation forensics. Bytes are
717
+ // a best-effort JSON.stringify length — close enough to the
718
+ // payload we hand the provider for prefix-cache analysis.
719
+ const beforeCount = messages.length;
720
+ let beforeBytes = null;
721
+ try { beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { beforeBytes = null; }
722
+ const trimmed = trimMessages(messages, safetyBudget, { reserveTokens });
723
+ const trimChanged = messagesArrayChanged(messages, trimmed);
724
+ const pruneCount = Math.max(beforeCount - trimmed.length, 0);
725
+ if (trimChanged) {
726
+ messages.length = 0;
727
+ messages.push(...trimmed);
728
+ // Trimming the transcript invalidates the server-side
729
+ // conversation anchor (xAI Responses / Codex WS rely on
730
+ // previous_response_id which points at a now-mutated prefix).
731
+ // Drop providerState so the next send starts a fresh chain
732
+ // instead of triggering silent cache miss or hard mismatch.
733
+ providerState = undefined;
734
+ // Compaction shrank the transcript, so prior turns no longer
735
+ // pressure the window — reset the iteration counter so a
736
+ // steadily-compacting long task isn't killed by the cap,
737
+ // while a non-compacting tight loop still hits it.
738
+ iterations = 0;
739
+ }
740
+ let afterBytes = null;
741
+ try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
742
+ traceBridgeTrim({
743
+ sessionId,
744
+ iteration: iterations + 1,
745
+ prune_count: pruneCount,
746
+ trim_changed: trimChanged,
747
+ input_prefix_hash: messagePrefixHash(messages),
748
+ before_count: beforeCount,
749
+ after_count: messages.length,
750
+ before_bytes: beforeBytes,
751
+ after_bytes: afterBytes,
752
+ });
753
+ }
754
+ const nextIteration = iterations + 1;
755
+ opts.iteration = nextIteration;
756
+ opts.providerState = providerState;
757
+ if (forcedFirstTool && toolCallsTotal === 0) {
758
+ opts.toolChoice = 'required';
759
+ } else {
760
+ delete opts.toolChoice;
761
+ }
762
+ const sendTools = forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools;
763
+ // Eager-dispatch queue: when the provider streams a tool-call event,
764
+ // start read-only tools immediately so execution overlaps with the
765
+ // remaining SSE parse. Writes and unknown tools wait until send()
766
+ // returns and run serially in the call-order loop below.
767
+ const pending = new Map();
768
+ // Streaming-time intra-turn dedup. When the LLM emits two
769
+ // tool_use blocks with identical (name, args) signatures in
770
+ // sequence, the provider's onToolCall fires for both BEFORE
771
+ // the iter for-body runs, so the batch-level pre-pass would be
772
+ // too late to prevent the eager dispatch of the second one.
773
+ // Track signatures of in-flight eager calls and skip starting a
774
+ // second one for the same sig. The duplicate's executeTool is
775
+ // never invoked; the for-body's pre-pass marks it as a duplicate
776
+ // and emits a stub tool_result. The sig is NOT cleared when the
777
+ // eager promise settles (see finally below): a streaming onToolCall
778
+ // can deliver a same-turn identical call AFTER the first promise
779
+ // settles but BEFORE the deferred cache set (:1256), and the static
780
+ // pre-pass (:909) only runs after send() returns — so clearing the
781
+ // sig on settle would let that second streaming eager call
782
+ // re-execute. A fresh Map() is created per turn, so the sig set
783
+ // resets at the turn boundary without leaking across iterations.
784
+ const _eagerInFlightSigs = new Map();
785
+ let _mutationEpoch = 0;
786
+ const startEagerTool = (call) => {
787
+ if (!call?.id || pending.has(call.id) || !isEagerDispatchable(call.name, tools)) return null;
788
+ const _sig = _intraTurnSig(call.name, call.arguments);
789
+ if (_eagerInFlightSigs.has(_sig)) return null;
790
+ // Repeat-failure guard also gates eager dispatch (reviewer-flagged):
791
+ // streaming onToolCall / startEagerRun would otherwise re-run an
792
+ // identical read-only call that already failed REPEAT_FAIL_LIMIT
793
+ // times before the serial for-body guard runs. Returning null here
794
+ // lets the serial body push the [repeat-failure-guard] stub.
795
+ {
796
+ const _rfg = sessionRef?._repeatFailGuard;
797
+ if (_rfg && _rfg.sig === _sig && _rfg.count >= REPEAT_FAIL_LIMIT) return null;
798
+ }
799
+ const toolKind = getToolKind(call.name);
800
+ // Shared pre-dispatch deny: identical predicate runs in the
801
+ // serial path below. If any role/permission guard would reject
802
+ // this call there, never start it eagerly here.
803
+ if (_preDispatchDeny(call, toolKind, sessionRef) !== null) return null;
804
+ const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: _mutationEpoch };
805
+ _eagerInFlightSigs.set(_sig, call.id);
806
+ entry.promise = (async () => {
807
+ try {
808
+ const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
809
+ if (permBlocked !== null) return { ok: true, value: permBlocked };
810
+ return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id }) };
811
+ } catch (error) {
812
+ return { ok: false, error };
813
+ }
814
+ })()
815
+ .finally(() => {
816
+ entry.endedAt = Date.now();
817
+ // Intentionally do NOT delete _sig here — see the block
818
+ // comment above. The sig must outlive promise settlement
819
+ // so a later same-turn streaming duplicate stays blocked
820
+ // at the _eagerInFlightSigs.has(_sig) guard until the turn
821
+ // boundary recreates the Map.
822
+ });
823
+ pending.set(call.id, entry);
824
+ return entry;
825
+ };
826
+ const startEagerRun = (calls, startIndex, dupSet) => {
827
+ for (let j = startIndex; j < calls.length; j += 1) {
828
+ const call = calls[j];
829
+ if (!call?.id || !isEagerDispatchable(call.name, tools)) break;
830
+ if (dupSet && dupSet.has(call.id)) continue;
831
+ if (!startEagerTool(call) && !pending.has(call.id)) break;
832
+ }
833
+ };
834
+ let _streamEagerBlocked = false;
835
+ opts.onToolCall = (call) => {
836
+ if (!isEagerDispatchable(call?.name, tools)) {
837
+ _streamEagerBlocked = true;
838
+ return;
839
+ }
840
+ if (_streamEagerBlocked) return;
841
+ startEagerTool(call);
842
+ };
843
+ // Repair any dangling assistant tool_use left over from a prior
844
+ // abort/error path before the provider sees the transcript. No-op
845
+ // on the healthy iteration cycle (every assistant tool_use is
846
+ // followed by tool results in the same loop body below).
847
+ _ensureTranscriptPairing(messages, sessionId);
848
+ // Strip soft-warn markers from prior tool results before the next
849
+ // send. Marker bytes (Tool-budget(xN), Same-file reads(xN), etc.)
850
+ // mutate every turn with dynamic counters, so leaving them in the
851
+ // transcript breaks server-side prefix cache lookup on later turns.
852
+ // The current turn's marker (if any) is appended AFTER this strip,
853
+ // so the model still sees the self-correct hint on its own iteration.
854
+ for (let _i = 0; _i < messages.length; _i++) {
855
+ const _m = messages[_i];
856
+ if (_m && _m.role === 'tool' && typeof _m.content === 'string' && _m.content.includes('⚠')) {
857
+ const _stripped = stripSoftWarns(_m.content);
858
+ if (_stripped !== _m.content) _m.content = _stripped;
859
+ }
860
+ }
861
+ const sendStartedAt = Date.now();
862
+ try {
863
+ response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
864
+ } catch (sendErr) {
865
+ // Context-window-exceeded is a deterministic refusal: the request is
866
+ // simply too large. Retry ONCE with a stricter trim budget that
867
+ // force-drops older non-system / non-latest turns before surfacing
868
+ // the error. Unrelated errors (network, stall, auth, etc.) re-throw
869
+ // untouched — they are handled by the provider/bridge retry layers.
870
+ if (
871
+ !isContextOverflowError(sendErr)
872
+ || !(sessionRef && typeof sessionRef.contextWindow === 'number')
873
+ ) {
874
+ throw sendErr;
875
+ }
876
+ const overflowBudget = Math.floor(sessionRef.contextWindow * OVERFLOW_RETRY_TRIM_PERCENT);
877
+ const overflowReserve = estimateRequestReserveTokens(sendTools.length ? sendTools : tools);
878
+ const retrimmed = trimMessages(messages, overflowBudget, { reserveTokens: overflowReserve });
879
+ messages.length = 0;
880
+ messages.push(...retrimmed);
881
+ // The transcript prefix changed; the server-side conversation anchor
882
+ // (previous_response_id / WS continuation) is now invalid. Drop
883
+ // providerState so the retry starts a fresh chain instead of
884
+ // tripping a silent cache miss or hard mismatch.
885
+ providerState = undefined;
886
+ opts.providerState = undefined;
887
+ // Drop eager-dispatch state before the retry send. A tool_use
888
+ // streamed by the failed first send could otherwise orphan its
889
+ // eager result or be double-dispatched; force the retry's tools
890
+ // through the serial post-send path with a clean matching slate.
891
+ opts.onToolCall = undefined;
892
+ pending.clear();
893
+ _eagerInFlightSigs.clear();
894
+ try {
895
+ process.stderr.write(
896
+ `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
897
+ `retrying once at budget=${overflowBudget} reserve=${overflowReserve} ` +
898
+ `messages=${messages.length}\n`,
899
+ );
900
+ } catch { /* best-effort */ }
901
+ response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
902
+ }
903
+ opts.onToolCall = undefined;
904
+ // Capture opaque state for the next turn (may be undefined — that's
905
+ // the stateless contract for providers that don't use continuation).
906
+ providerState = response?.providerState ?? undefined;
907
+ iterations = nextIteration;
908
+ traceBridgeLoop({
909
+ sessionId,
910
+ iteration: iterations,
911
+ sendMs: Date.now() - sendStartedAt,
912
+ messageCount: Array.isArray(messages) ? messages.length : 0,
913
+ bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
914
+ });
915
+ // Accumulate usage across iterations — every billable slot, not just
916
+ // input/output. Anthropic cache_read/cache_write typically stay 0 on
917
+ // the first iteration and surge on later ones (warm prefix reuse),
918
+ // so aggregating only the head would silently drop most of the
919
+ // cache-side tokens.
920
+ if (response.usage) {
921
+ if (lastUsage) {
922
+ lastUsage.inputTokens += response.usage.inputTokens || 0;
923
+ lastUsage.outputTokens += response.usage.outputTokens || 0;
924
+ lastUsage.cachedTokens = (lastUsage.cachedTokens || 0) + (response.usage.cachedTokens || 0);
925
+ lastUsage.cacheWriteTokens = (lastUsage.cacheWriteTokens || 0) + (response.usage.cacheWriteTokens || 0);
926
+ lastUsage.promptTokens = (lastUsage.promptTokens || 0) + (response.usage.promptTokens || 0);
927
+ }
928
+ else {
929
+ lastUsage = {
930
+ inputTokens: response.usage.inputTokens || 0,
931
+ outputTokens: response.usage.outputTokens || 0,
932
+ cachedTokens: response.usage.cachedTokens || 0,
933
+ cacheWriteTokens: response.usage.cacheWriteTokens || 0,
934
+ promptTokens: response.usage.promptTokens || 0,
935
+ raw: response.usage.raw,
936
+ };
937
+ // Snapshot the first turn separately so callers can show
938
+ // iter1 vs final cache-hit ratios — first iter is the
939
+ // warm-prefix signal, final iter is the steady-state
940
+ // efficiency signal after tool-result accumulation.
941
+ firstTurnUsage = { ...lastUsage };
942
+ }
943
+ }
944
+ // Provider may have returned despite an abort (SDKs that don't honour
945
+ // signal) — bail before processing any of its output.
946
+ throwIfAborted();
947
+ // Incremental metric persistence (fix A): push per-iteration token delta
948
+ // immediately so watchdog / bridge type=list sees live totals mid-turn.
949
+ if (sessionId && opts.onUsageDelta && response.usage) {
950
+ try {
951
+ opts.onUsageDelta({
952
+ sessionId,
953
+ iterationIndex: iterations,
954
+ deltaInput: response.usage.inputTokens || 0,
955
+ deltaOutput: response.usage.outputTokens || 0,
956
+ // Cache delta carried alongside input/output so live metrics
957
+ // reflect the same token classes the terminal aggregate adds;
958
+ // additive — callers that ignore these fields keep working.
959
+ deltaCachedRead: response.usage.cachedTokens || 0,
960
+ deltaCacheWrite: response.usage.cacheWriteTokens || 0,
961
+ ts: Date.now(),
962
+ });
963
+ } catch { /* best-effort — never break the loop */ }
964
+ }
965
+ // No tool calls. For PUBLIC bridge workers, the bridge contract
966
+ // (rules/bridge/00-common.md) requires either a tool call or a
967
+ // `<final-answer>` wrapped reply.
968
+ // A text-only turn without those tags violates the contract (e.g.
969
+ // Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
970
+ // call) and used to leave the session idle until the idle sweep
971
+ // collected it. Re-prompt the worker with a contract reminder; cap
972
+ // at 2 nudges so a model that never complies still terminates the
973
+ // loop. Hidden roles (cycle1-agent / cycle2-agent / explorer /
974
+ // scheduler-task / webhook-handler) are exempt:
975
+ // their own role rules define a different output contract (pipe-
976
+ // separated chunker output, structured pipe-format, etc.) and a
977
+ // text-only terminal turn is the correct shape — nudging them
978
+ // produces a contradictory user message that traps the model in a
979
+ // tool-call-blocked vs contract-required oscillation.
980
+ if (!response.toolCalls?.length) {
981
+ // No tool calls. Decide between final-answer accept vs nudge.
982
+ // - has content + non-hidden role → valid final, break.
983
+ // - empty content + hidden role → contract allows text-only
984
+ // terminal turn, break.
985
+ // - empty content + non-hidden role → one soft nudge. Repeated
986
+ // reminders waste turns and fragment the working context, so
987
+ // the second empty turn is accepted as terminal.
988
+ const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
989
+ const isHidden = HIDDEN_ROLE_NAMES.has(sessionRole);
990
+ const stopReason = response.stopReason ?? response.stop_reason ?? null;
991
+ const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
992
+ if (!hasContent && !isHidden) {
993
+ if (contractNudges >= 1) break;
994
+ contractNudges += 1;
995
+ let nudgeMsg;
996
+ if (isIncompleteStop) {
997
+ nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit <final-answer>...</final-answer> with your synthesis so far, or call more tools to finish.`;
998
+ } else {
999
+ nudgeMsg = '[mixdog-runtime] Your previous response was empty (no <final-answer> tag and no tool call). Either emit your final answer wrapped in <final-answer>...</final-answer> tags, or continue with tool calls. Do not return an empty turn.';
1000
+ }
1001
+ messages.push({ role: 'user', content: nudgeMsg });
1002
+ continue;
1003
+ }
1004
+ break;
1005
+ }
1006
+ const calls = response.toolCalls;
1007
+ toolCallsTotal += calls.length;
1008
+ // Per-turn batch shape — one row per assistant turn so trace
1009
+ // consumers can derive multi-tool adoption ratio without scanning
1010
+ // every assistant message body.
1011
+ recordToolBatch(sessionId, calls.length);
1012
+ onToolCall?.(iterations, calls);
1013
+ // Append assistant message with tool calls. reasoningItems is the
1014
+ // OpenAI Responses API replay payload (encrypted_content blobs);
1015
+ // providers that ignore it just see an extra field and drop it,
1016
+ // openai-oauth.convertMessagesToResponsesInput emits matching
1017
+ // type:'reasoning' input items on the next turn to keep the Codex
1018
+ // server-side cache prefix stable.
1019
+ const _assistantTurnMsg = {
1020
+ role: 'assistant',
1021
+ content: response.content || '',
1022
+ toolCalls: compactToolCallsForHistory(calls),
1023
+ ...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
1024
+ ? { reasoningItems: response.reasoningItems }
1025
+ : {}),
1026
+ ...(typeof response.reasoningContent === 'string' && response.reasoningContent
1027
+ ? { reasoningContent: response.reasoningContent }
1028
+ : {}),
1029
+ };
1030
+ messages.push(_assistantTurnMsg);
1031
+ // Execute each tool and append results.
1032
+ //
1033
+ // Intra-turn duplicate suppression: when an LLM emits two tool_use
1034
+ // blocks with identical (name, args) inside the SAME assistant turn,
1035
+ // re-executing wastes tokens. Restricted to tools with
1036
+ // `readOnlyHint:true` (= isEagerDispatchable) — bash/write/edit/
1037
+ // apply_patch may be intentional repeats with distinct side effects.
1038
+ // Pre-pass identifies duplicates BEFORE startEagerRun so eager
1039
+ // dispatch also skips them, not just the for-body.
1040
+ const _duplicateCallIds = new Set();
1041
+ const _dupFirstId = new Map();
1042
+ {
1043
+ const _firstIdBySig = new Map();
1044
+ for (const c of calls) {
1045
+ if (!c?.id) continue;
1046
+ if (!isEagerDispatchable(c.name, tools)) {
1047
+ _firstIdBySig.clear();
1048
+ continue;
1049
+ }
1050
+ const sig = _intraTurnSig(c.name, c.arguments);
1051
+ const first = _firstIdBySig.get(sig);
1052
+ if (first === undefined) {
1053
+ _firstIdBySig.set(sig, c.id);
1054
+ } else {
1055
+ _duplicateCallIds.add(c.id);
1056
+ _dupFirstId.set(c.id, first);
1057
+ }
1058
+ }
1059
+ }
1060
+ // R15: per-turn scalar read-count Map. Lifetime = this turn's tool-call batch.
1061
+ // Declared between the duplicate-detection block and the for-loop so it resets
1062
+ for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
1063
+ const call = calls[callIndex];
1064
+ if (isBuiltinTool(call.name)) {
1065
+ call.name = canonicalizeBuiltinToolName(call.name);
1066
+ }
1067
+ if (_duplicateCallIds.has(call.id)) {
1068
+ const _firstId = _dupFirstId.get(call.id);
1069
+ const _stub = `[intra-turn-dedup] identical read-only \`${call.name}\` call was already executed in this same assistant turn as tool_use_id=${_firstId}. The first call's tool_result is in context immediately above; skipping re-execution to save tokens. If you needed a different slice of the file, narrow the next call (different path / offset / limit / pattern) so it has a distinct signature.`;
1070
+ messages.push({
1071
+ role: 'tool',
1072
+ content: _stub,
1073
+ toolCallId: call.id,
1074
+ });
1075
+ continue;
1076
+ }
1077
+ // Cross-iteration repeat-failure guard. Distinct from the
1078
+ // intra-turn dedup above (which spans ONE assistant turn and
1079
+ // resets every turn): when the model re-issues an IDENTICAL
1080
+ // (name,args) call that has already failed REPEAT_FAIL_LIMIT times
1081
+ // in a row across iterations, stop re-executing — the result will
1082
+ // not change, and each retry burns a full (often slow) LLM
1083
+ // round-trip until the hard iteration cap. Steer it to change
1084
+ // approach instead.
1085
+ const _repeatFailSig = _intraTurnSig(call.name, call.arguments);
1086
+ {
1087
+ const _rfg = sessionRef?._repeatFailGuard;
1088
+ if (_rfg && _rfg.sig === _repeatFailSig && _rfg.count >= REPEAT_FAIL_LIMIT) {
1089
+ messages.push({
1090
+ role: 'tool',
1091
+ content: `[repeat-failure-guard] This exact \`${call.name}\` call (identical arguments) has already failed ${_rfg.count} times in a row; not re-executing because the result will not change. Change approach: use different arguments, a different tool, or skip this step.`,
1092
+ toolCallId: call.id,
1093
+ });
1094
+ continue;
1095
+ }
1096
+ }
1097
+ if (sessionId) markSessionToolCall(sessionId, call.name);
1098
+ let result;
1099
+ let toolStartedAt;
1100
+ let toolEndedAt;
1101
+ const toolKind = getToolKind(call.name);
1102
+ // Cross-turn read dedup. Mirrors Anthropic Claude Code's
1103
+ // fileReadCache.ts: if the path's stat tuple (mtime/size/ino/dev)
1104
+ // is unchanged since a prior read in THIS session, return the cached
1105
+ // body instead of executing. Both scalar and array/object-array path
1106
+ // forms are cached — keyed by (abs, offset, limit, mode, n) per entry.
1107
+ //
1108
+ // Scoped-tool cache (grep/glob/list + graph lookups): same idea
1109
+ // but keyed by (toolName, canonical args) without per-file stat.
1110
+ // These tools scan many files so a single stat tuple cannot cover
1111
+ // them. The scoped cache registers dependency roots and write-class
1112
+ // tools evict entries whose root contains the touched path.
1113
+ let _readCacheHit = null;
1114
+ let _scopedCacheHit = null;
1115
+ let _executeOk = false;
1116
+ let _resultKind = 'normal';
1117
+ if (sessionId && _isReadTool(call.name)) {
1118
+ _readCacheHit = tryReadCached({ sessionId, args: call.arguments, cwd });
1119
+ } else if (sessionId && _isScopedCacheableTool(call.name)) {
1120
+ _scopedCacheHit = tryScopedToolCached({ sessionId, toolName: _stripMcpPrefix(call.name), args: call.arguments, cwd });
1121
+ }
1122
+ try {
1123
+ if (_readCacheHit !== null) {
1124
+ toolStartedAt = Date.now();
1125
+ toolEndedAt = toolStartedAt;
1126
+ const _body = _readCacheHit.content;
1127
+ // Return the cached body byte-for-byte instead of a
1128
+ // human-readable cache marker. The marker made public
1129
+ // bridge workers treat a successful cached read as a
1130
+ // meta instruction and repeat the same read loop.
1131
+ result = _body;
1132
+ _resultKind = 'cache-hit';
1133
+ _executeOk = true;
1134
+ } else if (_scopedCacheHit !== null) {
1135
+ toolStartedAt = Date.now();
1136
+ toolEndedAt = toolStartedAt;
1137
+ const _body = _scopedCacheHit.content;
1138
+ result = _body;
1139
+ _resultKind = 'scoped-cache-hit';
1140
+ _executeOk = true;
1141
+ } else {
1142
+ // Fallback for providers that don't stream tool calls early:
1143
+ // execute a contiguous read-only run in parallel, but never
1144
+ // cross a write/bash/MCP boundary that may change state.
1145
+ if (isEagerDispatchable(call.name, tools)) {
1146
+ startEagerRun(calls, callIndex, _duplicateCallIds);
1147
+ }
1148
+ let eager = pending.get(call.id);
1149
+ if (eager !== undefined && eager.mutationEpoch < _mutationEpoch) {
1150
+ pending.delete(call.id);
1151
+ eager = undefined;
1152
+ }
1153
+ if (eager !== undefined) {
1154
+ toolStartedAt = eager.startedAt;
1155
+ const settled = await eager.promise;
1156
+ if (!settled.ok) throw settled.error;
1157
+ result = settled.value;
1158
+ toolEndedAt = eager.endedAt ?? Date.now();
1159
+ const _eagerKind = classifyResultKind(result);
1160
+ if (_eagerKind === 'error') {
1161
+ _resultKind = 'error';
1162
+ _executeOk = false;
1163
+ } else {
1164
+ _executeOk = true;
1165
+ }
1166
+ } else {
1167
+ toolStartedAt = Date.now();
1168
+ // Runtime permission guard. Schema profiles may hide
1169
+ // tools for routing efficiency, but this remains the
1170
+ // safety boundary for any tool_use that still reaches
1171
+ // the loop. _preDispatchDeny is the SHARED helper used
1172
+ // by both the eager dispatch path (startEagerTool) and
1173
+ // this serial path — keeps the bridge-owned control-
1174
+ // plane reject, role guards, wrapper guards, and
1175
+ // permission guards consistent across both paths.
1176
+ const _denyMsg = _preDispatchDeny(call, toolKind, sessionRef);
1177
+ if (_denyMsg !== null) {
1178
+ result = _denyMsg;
1179
+ toolEndedAt = Date.now();
1180
+ _resultKind = 'error';
1181
+ } else {
1182
+ const permBlocked = _checkWorkerPermission(call.name, call.arguments, sessionRef);
1183
+ if (permBlocked !== null) {
1184
+ result = permBlocked;
1185
+ toolEndedAt = Date.now();
1186
+ _resultKind = 'error';
1187
+ } else {
1188
+ result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id });
1189
+ toolEndedAt = Date.now();
1190
+ // Boundary: tool-return string convention → structural kind.
1191
+ // The only prefix check in this codebase; downstream layers
1192
+ // operate on _resultKind.
1193
+ if (classifyResultKind(result) === 'error') {
1194
+ _resultKind = 'error';
1195
+ _executeOk = false;
1196
+ } else {
1197
+ _executeOk = true;
1198
+ }
1199
+ // _resultKind stays 'normal' when tool returned a non-error string.
1200
+ }
1201
+ }
1202
+ }
1203
+ } // close: else branch of _readCacheHit check
1204
+ }
1205
+ catch (err) {
1206
+ if (toolStartedAt === undefined) toolStartedAt = Date.now();
1207
+ toolEndedAt = Date.now();
1208
+ result = `Error: ${err instanceof Error ? err.message : String(err)}`;
1209
+ _resultKind = 'error';
1210
+ }
1211
+ // Update the cross-iteration repeat-failure guard with this call's
1212
+ // outcome: bump the consecutive-failure count for an identical
1213
+ // signature, or clear it the moment the same call succeeds.
1214
+ if (sessionRef) {
1215
+ const _failed = !_executeOk || _resultKind === 'error';
1216
+ if (_failed) {
1217
+ sessionRef._repeatFailGuard = (sessionRef._repeatFailGuard?.sig === _repeatFailSig)
1218
+ ? { sig: _repeatFailSig, count: sessionRef._repeatFailGuard.count + 1 }
1219
+ : { sig: _repeatFailSig, count: 1 };
1220
+ } else if (sessionRef._repeatFailGuard?.sig === _repeatFailSig) {
1221
+ sessionRef._repeatFailGuard = null;
1222
+ }
1223
+ }
1224
+ // A failed executed call keeps its FULL argument body in history so the
1225
+ // model can retry against the original (a large apply_patch `patch` /
1226
+ // edit `old_string` would otherwise be hidden behind a
1227
+ // `[mixdog compacted …]` placeholder). Restored IMMEDIATELY — not at end
1228
+ // of loop — so an abort or post-processing throw after this point cannot
1229
+ // leave a failed edit compacted. Cache-safe: _assistantTurnMsg is not
1230
+ // transmitted until the next provider.send. Early-continue paths (dedup /
1231
+ // repeat-failure-guard) never reach here and stay compacted.
1232
+ if ((!_executeOk || _resultKind === 'error') && call?.id) {
1233
+ restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
1234
+ }
1235
+ // Cross-turn cache maintenance — gate on both _executeOk and _resultKind==='normal'.
1236
+ // _executeOk=false catches permission-blocked / catch-path / partial-fail results.
1237
+ // _resultKind==='normal' ensures cache-hit refs are never re-stored (structural,
1238
+ // no prefix sniffing).
1239
+ // NOTE: setReadCached / setScopedToolCached are deferred below (after
1240
+ // compressToolResult) so the cache holds the same content as conversation
1241
+ // history. Cache-hit refs point to a tool_use_id whose message body matches
1242
+ // exactly what's stored — no phantom full body.
1243
+ if (sessionId && _executeOk && _resultKind === 'normal') {
1244
+ const _toolBare = _stripMcpPrefix(call.name);
1245
+ if (_readCacheHit === null && _isReadTool(call.name)) {
1246
+ // Post-edit advisory: handle BOTH scalar and array forms
1247
+ // of args.path. The array form (path:[a,b,c] or
1248
+ // path:[{path:a},{path:b}]) was a coverage gap in R1 —
1249
+ // an LLM that edits X then reads [X,Y] should still see
1250
+ // the advisory for X.
1251
+ const _argsPath = call.arguments?.path;
1252
+ const _pathList = [];
1253
+ if (typeof _argsPath === 'string') {
1254
+ _pathList.push(_argsPath);
1255
+ } else if (typeof call.arguments?.file_path === 'string') {
1256
+ _pathList.push(call.arguments.file_path);
1257
+ } else if (Array.isArray(_argsPath)) {
1258
+ for (const _item of _argsPath) {
1259
+ if (typeof _item === 'string') _pathList.push(_item);
1260
+ else if (_item && typeof _item === 'object') {
1261
+ const _itemPath = typeof _item.path === 'string' ? _item.path : _item.file_path;
1262
+ if (typeof _itemPath === 'string') _pathList.push(_itemPath);
1263
+ }
1264
+ }
1265
+ }
1266
+ const _marks = [];
1267
+ for (const _p of _pathList) {
1268
+ const _m = consumePostEditMark({ sessionId, path: _p, cwd });
1269
+ if (_m) _marks.push({ path: _p, mark: _m });
1270
+ }
1271
+ } else if (_toolBare === 'apply_patch') {
1272
+ // apply_patch's args are a unified-diff text in `patch`
1273
+ // (resolved against `base_path` or cwd). Parse the diff
1274
+ // headers (`--- a/path` / `+++ b/path`) to extract the
1275
+ // touched paths and invalidate / mark each one. Falls
1276
+ // back to a full session clear only when no paths could
1277
+ // be parsed (malformed diff or unknown format).
1278
+ const _argsBase = call.arguments?.base_path;
1279
+ const _patchBase = (typeof _argsBase === 'string' && _argsBase.length > 0)
1280
+ ? (isAbsolute(_argsBase) ? _argsBase : resolvePath(cwd || process.cwd(), _argsBase))
1281
+ : (cwd || process.cwd());
1282
+ const _touched = extractTouchedPathsFromPatch(call.arguments?.patch);
1283
+ if (_touched.length > 0) {
1284
+ for (const _p of _touched) {
1285
+ invalidatePathForSession(sessionId, _p, _patchBase);
1286
+ markPostEdit({ sessionId, path: _p, cwd: _patchBase, toolName: 'apply_patch' });
1287
+ // R20: cross-dispatch prefetch cache invalidation.
1288
+ invalidatePrefetchCache(_p, _patchBase);
1289
+ }
1290
+ } else {
1291
+ clearReadDedupSession(sessionId);
1292
+ // R20: path unknown — can't target; no-op on prefetch cache
1293
+ // (stat-validation at lookup time will naturally reject stale entries).
1294
+ }
1295
+ // Targeted scoped-cache invalidation: only evict entries whose
1296
+ // dep paths intersect the touched set. Full wipe is the fallback
1297
+ // when no paths were extracted (D).
1298
+ if (_touched.length > 0) {
1299
+ clearScopedToolsForSessionPaths(sessionId, _touched, _patchBase);
1300
+ } else {
1301
+ clearScopedToolsForSession(sessionId);
1302
+ }
1303
+ } else if (_isScalarWriteEditTool(call.name)) {
1304
+ // Scalar `args.path` only: precise invalidate + advisory mark.
1305
+ // Array-form (`edits[]`/`writes[]`): the tool may have partial-
1306
+ // failed across paths and the result string aggregates;
1307
+ // full-clear instead of falsely marking every path.
1308
+ const _scalarPath = call.arguments?.path || call.arguments?.file_path;
1309
+ const _hasArrayForm = Array.isArray(call.arguments?.edits)
1310
+ || Array.isArray(call.arguments?.writes);
1311
+ if (_hasArrayForm) {
1312
+ clearReadDedupSession(sessionId);
1313
+ clearScopedToolsForSession(sessionId);
1314
+ // R20: array-form — walk each entry, extract its path,
1315
+ // and invalidate the prefetch cache + mark post-edit for
1316
+ // every distinct touched path. Falls back to the top-
1317
+ // level `path` (or `file_path`) when an entry omits its
1318
+ // own path. This covers both edit edits[] and write
1319
+ // writes[] forms; entries without a resolvable path are
1320
+ // silently skipped (their stat-validation safety net at
1321
+ // next lookup still applies).
1322
+ const _topPath = call.arguments?.path || call.arguments?.file_path;
1323
+ const _entries = call.arguments?.edits || call.arguments?.writes || [];
1324
+ const _seenPaths = new Set();
1325
+ for (const _e of _entries) {
1326
+ const _ep = _e?.path || _e?.file_path || _topPath;
1327
+ if (typeof _ep === 'string' && _ep && !_seenPaths.has(_ep)) {
1328
+ _seenPaths.add(_ep);
1329
+ invalidatePathForSession(sessionId, _ep, cwd);
1330
+ markPostEdit({ sessionId, path: _ep, cwd, toolName: _toolBare });
1331
+ invalidatePrefetchCache(_ep, cwd);
1332
+ }
1333
+ }
1334
+ if (_seenPaths.size > 0) {
1335
+ clearScopedToolsForSessionPaths(sessionId, [..._seenPaths], cwd);
1336
+ }
1337
+ } else if (typeof _scalarPath === 'string') {
1338
+ invalidatePathForSession(sessionId, _scalarPath, cwd);
1339
+ markPostEdit({ sessionId, path: _scalarPath, cwd, toolName: _toolBare });
1340
+ // R20: cross-dispatch prefetch cache invalidation.
1341
+ invalidatePrefetchCache(_scalarPath, cwd);
1342
+ // Targeted scoped-cache invalidation for the single touched path (D).
1343
+ clearScopedToolsForSessionPaths(sessionId, [_scalarPath], cwd);
1344
+ } else {
1345
+ // No path extractable — full wipe fallback.
1346
+ clearScopedToolsForSession(sessionId);
1347
+ }
1348
+ }
1349
+ } // end _executeOk+_resultKind gate (scoped tool cache set)
1350
+ // E: mutation tools (apply_patch / write / edit) must invalidate caches
1351
+ // even on returned-error/partial-fail — the file state is unknown after
1352
+ // an error exit, and some tools report failure as an Error: result string
1353
+ // rather than throwing.
1354
+ // This block runs unconditionally (not gated on _executeOk or _resultKind).
1355
+ if (sessionId && (!_executeOk || _resultKind === 'error') && (_stripMcpPrefix(call.name) === 'apply_patch' || _isScalarWriteEditTool(call.name))) {
1356
+ clearReadDedupSession(sessionId);
1357
+ }
1358
+ if (_isMutationTool(call.name)) {
1359
+ _mutationEpoch += 1;
1360
+ }
1361
+ // Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
1362
+ // that throws or fails partway can still leave stale find_symbol / grep entries.
1363
+ // Must not be gated on _executeOk or _resultKind.
1364
+ if (sessionId && _isBashTool(call.name)) {
1365
+ clearScopedToolsForSession(sessionId);
1366
+ }
1367
+ // R17 compression pipeline — correct ordering (compress → cache → push):
1368
+ // 1. compressToolResult: lossless ANSI/dedup/separator passes.
1369
+ // 2. setReadCached / setScopedToolCached: cache stores the SAME result that
1370
+ // goes into conversation history. Cache-hit refs point to the tool_use_id
1371
+ // whose message body matches — no phantom full body.
1372
+ // 3. offload → hint → message push.
1373
+ // Offload FIRST — before compress. Large RAW output goes to a disk sidecar
1374
+ // + ~2K preview before any in-place shrink (lossless compress) can reduce
1375
+ // it below the offload threshold and pre-empt the sidecar. When offload
1376
+ // fires it replaces `result` with a short preview stub (<2K) referencing
1377
+ // the on-disk path; the later compress is a no-op on that stub. compress
1378
+ // then only touches output that stayed inline (<= threshold).
1379
+ // Per-tool post-processing backstop. The executeTool try/catch
1380
+ // above terminates BEFORE offload/compress/trim/hint/cache writes/
1381
+ // trace/messages.push, so a maybeOffloadToolResult rejection (or
1382
+ // any downstream throw) would otherwise leave the assistant
1383
+ // tool_use message with no matching tool result. Wrap the whole
1384
+ // post-processing window through messages.push() in a catch; on
1385
+ // failure push a synthetic Error: tool result for this call.id
1386
+ // and skip the cache writes for it.
1387
+ let _postProcessOk = true;
1388
+ try {
1389
+ // Offload thresholds are keyed by BARE tool name
1390
+ // (INLINE_THRESHOLD_BY_TOOL: grep=20k, bash=30k, read=Infinity, ...),
1391
+ // so strip the MCP prefix exactly as the cache write below does.
1392
+ // Otherwise an mcp__..__grep name misses its 20k grep cap and
1393
+ // silently falls back to the 50k default — per-tool limits ignored.
1394
+ const _toolBare = _stripMcpPrefix(call.name);
1395
+ result = await maybeOffloadToolResult(sessionId, call.id, _toolBare, result);
1396
+ result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
1397
+ traceBridgeTool({
1398
+ sessionId,
1399
+ iteration: iterations,
1400
+ toolName: call.name,
1401
+ toolKind,
1402
+ toolMs: toolEndedAt - toolStartedAt,
1403
+ toolArgs: call.arguments,
1404
+ role: sessionRef?.role || null,
1405
+ model: sessionRef?.model || null,
1406
+ resultKind: _resultKind,
1407
+ resultText: result,
1408
+ });
1409
+ // Cache stores run AFTER compress+trim+offload+hint AND after all other
1410
+ // post-processing (trace) so stored content == history content. Placing
1411
+ // the cache writes immediately before messages.push ensures ANY throw
1412
+ // earlier in post-processing skips the cache entirely — no stale or
1413
+ // partial result is ever cached. Cache-hit refs pointing to an offloaded
1414
+ // tool_use will show the offload stub; LLM can still recover the full
1415
+ // body via the disk path in that stub.
1416
+ if (sessionId && _executeOk && _resultKind === 'normal') {
1417
+ if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
1418
+ const _outcome = sessionRef?._scopedCacheOutcomeByCallId?.get(call.id);
1419
+ setScopedToolCached({
1420
+ sessionId,
1421
+ toolName: _toolBare,
1422
+ args: call.arguments,
1423
+ cwd,
1424
+ content: result,
1425
+ toolUseId: call.id,
1426
+ complete: _outcome ? _outcome.complete : true,
1427
+ });
1428
+ sessionRef?._scopedCacheOutcomeByCallId?.delete(call.id);
1429
+ }
1430
+ if (_readCacheHit === null && _isReadTool(call.name)) {
1431
+ // Pass tool_use id so future cache-hits can reference the body's location in history.
1432
+ setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
1433
+ }
1434
+ }
1435
+ messages.push({
1436
+ role: 'tool',
1437
+ content: result,
1438
+ toolCallId: call.id,
1439
+ toolKind: _resultKind,
1440
+ });
1441
+ } catch (postErr) {
1442
+ _postProcessOk = false;
1443
+ // Post-processing failed AFTER a successful exec: the result is
1444
+ // replaced with an error below, so preserve this call's full body
1445
+ // too for a clean retry (mirrors the failed-exec path above).
1446
+ if (call?.id) restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
1447
+ const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
1448
+ // Always emit a matching tool result so the assistant
1449
+ // tool_use isn't orphaned. Cache writes are placed at the
1450
+ // end of the try block (immediately before messages.push),
1451
+ // so ANY throw in post-processing reaches this catch before
1452
+ // the cache is written — stale/partial results are never
1453
+ // cached. The next read on the same path/scope re-executes
1454
+ // naturally.
1455
+ messages.push({
1456
+ role: 'tool',
1457
+ content: _postMsg,
1458
+ toolCallId: call.id,
1459
+ toolKind: 'error',
1460
+ });
1461
+ }
1462
+ // Soft-cancel after each tool: if close landed during execution,
1463
+ // discard the rest of the batch and skip the next provider.send.
1464
+ throwIfAborted();
1465
+ }
1466
+ // About to re-send with tool results — transition back to connecting for the next turn.
1467
+ if (sessionId) updateSessionStage(sessionId, 'connecting');
1468
+ }
1469
+ return {
1470
+ ...response,
1471
+ usage: lastUsage || response.usage,
1472
+ lastTurnUsage: response.usage,
1473
+ firstTurnUsage: firstTurnUsage || response.usage,
1474
+ iterations,
1475
+ toolCallsTotal,
1476
+ providerState,
1477
+ };
1478
+ }