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,1010 @@
1
+ /**
2
+ * ai-wrapped-dispatch — dispatch hub for `recall` / `search` / `explore`.
3
+ *
4
+ * All three MCP tools flagged `aiWrapped: true` in tools.json route here
5
+ * instead of the direct module handler. Each query spawns its own Pool C
6
+ * agent session and runs concurrently via Promise.allSettled, so wall-clock
7
+ * latency is bound by the slowest query rather than the sum. A single query
8
+ * spawns a single agent, so the per-array cost scales linearly with query
9
+ * count. Shared Pool B/C cache shards mean only the first concurrent agent
10
+ * pays the cold-write; peers ride the warm prefix.
11
+ *
12
+ * Dispatch completion pushes into the caller's session via the existing
13
+ * `notifications/claude/channel` bridge. The notify meta carries
14
+ * `type: 'dispatch_result'` plus an `instruction` string so the Lead
15
+ * integrates the answer on its next turn automatically.
16
+ */
17
+
18
+ import { homedir } from 'os'
19
+ import { resolve as resolvePath, isAbsolute, join, relative, dirname } from 'path'
20
+ import { createHash } from 'crypto'
21
+ import { existsSync, mkdirSync, readFileSync, statSync, readdirSync, unlinkSync, writeFileSync } from 'fs'
22
+ import { loadConfig, getPluginData } from './config.mjs'
23
+ import { fileURLToPath } from 'url'
24
+ import { getHiddenRole } from './internal-roles.mjs'
25
+ import { writeJsonAtomicSync } from '../../shared/atomic-file.mjs'
26
+ import { errText } from '../../shared/err-text.mjs'
27
+ import { resolvePresetName } from './smart-bridge/bridge-llm.mjs'
28
+ import { smartReadTruncate } from './tools/builtin.mjs'
29
+ import { executeBuiltinTool } from './tools/builtin.mjs'
30
+ import { addPending, removePending, setPendingResult } from './dispatch-persist.mjs'
31
+ import { notifyActivity } from './activity-bus.mjs'
32
+ import { stripSoftWarns } from './tool-loop-guard.mjs'
33
+ import { stripAnsi, normalizeWhitespace, dedupRepeatedLines } from './tools/result-compression.mjs'
34
+ import {
35
+ EXPLORE_OUTPUT_CHAR_CAP,
36
+ EXPLORE_PER_PIECE_CHAR_CAP,
37
+ EXPLORE_TRUNCATION_MARKER,
38
+ } from './explore-validator.mjs'
39
+ import { classifyResultKind } from './session/result-classification.mjs'
40
+ import { isUncPath } from './tools/builtin/device-paths.mjs'
41
+
42
+ // Plugin version — read once at module load from package.json so per-call cost
43
+ // is zero. Included in the query-cache key so cached results are invalidated
44
+ // when the plugin version changes (new prompt templates, tool definitions, etc.).
45
+ // Intentionally retained: query-result disk cache was removed but this constant
46
+ // stays so a future cache re-enable keeps stable invalidation semantics.
47
+ const _PLUGIN_VERSION = (() => {
48
+ try {
49
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json')
50
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
51
+ return String(pkg.version || 'unknown')
52
+ } catch (err) {
53
+ throw new Error(`[ai-wrapped-dispatch] package.json read failed: ${err.message}`)
54
+ }
55
+ })()
56
+
57
+ // Fan-out deadline — documented runtime envelope.
58
+ // Default 240 s; override via env FANOUT_DEADLINE_S. 240 s balances
59
+ // the slowest bridge role latency against session responsiveness.
60
+ // Applied to both sync and background fan-out paths. After expiry, settled
61
+ // subs are merged as partial; pending subs are aborted.
62
+ const _FANOUT_DEADLINE_MS = (() => {
63
+ const v = parseInt(process.env.FANOUT_DEADLINE_S, 10)
64
+ return Number.isFinite(v) && v > 0 ? v * 1000 : 240_000
65
+ })()
66
+
67
+ // Hard errors that should trigger sibling abort + partial-error escalation.
68
+ // SessionClosedError is excluded — it means the parent itself aborted, not
69
+ // a sub failure.
70
+ function isHardSubError(reason) {
71
+ if (!reason) return false
72
+ if (reason?.name === 'SessionClosedError') return false
73
+ return true
74
+ }
75
+
76
+ function _roleNameForTool(tool) {
77
+ const def = getHiddenRole('explorer')
78
+ if (def && def.invokedBy === tool) return 'explorer'
79
+ return null
80
+ }
81
+
82
+ const ROLE_BY_TOOL = Object.freeze({
83
+ explore: { role: _roleNameForTool('explore'), build: buildExplorerPrompt, label: _roleNameForTool('explore') || 'explorer' },
84
+ })
85
+
86
+ // Clamp a raw subagent body (or error string) to the per-piece cap
87
+ // BEFORE it gets wrapped with header / separator. Returns the (possibly
88
+ // truncated) string; truncation reuses the existing marker so callers
89
+ // see a consistent signal.
90
+ function clampPiece(raw) {
91
+ if (typeof raw !== 'string') return raw
92
+ if (raw.length <= EXPLORE_PER_PIECE_CHAR_CAP) return raw
93
+ return raw.slice(0, EXPLORE_PER_PIECE_CHAR_CAP) + EXPLORE_TRUNCATION_MARKER
94
+ }
95
+
96
+ // Build a merged answer with a hard cumulative-size cap. Mirrors the
97
+ // per-mode shape used by the regular merge path (single query returns
98
+ // the raw answer; multi-query prepends `### Query N:` headers and joins
99
+ // with `---`) but stops appending once the running total crosses the
100
+ // cap, then emits a single inline marker. Each piece is also pre-clamped
101
+ // to EXPLORE_PER_PIECE_CHAR_CAP so a single oversized response can't
102
+ // blow up before the running-total check fires.
103
+ // partialInfo: { completed, total, deadlineSecs } | null — appends footer when set.
104
+ function mergeExploreSettled(settled, queries, label, partialInfo) {
105
+ const isSingle = queries.length === 1
106
+ if (isSingle) {
107
+ const r = settled[0]
108
+ const raw = r.status === 'fulfilled'
109
+ ? (r.value || '(no response)')
110
+ : `[${label} error] ${errText(r.reason)}`
111
+ // Single-query path: per-piece cap == cumulative cap effectively, but
112
+ // still pre-clamp to keep the post-clamp slice bounded and cheap.
113
+ const clamped = clampPiece(raw)
114
+ if (typeof clamped === 'string' && clamped.length > EXPLORE_OUTPUT_CHAR_CAP) {
115
+ return clamped.slice(0, EXPLORE_OUTPUT_CHAR_CAP) + EXPLORE_TRUNCATION_MARKER
116
+ }
117
+ return _appendPartialFooter(clamped, partialInfo)
118
+ }
119
+ const parts = []
120
+ let total = 0
121
+ let truncated = false
122
+ let truncatedAtPiece = -1
123
+ const sep = '\n\n'
124
+ for (let i = 0; i < settled.length; i++) {
125
+ const r = settled[i]
126
+ const header = `## Q${i + 1}: ${String(queries[i] ?? '').replace(/\s+/g, ' ').slice(0, 60)}`
127
+ const rawBody = r.status === 'fulfilled'
128
+ ? (r.value || '(no response)')
129
+ : `[${label} error] ${errText(r.reason)}`
130
+ // Pre-clamp the body BEFORE template construction so a 400MB rogue
131
+ // response can't allocate a 400MB+ piece string just to be discarded.
132
+ const body = clampPiece(rawBody)
133
+ const piece = `${header}\n${body}`
134
+ const addLen = (parts.length === 0 ? 0 : sep.length) + piece.length
135
+ // Running-total guard: stop appending once the next piece would push
136
+ // us past the cumulative cap. Truncate the trailing piece to the
137
+ // remaining budget so we still emit something for the boundary query.
138
+ if (total + addLen > EXPLORE_OUTPUT_CHAR_CAP) {
139
+ const remaining = EXPLORE_OUTPUT_CHAR_CAP - total - (parts.length === 0 ? 0 : sep.length)
140
+ if (remaining > 0) {
141
+ parts.push(piece.slice(0, remaining))
142
+ total += (parts.length === 1 ? 0 : sep.length) + remaining
143
+ }
144
+ truncated = true
145
+ truncatedAtPiece = i + 1
146
+ break
147
+ }
148
+ parts.push(piece)
149
+ total += addLen
150
+ }
151
+ const merged = parts.join(sep)
152
+ if (!truncated) return _appendPartialFooter(merged, partialInfo)
153
+ const note = truncatedAtPiece > 0
154
+ ? `\n\n[explore: merge truncated at piece ${truncatedAtPiece}/${settled.length}]`
155
+ : ''
156
+ return _appendPartialFooter(merged + EXPLORE_TRUNCATION_MARKER + note, partialInfo)
157
+ }
158
+
159
+ // Append "(M/N ok, dl=Xs)" footer when partialInfo is truthy.
160
+ function _appendPartialFooter(text, partialInfo) {
161
+ if (!partialInfo) return text
162
+ const { completed, total, deadlineSecs } = partialInfo
163
+ return `${text}\n\n(${completed}/${total} ok, dl=${deadlineSecs}s)`
164
+ }
165
+
166
+ // Preflight: reject explore cwd values that are structurally unbounded
167
+ // roots — fs root, a bare drive root, the home dir, or ~/.claude. This is
168
+ // a path-only gate by design: those roots are never a valid explore scope,
169
+ // and on Windows a probe walk under `path:'/'` returns a misleading 0-entry
170
+ // count, so a count-based check cannot catch them reliably — only the
171
+ // structural path check does.
172
+ //
173
+ // Returns a non-empty error string when rejected, '' when acceptable.
174
+ // Callers MUST abort with an MCP error when non-empty.
175
+ //
176
+ async function checkBroadCwdBlock(resolvedCwd, rawCwdInput) {
177
+ const display = (typeof rawCwdInput === 'string' && rawCwdInput.trim())
178
+ ? rawCwdInput.trim()
179
+ : (resolvedCwd || '')
180
+ if (!resolvedCwd) return ''
181
+ // Hard-block system roots and home directory: their glob is unbounded in
182
+ // practice (50k+ entries) and a probe walk under `path:'/'` on Windows
183
+ // can return a misleading 0-entry count when the glob handler cannot
184
+ // resolve the root, letting the spawn proceed. The invariant is
185
+ // structural: these paths are never a valid explore scope.
186
+ const norm = String(resolvedCwd).replace(/\\/g, '/').replace(/\/+$/, '')
187
+ const isUnixRoot = norm === ''
188
+ const isDriveRoot = /^[A-Za-z]:$/.test(norm)
189
+ const home = homedir().replace(/\\/g, '/').replace(/\/+$/, '')
190
+ const isHomeRoot = norm.length > 0 && norm === home
191
+ const claudeDir = home + '/.claude'
192
+ const isClaudeDir = norm === claudeDir
193
+ if (isUnixRoot || isDriveRoot || isHomeRoot || isClaudeDir) {
194
+ return `Error: explore root too broad: "${display}" is a system root or home directory. Narrow to a specific project subdirectory.`
195
+ }
196
+ return ''
197
+ }
198
+
199
+ // Background dispatch registry. Entries live in-memory for the plugin server
200
+ // process lifetime — the merged answer is auto-pushed via the channel,
201
+ // and the registry is kept around for observability only. Pruned
202
+ // opportunistically to keep the map bounded.
203
+ const _dispatchResults = new Map() // id → { status, role, tool, queries, createdAt, completedAt?, content?, error? }
204
+ const DISPATCH_RESULT_MAX_ENTRIES = 200
205
+ const DISPATCH_RESULT_TTL_MS = 30 * 60_000 // 30 minutes — enough for the Lead to loop back, short enough to not hoard memory
206
+ // R15: hard caps to bound fan-out + background concurrency. Without these,
207
+ // model/prompt-injection abuse can spawn an unbounded number of hidden-role
208
+ // sub-sessions (one per query) or pile up background dispatches faster than
209
+ // they complete, exhausting the plugin server's memory/file-handle budget.
210
+ const MAX_FANOUT_QUERIES = 12
211
+ const MAX_ACTIVE_BG_DISPATCHES = 8
212
+ let _activeBgDispatches = 0
213
+ // (explore query-result cache + disk persistence + inflight coalescing
214
+ // removed — each sub-query now fans out uncached, matching native Explore)
215
+
216
+
217
+
218
+
219
+ /* explore cache key builder removed
220
+ .update(siblingList.map((s) => normalizeQueryForCache(s)).sort().join('\u0001'))
221
+ removed */
222
+
223
+
224
+
225
+
226
+
227
+
228
+ /* explore inflight coalescing removed
229
+ removed */
230
+
231
+ /* explore inflight join/settle removed
232
+ removed */
233
+
234
+ // Shared fan-out controller/deadline/merge/error pipeline used by both
235
+ // sync (in-turn merged answer) and background (handle-then-push) paths.
236
+ // The two paths agree on every observable: parent-abort cascade, sub
237
+ // controllers, hard-error escalation + sibling abort, deadline race, and
238
+ // settled-rebuild. Per-path divergence is parameterized:
239
+ // - `isBackground` controls the deadline-rebuild shape: sync races the
240
+ // live promise against the immediate-reject ("late winner wins"),
241
+ // background uses the immediate-reject only (matches the original
242
+ // bg form byte-for-byte).
243
+ // - `onHardError()` fires once when the first hard sub-error escalates,
244
+ // letting the bg caller mutate the dispatch-registry entry to
245
+ // 'partial-error' while the sync caller skips the mutation.
246
+ // Sub-queries run uncached: each fans out to its own bridge role with no
247
+ // result-cache or inflight-coalescing layer (matches native Explore).
248
+ async function _runFanout({ queries, name, resolvedCwd, brief, spec, ctx, makeBridgeLlm, bridgeConfig, isBackground, onHardError }) {
249
+ // Parent abort → sub controllers link. Resolve parent signal first so an
250
+ // already-aborted parent cascades into freshly-created sub controllers in
251
+ // the same synchronous frame.
252
+ let parentSig = null
253
+ try {
254
+ if (ctx?.callerSessionId) {
255
+ const { getAbortSignalForSession } = await import('./session/abort-lookup.mjs')
256
+ parentSig = await getAbortSignalForSession(ctx.callerSessionId)
257
+ }
258
+ } catch (e) { try { process.stderr.write(`[ai-wrapped-dispatch] swallow: ${e?.message ?? e}\n`); } catch {} }
259
+
260
+ const subControllers = queries.map(() => { try { return new AbortController() } catch { return null } })
261
+
262
+ let _parentAbortHandler = null
263
+ if (parentSig) {
264
+ if (parentSig.aborted) {
265
+ subControllers.forEach(ac => { try { ac?.abort() } catch {} })
266
+ } else {
267
+ _parentAbortHandler = () => {
268
+ subControllers.forEach(ac => { try { ac?.abort() } catch {} })
269
+ }
270
+ parentSig.addEventListener('abort', _parentAbortHandler, { once: true })
271
+ }
272
+ }
273
+
274
+ // Per-request cancellation (ESC / MCP `notifications/cancelled`) → abort subs.
275
+ // ctx.requestSignal is the forwarded extra.signal from the CallTool handler.
276
+ // Unlike parentSig (session-level), it fires when the user cancels THIS
277
+ // specific tool request. Wiring it into the sub controllers makes ESC
278
+ // actually stop the explorer fan-out instead of letting it run to
279
+ // completion. Background path: the MCP request completes on handle return,
280
+ // so this signal never aborts there — harmless no-op.
281
+ const requestSig = ctx?.requestSignal || null
282
+ let _requestAbortHandler = null
283
+ if (requestSig && requestSig !== parentSig) {
284
+ if (requestSig.aborted) {
285
+ subControllers.forEach(ac => { try { ac?.abort() } catch {} })
286
+ } else {
287
+ _requestAbortHandler = () => {
288
+ subControllers.forEach(ac => { try { ac?.abort() } catch {} })
289
+ }
290
+ requestSig.addEventListener('abort', _requestAbortHandler, { once: true })
291
+ }
292
+ }
293
+
294
+ let hardErrorEscalated = false
295
+ const escapedSettled = []
296
+
297
+ const promises = queries.map((q, i) => {
298
+ const subSignal = subControllers[i]?.signal ?? null
299
+ // No result cache / inflight coalescing: each sub-query runs its own
300
+ // bridge role directly, driven by this sub-query's abort controller.
301
+ const p = (async () => {
302
+ const llm = makeBridgeLlm({
303
+ role: spec.role,
304
+ cwd: resolvedCwd,
305
+ brief,
306
+ parentSessionId: ctx?.callerSessionId || null,
307
+ clientHostPid: ctx?.clientHostPid,
308
+ parentSignal: subSignal,
309
+ config: bridgeConfig ?? null,
310
+ })
311
+ return llm({ prompt: spec.build(q, resolvedCwd) })
312
+ })()
313
+ p.then(
314
+ (val) => { escapedSettled[i] = { status: 'fulfilled', value: val } },
315
+ (err) => {
316
+ escapedSettled[i] = { status: 'rejected', reason: err }
317
+ if (!hardErrorEscalated && isHardSubError(err)) {
318
+ hardErrorEscalated = true
319
+ subControllers.forEach((ac, j) => { if (j !== i) try { ac?.abort() } catch {} })
320
+ if (typeof onHardError === 'function') onHardError()
321
+ }
322
+ },
323
+ )
324
+ return p
325
+ })
326
+
327
+ // Deadline timer — race against all subs.
328
+ let deadlineTimer = null
329
+ let deadlineFired = false
330
+ const deadlineMs = _FANOUT_DEADLINE_MS
331
+ const deadlinePromise = new Promise((resolve) => {
332
+ deadlineTimer = setTimeout(() => {
333
+ deadlineFired = true
334
+ subControllers.forEach(ac => { try { ac?.abort() } catch {} })
335
+ resolve('__deadline__')
336
+ }, deadlineMs)
337
+ if (typeof deadlineTimer?.unref === 'function') deadlineTimer.unref()
338
+ })
339
+
340
+ await Promise.race([Promise.allSettled(promises), deadlinePromise])
341
+ clearTimeout(deadlineTimer)
342
+
343
+ // Rebuild settled from what resolved so far. Background path uses the
344
+ // immediate-reject form; sync path races the live promise against the
345
+ // immediate-reject so a late winner can still land.
346
+ const settled = await Promise.allSettled(
347
+ promises.map((p, i) => {
348
+ if (escapedSettled[i] !== undefined) {
349
+ return escapedSettled[i].status === 'fulfilled'
350
+ ? Promise.resolve(escapedSettled[i].value)
351
+ : Promise.reject(escapedSettled[i].reason)
352
+ }
353
+ const timeoutP = Promise.resolve(undefined).then(() => Promise.reject(new Error('bridge role timed out (deadline)')))
354
+ return isBackground ? timeoutP : Promise.race([p, timeoutP])
355
+ }),
356
+ )
357
+
358
+ // Only genuinely fulfilled results count as 'ok' in the partial
359
+ // footer. Any rejection — timeout or otherwise — is not ok, since
360
+ // _appendPartialFooter renders completedCount as the "ok" tally.
361
+ const completedCount = settled.filter(r => r.status === 'fulfilled').length
362
+ const partialInfo = deadlineFired
363
+ ? { completed: completedCount, total: queries.length, deadlineSecs: Math.round(deadlineMs / 1000) }
364
+ : null
365
+
366
+ if (parentSig && _parentAbortHandler) {
367
+ try { parentSig.removeEventListener('abort', _parentAbortHandler) } catch {}
368
+ }
369
+ if (requestSig && _requestAbortHandler) {
370
+ try { requestSig.removeEventListener('abort', _requestAbortHandler) } catch {}
371
+ }
372
+
373
+ return { settled, partialInfo, hardErrorEscalated }
374
+ }
375
+
376
+ function _pruneDispatchResults() {
377
+ if (_dispatchResults.size < DISPATCH_RESULT_MAX_ENTRIES) return
378
+ const now = Date.now()
379
+ for (const [id, entry] of _dispatchResults) {
380
+ const age = now - (entry.completedAt || entry.createdAt || now)
381
+ if (entry.status !== 'running' && age > DISPATCH_RESULT_TTL_MS) _dispatchResults.delete(id)
382
+ }
383
+ if (_dispatchResults.size >= DISPATCH_RESULT_MAX_ENTRIES) {
384
+ // Still full — evict the oldest regardless of status.
385
+ const oldest = _dispatchResults.keys().next().value
386
+ if (oldest) _dispatchResults.delete(oldest)
387
+ }
388
+ }
389
+
390
+ export async function dispatchAiWrapped(name, args, ctx) {
391
+ let rawQuery = args.query
392
+ // MCP schema-less query field: some clients JSON-stringify arrays when the
393
+ // inputSchema does not declare an explicit `type`. Parse `'["a","b"]'` back
394
+ // into a real array so fan-out works whether the caller passed an array
395
+ // literal or a stringified one. Plain strings (no leading `[`) pass through.
396
+ if (typeof rawQuery === 'string') {
397
+ const trimmed = rawQuery.trim()
398
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
399
+ try {
400
+ const parsed = JSON.parse(trimmed)
401
+ if (Array.isArray(parsed)) rawQuery = parsed
402
+ } catch { /* not valid JSON array — keep as plain string */ }
403
+ }
404
+ }
405
+ if (rawQuery == null) return fail('query is required')
406
+ let queries = Array.isArray(rawQuery) ? rawQuery.slice() : [rawQuery]
407
+ if (queries.length === 0) return fail('query cannot be empty')
408
+ // R15: bound LLM-dispatch fan-out at the call boundary. Truncating here
409
+ // (before either sync or background path forks) guarantees one call cannot
410
+ // spawn more than MAX_FANOUT_QUERIES hidden-role sub-sessions via a
411
+ // hostile/poisoned `query:[...]`. The capped count is surfaced to the
412
+ // caller via the merged-answer notice below.
413
+ let _fanoutCapNotice = ''
414
+ if (queries.length > MAX_FANOUT_QUERIES) {
415
+ _fanoutCapNotice = `[capped ${queries.length}->${MAX_FANOUT_QUERIES} queries]`
416
+ queries = queries.slice(0, MAX_FANOUT_QUERIES)
417
+ }
418
+
419
+ const spec = ROLE_BY_TOOL[name]
420
+ if (!spec) throw new Error(`Unknown aiWrapped tool: ${name}`)
421
+
422
+ // recall / search are handled directly in server-main.mjs
423
+ // _dispatchToolImpl — they no longer reach this dispatcher at all.
424
+ // Only `explore` (LLM-routed hidden role) flows through here.
425
+
426
+ // Recursion break — the tool schema stays full across every session so
427
+ // that all roles share one cache shard. The counterweight lives here:
428
+ // when a hidden-role session (explorer / cycle1 / cycle2) calls back
429
+ // into an aiWrapped dispatcher, we reject the call at runtime. Without
430
+ // this, `explore` inside an explorer turn would spawn another explorer
431
+ // session and fan out forever.
432
+ if (ctx?.callerSessionId) {
433
+ try {
434
+ const { loadSession } = await import('./session/store.mjs')
435
+ const { isHiddenRole } = await import('./internal-roles.mjs')
436
+ const caller = loadSession(ctx.callerSessionId)
437
+ if (!caller) {
438
+ return fail(
439
+ `"${name}" blocked: caller session "${ctx.callerSessionId}" not found — recursion guard fails closed.`,
440
+ )
441
+ }
442
+ if (isHiddenRole(caller.role)) {
443
+ return fail(
444
+ `"${name}" is blocked inside the "${caller.role}" hidden role (recursion break). `
445
+ + `Use direct read / code_graph (mode:search for a symbol name) / grep / glob for your query.`,
446
+ )
447
+ }
448
+ } catch (e) {
449
+ return fail(
450
+ `"${name}" blocked: recursion guard introspection failed (${e?.message || e}). Fail-closed for safety.`,
451
+ )
452
+ }
453
+ }
454
+
455
+ const { makeBridgeLlm } = await import('./smart-bridge/bridge-llm.mjs')
456
+ const bridgeConfig = loadConfig()
457
+
458
+ // `brief` (default true) applies a ~3000-token cap to each bridge role
459
+ // answer before it rides back into the Lead context. Pass `brief:false`
460
+ // when the caller explicitly wants the uncapped synthesis. See
461
+ // bridge-llm.mjs::applyBriefCap for the cap shape.
462
+ const brief = args.brief !== false;
463
+ const hasExplicitCwdArg = typeof args.cwd === 'string' && args.cwd.trim()
464
+ const cwdInput = hasExplicitCwdArg
465
+ ? args.cwd
466
+ : ctx?.callerCwd
467
+ // Only `explore` reaches this dispatcher (ROLE_BY_TOOL registers it alone;
468
+ // any other tool name throws at the spec lookup above).
469
+ let resolvedCwd
470
+ try {
471
+ resolvedCwd = resolveExploreCwd(cwdInput, ctx?.callerCwd, Boolean(hasExplicitCwdArg))
472
+ } catch (e) {
473
+ // Fail-loud: explicit cwd resolved outside callerCwd and does not exist
474
+ // (no silent rebase to callerCwd). The sole call site can surface the
475
+ // mistake to Lead directly instead of running explore against the
476
+ // wrong tree.
477
+ return fail(errText(e))
478
+ }
479
+
480
+ // Hard-block broad cwds for explore before spawning any bridge roles.
481
+ // V8 string-limit risk: scanning home / ~/.claude / fs-root can blow the
482
+ // mcp server process. Fail fast here so neither the sync nor background
483
+ // path ever launches a bridge role against a dangerous root.
484
+ const _earlyBroadErr = await checkBroadCwdBlock(resolvedCwd, hasExplicitCwdArg ? args.cwd : '')
485
+ if (_earlyBroadErr) return fail(_earlyBroadErr)
486
+
487
+ // Sync by default — the merged bridge role answer lands in-turn as the MCP
488
+ // tool response, no channel round-trip, no turn fragmentation. Opt into
489
+ // background=true for heavy multi-angle queries that risk exceeding the
490
+ // 120s harness-owned MCP request ceiling (the harness severs the request at
491
+ // that fixed limit); in that case a handle is returned immediately and the
492
+ // merged answer is pushed via the channel bridge when ready.
493
+ const background = typeof args.background === 'boolean'
494
+ ? args.background
495
+ : false
496
+
497
+ if (!background) {
498
+ // Sync fan-out: shared controller/deadline/cache/merge pipeline runs the
499
+ // bridge roles; the merged answer lands in-turn.
500
+ //
501
+ // Crash-safety net: register a recoverable handle BEFORE running and persist
502
+ // the merged body on the completion path BEFORE removing the pending entry,
503
+ // so a transport tear-down landing between persist and remove can't silently
504
+ // lose a finished answer (recoverPending replays it on next boot). NOTE: a
505
+ // user cancellation (requestSignal abort, detected below) is the deliberate
506
+ // exception — it drops the pending entry WITHOUT replay, because a cancelled
507
+ // dispatch must not resurface later.
508
+ const _dataDir = process.env.CLAUDE_PLUGIN_DATA
509
+ const handle = `dispatch_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
510
+ try { addPending(_dataDir, handle, name, queries, ctx?.routingSessionId, ctx?.clientHostPid) } catch {}
511
+
512
+ // Cancellation detection. ctx.requestSignal is the forwarded extra.signal
513
+ // (server-main aiWrapped route) — it fires on an MCP `notifications/cancelled`
514
+ // for THIS request, i.e. the user pressing ESC (or a harness transport
515
+ // sever). _runFanout now wires this same signal into the explorer sub
516
+ // controllers, so when it fires the fan-out is actively aborted rather than
517
+ // run to completion. We also capture it here as `_severed` so the exits
518
+ // below drop the pending entry and return in-turn WITHOUT resurrecting a
519
+ // cancelled result through the dispatch_result channel. The abort may land
520
+ // mid-fan-out, so register a listener in addition to the synchronous check.
521
+ const _sig = ctx?.requestSignal
522
+ let _severed = false
523
+ const _onSeverAbort = () => { _severed = true }
524
+ if (_sig) {
525
+ if (_sig.aborted) _severed = true
526
+ else _sig.addEventListener('abort', _onSeverAbort, { once: true })
527
+ }
528
+
529
+ try {
530
+ const { settled, partialInfo, hardErrorEscalated: _hardErrorEscalated } = await _runFanout({
531
+ queries, name, resolvedCwd, brief, spec, ctx, makeBridgeLlm,
532
+ bridgeConfig,
533
+ isBackground: false,
534
+ })
535
+
536
+ // Cancellation exit (ESC / transport sever): requestSignal fired and
537
+ // _runFanout's requestSignal→subController link already aborted the
538
+ // explorer subs. A cancelled dispatch must NOT persist for replay or
539
+ // resurface later through the channel — drop the pending entry and return
540
+ // in-turn (the return is harmlessly discarded if the transport is dead).
541
+ if (_severed) {
542
+ try { removePending(_dataDir, handle) } catch {}
543
+ return ok('[explore cancelled by user]')
544
+ }
545
+
546
+ // Only `explore` reaches this dispatcher (ROLE_BY_TOOL registers it alone;
547
+ // any other tool name throws at the spec lookup above).
548
+ const merged = mergeExploreSettled(settled, queries, spec.label, partialInfo)
549
+
550
+ // All-failed detection: every entry rejected. Surface as MCP isError so
551
+ // caller doesn't merge the failures back into context as if they were
552
+ // normal results.
553
+ const allFailed = settled.every(r => r.status === 'rejected')
554
+
555
+ // Persist the finished body (success OR all-failed) BEFORE acking — mirror
556
+ // the background path's persist→ack→remove ordering. Await the disk tail so
557
+ // a tear-down between persist and remove cannot lose the body. Best-effort:
558
+ // a persist failure must not block the in-turn answer.
559
+ const _handleLine = `[explore handle: ${handle}]`
560
+ const _mergedWithHandle = `${_handleLine}\n${merged}`
561
+ // Align the PERSISTED body with the RETURNED body: fold in the fan-out cap
562
+ // notice when present so a restart replay through recoverPending carries
563
+ // the same `[capped N->M queries]` notice the in-turn return would have
564
+ // shown — not a silently truncated body.
565
+ const _syncMerged = _fanoutCapNotice ? `${_fanoutCapNotice}\n${_mergedWithHandle}` : _mergedWithHandle
566
+ const _syncMergedCapped = capDispatchRetrievalBody(_syncMerged).text
567
+ const _syncPersistTail = () => Promise.resolve(
568
+ setPendingResult(_dataDir, handle, name, queries, _syncMerged, !!allFailed, ctx?.routingSessionId, ctx?.clientHostPid),
569
+ ).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] sync persist failed: ${e?.message ?? e}\n`); } catch {} })
570
+ const _okCount = settled.filter(r => r.status === 'fulfilled').length
571
+ const _hardAllFailed = _hardErrorEscalated && !allFailed && _okCount === 0
572
+ const _awaitSyncPersist = allFailed || _hardAllFailed
573
+ if (_awaitSyncPersist) {
574
+ try { await _syncPersistTail() } catch (e) { try { process.stderr.write(`[ai-wrapped-dispatch] sync persist failed: ${e?.message ?? e}\n`); } catch {} }
575
+ } else {
576
+ _syncPersistTail()
577
+ }
578
+
579
+ // Hard-error escalation: any hard sub error and not all already covered.
580
+ if (_hardErrorEscalated && !allFailed) {
581
+ const hardFailed = settled.filter(r => r.status === 'rejected' && isHardSubError(r.reason)).length
582
+ if (_okCount === 0) {
583
+ // All-hard-error exit. A late cancellation (abort landing during the
584
+ // sync merge) is handled the same as a normal exit: drop the pending
585
+ // entry and return in-turn — never channel-push a cancelled result.
586
+ try { removePending(_dataDir, handle) } catch {}
587
+ return fail(_syncMergedCapped)
588
+ }
589
+ // Partial-error: some completed, annotate but don't fail the whole call.
590
+ process.stderr.write(`[ai-wrapped-dispatch] partial-error: ${hardFailed} hard errors, ${_okCount} ok — escalated\n`)
591
+ }
592
+ if (allFailed) {
593
+ // All-failed exit — drop the pending entry and return in-turn.
594
+ try { removePending(_dataDir, handle) } catch {}
595
+ return fail(_syncMergedCapped)
596
+ }
597
+ // Normal success exit — pop the finalized pending entry and return in-turn.
598
+ try { removePending(_dataDir, handle) } catch {}
599
+ return ok(_syncMergedCapped)
600
+ } finally {
601
+ if (_sig) {
602
+ try { _sig.removeEventListener('abort', _onSeverAbort) } catch {}
603
+ }
604
+ }
605
+ }
606
+
607
+ // Background dispatch path. The caller (Lead) gets an immediate handle;
608
+ // bridge roles stream in the background and the merged answer is pushed
609
+ // via the channel notification bridge.
610
+ //
611
+ // R15: bound background concurrency. Without this, a flood of background
612
+ // dispatches (one per `background:true` call) accumulates indefinitely —
613
+ // _pruneDispatchResults only evicts finished results, not admission. Reject
614
+ // new background dispatches when the in-flight count is at the cap so the
615
+ // model/caller cannot exhaust the plugin server via prompt-injection abuse.
616
+ if (_activeBgDispatches >= MAX_ACTIVE_BG_DISPATCHES) {
617
+ return fail(`dispatch capacity exceeded - retry shortly (active=${_activeBgDispatches}/${MAX_ACTIVE_BG_DISPATCHES})`)
618
+ }
619
+ _activeBgDispatches += 1
620
+ _pruneDispatchResults()
621
+ const id = `dispatch_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
622
+ _dispatchResults.set(id, {
623
+ status: 'running',
624
+ tool: name,
625
+ role: spec.role,
626
+ queries,
627
+ createdAt: Date.now(),
628
+ })
629
+ // Persist so a plugin restart mid-dispatch can emit a single Aborted
630
+ // notification on next bootstrap instead of silently orphaning the handle.
631
+ addPending(process.env.CLAUDE_PLUGIN_DATA, id, name, queries, ctx?.routingSessionId, ctx?.clientHostPid)
632
+ // Starting a bridge dispatch counts as session activity — keeps
633
+ // background tasks suppressed while long-running work is in flight.
634
+ notifyActivity()
635
+ // (start banner removed — notifyFn takes no opts so silent_to_agent is
636
+ // not available; pushing "X started" to the channel is channel noise.
637
+ // The merged result arrives later via pushDispatchResult.)
638
+ // Wire caller abort: when the caller session aborts (ESC, new prompt),
639
+ // mark the dispatch handle cancelled so a later result push doesn't echo
640
+ // a stale answer back to a session that already moved on. Best-effort:
641
+ // background bridge roles continue running on the bridge, but their result
642
+ // is suppressed at push time.
643
+ let _callerAborted = false;
644
+ try {
645
+ if (ctx?.callerSessionId) {
646
+ import('./session/abort-lookup.mjs').then(({ getAbortSignalForSession }) => {
647
+ Promise.resolve(getAbortSignalForSession(ctx.callerSessionId)).then((sig) => {
648
+ if (!sig) return;
649
+ if (sig.aborted) { _callerAborted = true; return; }
650
+ sig.addEventListener('abort', () => {
651
+ _callerAborted = true;
652
+ const entry = _dispatchResults.get(id);
653
+ if (entry && entry.status === 'running') {
654
+ entry.status = 'cancelled';
655
+ entry.completedAt = Date.now();
656
+ }
657
+ }, { once: true });
658
+ }).catch(() => {});
659
+ }).catch(() => {});
660
+ }
661
+ } catch {}
662
+ // Background fan-out with parent abort cascade + deadline.
663
+ ;(async () => {
664
+ // Shared fan-out pipeline. The bg-only hook flips the dispatch-registry
665
+ // entry to 'partial-error' on the first hard sub error.
666
+ const { settled, partialInfo: bgPartialInfo } = await _runFanout({
667
+ queries, name, resolvedCwd, brief, spec, ctx, makeBridgeLlm,
668
+ bridgeConfig,
669
+ isBackground: true,
670
+ onHardError: () => {
671
+ const bgEntry = _dispatchResults.get(id)
672
+ if (bgEntry && bgEntry.status === 'running') bgEntry.status = 'partial-error'
673
+ },
674
+ })
675
+
676
+ // Only `explore` reaches this dispatcher (ROLE_BY_TOOL registers it alone;
677
+ // any other tool name throws at the spec lookup above).
678
+ const merged = mergeExploreSettled(settled, queries, spec.label, bgPartialInfo)
679
+ _pruneDispatchResults()
680
+ const entry = _dispatchResults.get(id)
681
+ const allFailed = settled.every(r => r.status === 'rejected')
682
+ if (entry) {
683
+ // Preserve partial-error stamped by onHardError; completion must not
684
+ // overwrite that signal when at least one sub failed hard but others
685
+ // succeeded. allFailed still escalates to 'error'.
686
+ entry.status = allFailed
687
+ ? 'error'
688
+ : (entry.status === 'partial-error' ? 'partial-error' : 'done')
689
+ entry.isError = allFailed
690
+ entry.content = merged
691
+ entry.completedAt = Date.now()
692
+ }
693
+ _activeBgDispatches = Math.max(0, _activeBgDispatches - 1)
694
+ if (_callerAborted) {
695
+ // Caller already moved on; suppress notification but persist the
696
+ // completed body before removing so recoverPending on next boot
697
+ // re-delivers the actual answer rather than the Aborted boilerplate
698
+ // (matches the persist→notify→remove order used by pushDispatchResult).
699
+ const _dataDir = process.env.CLAUDE_PLUGIN_DATA
700
+ if (_dataDir) {
701
+ import('./dispatch-persist.mjs').then(({ setPendingResult, removePending: _rm }) =>
702
+ Promise.resolve(setPendingResult(_dataDir, id, name, queries, merged, !!allFailed, ctx?.routingSessionId, ctx?.clientHostPid))
703
+ .then(() => _rm(_dataDir, id)),
704
+ ).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] caller-aborted persist failed: ${e?.message ?? e}\n`); } catch {} })
705
+ }
706
+ return
707
+ }
708
+ const _bgMerged = _fanoutCapNotice ? `${_fanoutCapNotice}\n${merged}` : merged
709
+ // pushDispatchResult owns the persist→notify→remove sequence (see
710
+ // setPendingResult / removePending in dispatch-persist.mjs); do NOT
711
+ // pre-remove the pending entry here — that would race the persist and
712
+ // a crash between addPending and notify could lose the result.
713
+ pushDispatchResult(ctx, id, name, queries, _bgMerged, { error: allFailed })
714
+ })().catch((err) => {
715
+ const msg = errText(err)
716
+ _pruneDispatchResults()
717
+ const entry = _dispatchResults.get(id)
718
+ if (entry) {
719
+ entry.status = 'error'
720
+ entry.error = msg
721
+ entry.completedAt = Date.now()
722
+ }
723
+ _activeBgDispatches = Math.max(0, _activeBgDispatches - 1)
724
+ // Same persist+notify-before-remove invariant as the success path —
725
+ // pushDispatchResult drives removal after the error body is persisted
726
+ // and notified.
727
+ pushDispatchResult(ctx, id, name, queries, `[${spec.label} dispatch error] ${msg}`, { error: true })
728
+ })
729
+ const queryCount = queries.length === 1 ? `1 query` : `${queries.length} queries`
730
+ const _startNotice = _fanoutCapNotice ? `${_fanoutCapNotice} ` : ''
731
+ return ok(`${_startNotice}${name} started — ${queryCount}. Merged answer will be auto-pushed via the channel (handle ${id}).`)
732
+ }
733
+
734
+
735
+ function _escapeXml(str) {
736
+ return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
737
+ }
738
+
739
+ function buildExplorerPrompt(query, cwd) {
740
+ // Full isolation: each explorer receives ONLY its own <query>. Peer queries
741
+ // are never injected, so a weaker model cannot bleed into or re-answer sibling
742
+ // topics — it never sees them. The descriptive-only contract lives at
743
+ // system level (rules/bridge/30-explorer.md via collect.mjs): brief-level
744
+ // constraints alone proved insufficient — haiku still rendered verdicts on
745
+ // evaluative queries. The trailing one-liner is reinforcement only; small
746
+ // models weight the instruction after the query over the query body.
747
+ return `<query>${_escapeXml(query)}</query>\nReminder: describe with file:line evidence; no verdicts, ratings, or recommendations.`
748
+ }
749
+
750
+ /**
751
+ * Resolve user-provided cwd: expand `~`, resolve relatives against the
752
+ * launch workspace. Falls back to null so callers use process.cwd().
753
+ */
754
+ function resolveCwd(input, baseCwd = process.cwd()) {
755
+ if (!input || typeof input !== 'string') return null
756
+ const trimmed = input.trim()
757
+ if (!trimmed) return null
758
+ const expanded = trimmed.startsWith('~')
759
+ ? trimmed.replace(/^~/, homedir())
760
+ : trimmed
761
+ const base = (typeof baseCwd === 'string' && baseCwd) ? baseCwd : process.cwd()
762
+ return isAbsolute(expanded) ? expanded : resolvePath(base, expanded)
763
+ }
764
+
765
+ function resolveExploreCwd(input, callerCwd, hasExplicitCwdArg = false) {
766
+ const base = resolveCwd(callerCwd, process.cwd())
767
+ const resolved = resolveCwd(input, base || process.cwd())
768
+ // UNC / SMB reject: exploring a network share root auto-authenticates to
769
+ // the remote host and leaks the current user's NTLM hash. Reject the
770
+ // resolved cwd before any bridge role can walk it. Mirrors the read/list
771
+ // path's UNC guard.
772
+ if (typeof isUncPath === 'function' && isUncPath(resolved)) {
773
+ throw new Error(
774
+ `explore cwd "${input}" resolves to a UNC / SMB path ("${resolved}") — network credential leak risk; pass a local absolute path`,
775
+ )
776
+ }
777
+ if (!hasExplicitCwdArg || !base || !resolved) return resolved
778
+ // Explicit cwd: must point to a real directory regardless of whether it
779
+ // sits under callerCwd. An under-callerCwd path that doesn't exist is
780
+ // still almost certainly a typo (e.g. cwd:'missing'); a path outside
781
+ // callerCwd that does exist is the deliberate redirect branch
782
+ // (Lead exploring a sibling tree, plugin source, etc.) — both are
783
+ // accepted only when the directory actually exists.
784
+ if (_cwdIsExistingDir(resolved)) return resolved
785
+ // Fail-loud rather than silently rebasing to callerCwd: a non-existent
786
+ // explicit cwd is almost always a typo and silent rebase would run the
787
+ // explore against the wrong tree without warning. The single caller in
788
+ // dispatchAiWrapped catches this and returns it through fail().
789
+ throw new Error(
790
+ `explore cwd "${input}" does not exist (resolved to "${resolved}") — pass a valid absolute path or omit cwd to use the launch workspace`,
791
+ )
792
+ }
793
+
794
+ function _cwdIsExistingDir(p) {
795
+ if (!p || typeof p !== 'string') return false
796
+ let st
797
+ try { st = statSync(p) } catch { return false }
798
+ return Boolean(st && st.isDirectory())
799
+ }
800
+
801
+ function isPathInside(baseCwd, targetCwd) {
802
+ if (!baseCwd || !targetCwd) return false
803
+ const rel = relative(resolvePath(baseCwd), resolvePath(targetCwd))
804
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
805
+ }
806
+
807
+ /**
808
+ * Resolve a short model tag for the given hidden role, mirroring the
809
+ * `modelTag` format that bridge/worker lifecycle notifications use in
810
+ * src/agent/index.mjs (e.g. `3-5-sonnet`). Best-effort — returns an
811
+ * empty string when the preset / config can't be resolved so the header
812
+ * still renders (falls back to `[{tool}] Done.`).
813
+ */
814
+ export function resolveAgentModelTag(role) {
815
+ try {
816
+ const presetName = resolvePresetName({ role })
817
+ if (!presetName) return ''
818
+ const config = loadConfig()
819
+ const preset = config?.presets?.find((p) => p.id === presetName || p.name === presetName)
820
+ const raw = preset?.model
821
+ if (!raw || typeof raw !== 'string') return ''
822
+ const stripped = raw.startsWith('claude-') ? raw.slice('claude-'.length) : raw
823
+ return stripped || ''
824
+ } catch {
825
+ return ''
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Build the `Done.` header that wraps async-result notifications, mirroring
831
+ * the Pool B worker completion shape emitted in src/agent/index.mjs:
832
+ * [{model-tag}] [{role}] <content>
833
+ * Dispatch re-uses the same pattern so the user sees a consistent
834
+ * `Done.` header across bridge worker output and recall/search/explore
835
+ * dispatch result delivery.
836
+ *
837
+ * When the model tag can't be resolved, falls back to `[{tool}] Done.`.
838
+ * When the tool is empty (shouldn't happen), falls back to `Done.`.
839
+ */
840
+ export function buildDispatchResultHeader(tool, modelTag) {
841
+ const toolPart = tool ? `[${tool}] ` : ''
842
+ const tagPart = modelTag ? `[${modelTag}] ` : ''
843
+ return `${tagPart}${toolPart}Done.`
844
+ }
845
+
846
+ function capDispatchRetrievalBody(body) {
847
+ let bodyStr = typeof body === 'string' ? body : String(body ?? '')
848
+ bodyStr = stripSoftWarns(bodyStr)
849
+ bodyStr = stripAnsi(bodyStr)
850
+ bodyStr = normalizeWhitespace(bodyStr)
851
+ bodyStr = dedupRepeatedLines(bodyStr)
852
+ const bodyBytes = Buffer.byteLength(bodyStr, 'utf8')
853
+ let bodyLines = bodyStr.length === 0 ? 0 : 1
854
+ for (let i = 0; i < bodyStr.length; i += 1) {
855
+ if (bodyStr.charCodeAt(i) === 10) bodyLines += 1
856
+ }
857
+ const { text, truncated } = smartReadTruncate(bodyStr, bodyLines, bodyBytes)
858
+ return { text, truncated }
859
+ }
860
+
861
+ // Spool the full, untruncated dispatch body to disk when the notify body was
862
+ // smart-truncated, mirroring _saveLeadDirectFullOutput in server-main.mjs.
863
+ // Best-effort: a spool failure must never break the notify path, so the caller
864
+ // only appends the pointer line when this returns a path.
865
+ function saveDispatchFullOutput(dataDir, id, rawBody) {
866
+ try {
867
+ const dir = resolvePath(dataDir, 'tool-results', 'dispatch')
868
+ mkdirSync(dir, { recursive: true })
869
+ const safeId = String(id || 'dispatch').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 80) || 'dispatch'
870
+ const file = resolvePath(dir, `${safeId}.txt`)
871
+ writeFileSync(file, rawBody, 'utf8')
872
+ return file
873
+ } catch {
874
+ return null
875
+ }
876
+ }
877
+
878
+ // Age-based GC for non-per-session tool-results subdirs (`lead-direct/`,
879
+ // `dispatch/`): these accumulate one file per output and are never reopened,
880
+ // so an mtime TTL is the only reliable cleanup signal. Runs once per process
881
+ // boot (see invocation below), mirroring the stale-log sweep in
882
+ // src/channels/index.mjs. 7-day TTL keeps recent forensics while bounding leak.
883
+ const _TOOL_RESULTS_TTL_MS = 7 * 24 * 60 * 60 * 1000
884
+ function _gcToolResultsOnce() {
885
+ const dataDir = process.env.CLAUDE_PLUGIN_DATA
886
+ if (!dataDir) return
887
+ const now = Date.now()
888
+ for (const sub of ['lead-direct', 'dispatch']) {
889
+ try {
890
+ const dir = resolvePath(dataDir, 'tool-results', sub)
891
+ for (const f of readdirSync(dir)) {
892
+ const p = resolvePath(dir, f)
893
+ try { if (now - statSync(p).mtimeMs > _TOOL_RESULTS_TTL_MS) unlinkSync(p) } catch {}
894
+ }
895
+ } catch {}
896
+ }
897
+ }
898
+ try { _gcToolResultsOnce() } catch {}
899
+
900
+ export function pushDispatchResult(ctx, id, tool, queries, body, flags = {}) {
901
+ const notify = ctx?.notifyFn
902
+ if (typeof notify !== 'function') {
903
+ // notifyFn absent means the background result has nowhere to go — the
904
+ // promise would silently vanish. Write a visible stderr line so the
905
+ // operator can diagnose "auto-pushed" answers that never arrived, and
906
+ // return a structured marker so callers can detect the gap.
907
+ try {
908
+ process.stderr.write(`[ai-wrapped-dispatch] pushDispatchResult: no notifyFn — result lost tool=${tool} id=${id}\n`)
909
+ } catch {}
910
+ return { lost: true, tool, id, reason: 'no-notify-fn' }
911
+ }
912
+ const queryCount = queries.length === 1
913
+ ? `1 query`
914
+ : `${queries.length} queries`
915
+ const bodyHeader = flags.error
916
+ ? `${tool} failed`
917
+ : `${tool} — ${queryCount}`
918
+ // Smart truncation — large recall/search/explore merged bodies
919
+ // (multi-query fan-out) can blow past the 30 KB smart-read cap and waste
920
+ // Lead context. Apply the same head/tail summariser used by `read`
921
+ // (single + array form) so Lead still sees the interesting frames (first queries
922
+ // and final queries) without paying for the middle mass. Truncation acts
923
+ // on the body only — the `Done.` header is prepended AFTER, so it never
924
+ // gets cut.
925
+ const rawBody = typeof body === 'string' ? body : String(body ?? '')
926
+ const { text: cappedBody, truncated: bodyTruncated } = capDispatchRetrievalBody(body)
927
+ // When smart-truncation actually dropped content, spool the full raw body to
928
+ // tool-results/dispatch/<id>.txt and point the notify body at it so Lead can
929
+ // recover the complete output instead of reconstructing it by hand.
930
+ let notifyBody = `${bodyHeader}\n\n${cappedBody}`
931
+ if (bodyTruncated) {
932
+ const _dataDirSpool = process.env.CLAUDE_PLUGIN_DATA
933
+ if (_dataDirSpool && id) {
934
+ const _fullPath = saveDispatchFullOutput(_dataDirSpool, id, rawBody)
935
+ if (_fullPath) notifyBody += `\n[full output saved: ${_fullPath}]`
936
+ }
937
+ }
938
+ const persistBody = `${bodyHeader}\n\n${rawBody}`
939
+ // Prepend a `Done.` wrapper that mirrors the Pool B worker
940
+ // completion header in src/agent/index.mjs (`${modelTag}[${role}] ...`).
941
+ // When the model tag can't be resolved, the helper falls back to
942
+ // `[{tool}] Done.` — still better than no header.
943
+ const spec = ROLE_BY_TOOL[tool]
944
+ const modelTag = spec ? resolveAgentModelTag(spec.role) : ''
945
+ const doneHeader = flags.error
946
+ ? buildDispatchResultHeader(tool, modelTag).replace(/Done\.$/, 'Failed.')
947
+ : buildDispatchResultHeader(tool, modelTag)
948
+ const notifyContent = `${doneHeader}\n\n${notifyBody}`
949
+ const persistContent = `${doneHeader}\n\n${persistBody}`
950
+ // NOTE: duplicate notification on reconnect is acceptable — the goal is
951
+ // "no silent loss". Host-side dedup is not implemented; a reconnect-window
952
+ // duplicate surfaces the answer twice rather than not at all.
953
+ const _dataDir = process.env.CLAUDE_PLUGIN_DATA
954
+ const _qs = Array.isArray(queries) ? queries : [String(queries ?? '')]
955
+ // Persist the merged result BODY before notify so a torn-down transport
956
+ // can't lose the answer. recoverPending replays the persisted content on
957
+ // next boot instead of the generic Aborted boilerplate.
958
+ // setPendingResult enqueues a debounced/serialized write and returns the
959
+ // tail Promise that resolves AFTER the disk write completes. We must
960
+ // await that tail (not just the function return) before notify, so a
961
+ // crash between notify and disk-flush can't drop the answer — recoverPending
962
+ // replays from the persisted body, which has to actually be on disk first.
963
+ const _persistPromise = (_dataDir && id && tool)
964
+ ? import('./dispatch-persist.mjs').then(({ setPendingResult }) => {
965
+ const tail = setPendingResult(_dataDir, id, tool, _qs, persistContent, !!flags.error, ctx?.routingSessionId, ctx?.clientHostPid)
966
+ // Defensive: older builds returned void. Coerce to a Promise so the
967
+ // chain still awaits something rather than resolving immediately.
968
+ return tail && typeof tail.then === 'function' ? tail : Promise.resolve()
969
+ }).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] setPendingResult failed: ${e?.message ?? e}\n`); } catch {} })
970
+ : Promise.resolve()
971
+ try {
972
+ _persistPromise.then(() => {
973
+ return Promise.resolve(
974
+ notify(notifyContent, {
975
+ type: 'dispatch_result',
976
+ dispatch_id: id,
977
+ tool,
978
+ // Daemon routing: deliver this result to the dispatching terminal
979
+ // via caller_session_id (owner-only in daemon; no session → drop).
980
+ caller_session_id: ctx?.routingSessionId,
981
+ ...(typeof ctx?.clientHostPid === 'number' && ctx.clientHostPid > 0
982
+ ? { client_host_pid: String(ctx.clientHostPid) }
983
+ : {}),
984
+ instruction: `The ${tool} dispatch you started earlier (${id}) has returned — use this answer in your next step.`,
985
+ }),
986
+ )
987
+ }).then(() => {
988
+ // Notify resolved successfully — safe to remove the pending entry.
989
+ if (_dataDir && id) {
990
+ import('./dispatch-persist.mjs').then(({ removePending }) => removePending(_dataDir, id)).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] removePending failed: ${e?.message ?? e}\n`); } catch {} })
991
+ }
992
+ }).catch((err) => {
993
+ try {
994
+ process.stderr.write(`[ai-wrapped-dispatch] pushDispatchResult async failed: tool=${tool} id=${id} err=${err?.message ?? String(err)} — leaving in pending for recoverPending\n`)
995
+ } catch {}
996
+ // Leave the pending entry — recoverPending will retry on next boot.
997
+ })
998
+ } catch (err) {
999
+ try { process.stderr.write(`[ai-wrapped-dispatch] pushDispatchResult failed: tool=${tool} id=${id} err=${err?.message ?? String(err)}\n`); } catch {}
1000
+ // Sync-throw safety net: _persistPromise already fired above (best-effort).
1001
+ }
1002
+ }
1003
+
1004
+ function ok(text) {
1005
+ return { content: [{ type: 'text', text }] }
1006
+ }
1007
+
1008
+ function fail(msg) {
1009
+ return { content: [{ type: 'text', text: `[aiWrapped error] ${msg}` }], isError: true }
1010
+ }