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,3055 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * mixdog — MCP server entry point.
4
+ *
5
+ * Four modules (channels, memory, search, agent) exposed over a single
6
+ * MCP server. Tool routing is driven by the static manifest in tools.json,
7
+ * which records the owning module for every tool.
8
+ *
9
+ * Module lifecycle:
10
+ * • memory — eager init right after the MCP handshake completes,
11
+ * because channels depends on it for episode delivery.
12
+ * • channels — eager init (runs background workers: Discord gateway,
13
+ * scheduler, webhook, event pipeline). Started after memory is ready.
14
+ * • search / agent — eager init after MCP handshake.
15
+ */
16
+
17
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js'
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
19
+ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
20
+ import { z } from 'zod'
21
+ import { fork, spawnSync } from 'child_process'
22
+ import { randomUUID, createHash } from 'crypto'
23
+
24
+ // Per-process instance id. One server-main = one stdio MCP client = one
25
+ // logical Claude Code session. memory.entries / trace.entries carry this id
26
+ // in the existing session_id column so multi-terminal usage keeps recall and
27
+ // cycle scope per-instance even though PG/memory worker are singletons.
28
+ // Workers inherit the same id via env (MIXDOG_SESSION_ID).
29
+ const SESSION_ID = randomUUID()
30
+ process.env.MIXDOG_OWNER_SESSION_ID = SESSION_ID
31
+ import { readFileSync, writeFileSync, mkdirSync, watch as fsWatch, existsSync, unlinkSync, statSync, renameSync, createWriteStream, appendFileSync } from 'fs'
32
+ import { appendFile as appendFileAsync, writeFile as writeFileAsync } from 'fs/promises'
33
+ import { join, resolve as pathResolve } from 'path'
34
+ import { homedir, tmpdir } from 'os'
35
+ import { pathToFileURL } from 'url'
36
+ import { createRequire } from 'module'
37
+ import { resolvePluginData } from './src/shared/plugin-paths.mjs'
38
+ import { ensureDataSeeds } from './src/shared/seed.mjs'
39
+ import { readSection } from './src/shared/config.mjs'
40
+ import { withFileLockSync, writeJsonAtomicSync } from './src/shared/atomic-file.mjs'
41
+ import { loadConfig as loadSearchConfig } from './src/search/lib/config.mjs'
42
+ import { PROVIDER_CAPS } from './src/search/lib/backends/index.mjs'
43
+ import { captureOriginalUserCwd, pwd, rawUserCwd, readLastSessionCwd } from './src/shared/user-cwd.mjs'
44
+ import { resolveProjectId as _resolveProjectIdForBoot } from './src/memory/lib/project-id-resolver.mjs'
45
+ import { configureCacheStatsSnapshot } from './src/agent/orchestrator/session/read-dedup.mjs'
46
+ import { smartReadTruncate } from './src/agent/orchestrator/tools/builtin/read-formatting.mjs'
47
+ import { formatToolStartProgress } from './src/agent/orchestrator/tools/progress-message.mjs'
48
+ import { maybeRequestDefenderExclusion } from './src/setup/defender-exclusion.mjs'
49
+ import { isHookPipeServerStarted, startHookPipeServer, stopHookPipeServer } from './src/channels/lib/hook-pipe-server.mjs'
50
+ import { Session, SessionRegistry } from './src/daemon/session.mjs'
51
+ import { listen as daemonListen } from './src/daemon/transport.mjs'
52
+ import { FramedServerTransport } from './src/daemon/mcp-transport.mjs'
53
+
54
+ // silent_to_agent is an INTERNAL routing flag (consumed by the daemon router /
55
+ // agentNotify before any CC delivery). It must never cross to Claude Code,
56
+ // whose channel-notification schema is meta: Record<string,string> — a boolean
57
+ // would fail zod and silently drop the whole notification. Strip it from meta
58
+ // right before a notification is delivered to CC. (Routing has already read it.)
59
+ function channelNotifyParamsForCc(notification) {
60
+ const m = notification?.params?.meta
61
+ if (!m || typeof m !== 'object' || !('silent_to_agent' in m)) return notification
62
+ const { silent_to_agent, ...rest } = m
63
+ return { ...notification, params: { ...notification.params, meta: rest } }
64
+ }
65
+
66
+ // ── Environment ──────────────────────────────────────────────────────
67
+ // Claude Code normally injects CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA
68
+ // for relative-path plugin sources. For URL-based sources it may skip
69
+ // injection, so fall back to process.cwd() and the standard data path.
70
+ const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || process.cwd()
71
+ const PLUGIN_DATA = resolvePluginData()
72
+ mkdirSync(PLUGIN_DATA, { recursive: true })
73
+ captureOriginalUserCwd() // seed single-source-of-truth cwd before any tool dispatch
74
+ // Session-cwd auto-init: when user-cwd.txt resolves to a directory that
75
+ // belongs to a known project (resolveProjectId returns non-null) OR has
76
+ // an ancestor `.git`, seed MIXDOG_SESSION_CWD so the cwd tool's `get`
77
+ // reports a usable session cwd from the first call. Skipped when the
78
+ // env var is already set (an outer supervisor / prior call already
79
+ // chose the session cwd).
80
+ ;(() => {
81
+ if (typeof process.env.MIXDOG_SESSION_CWD === 'string' && process.env.MIXDOG_SESSION_CWD.length > 0) return
82
+ // Per-terminal restore: a dev-sync child restart drops the in-memory
83
+ // MIXDOG_SESSION_CWD the user chose via `cwd set` (the supervisor respawns
84
+ // this child with its OWN env, so the child's runtime mutation is gone).
85
+ // Re-seed from the supervisor-PID-keyed sentinel BEFORE the user-cwd.txt
86
+ // session-start default below. The key is per terminal, so this never
87
+ // picks up another terminal's cwd; readLastSessionCwd validates the dir
88
+ // still exists.
89
+ try {
90
+ const restored = readLastSessionCwd()
91
+ if (restored) {
92
+ process.env.MIXDOG_SESSION_CWD = restored
93
+ process.stderr.write(`[cwd-autoinit] restored MIXDOG_SESSION_CWD=${restored} (per-terminal)\n`)
94
+ return
95
+ }
96
+ } catch {}
97
+ let seed
98
+ try { seed = rawUserCwd() } catch { return }
99
+ if (!seed || typeof seed !== 'string') return
100
+ let qualifies = false
101
+ try { qualifies = _resolveProjectIdForBoot(seed) != null } catch {}
102
+ if (!qualifies) {
103
+ try {
104
+ let dir = seed
105
+ while (dir && dir !== pathResolve(dir, '..')) {
106
+ if (existsSync(join(dir, '.git'))) { qualifies = true; break }
107
+ const parent = pathResolve(dir, '..')
108
+ if (parent === dir) break
109
+ dir = parent
110
+ }
111
+ } catch {}
112
+ }
113
+ if (qualifies) {
114
+ process.env.MIXDOG_SESSION_CWD = seed
115
+ process.stderr.write(`[cwd-autoinit] seeded MIXDOG_SESSION_CWD=${seed}\n`)
116
+ }
117
+ })()
118
+ process.stderr.write(`[boot-time] tag=server-entry tMs=${Date.now()}\n`)
119
+ try { ensureDataSeeds(PLUGIN_DATA) } catch {}
120
+ configureCacheStatsSnapshot(PLUGIN_DATA)
121
+
122
+ // Hook/statusline IPC is a singleton named pipe. In multi-terminal mode only
123
+ // the active terminal owner may hold it; otherwise a standby process can steal
124
+ // statusline / hook traffic from the real active-instance owner.
125
+ let hookPipeStarted = false
126
+ let hookPipeOwnershipTimer = null
127
+ const HOOK_PIPE_OWNER_CHECK_MS = 1000
128
+ const HOOK_PIPE_LEAD_PID = (() => {
129
+ const pid = Number(process.env.MIXDOG_SUPERVISOR_PID)
130
+ return Number.isFinite(pid) && pid > 0 ? pid : process.pid
131
+ })()
132
+ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
133
+ ? pathResolve(process.env.MIXDOG_RUNTIME_ROOT)
134
+ : join(tmpdir(), 'mixdog')
135
+ const ACTIVE_INSTANCE_FILE = join(RUNTIME_ROOT, 'active-instance.json')
136
+
137
+ function parsePositivePid(value) {
138
+ const pid = Number(value)
139
+ return Number.isFinite(pid) && pid > 0 ? pid : null
140
+ }
141
+
142
+ function isMemoryOwnerPidAlive(pid) {
143
+ if (pid == null) return false
144
+ try {
145
+ process.kill(pid, 0)
146
+ return true
147
+ } catch (e) {
148
+ if (e && e.code === 'ESRCH') return false
149
+ return true
150
+ }
151
+ }
152
+
153
+ function resolveOwnerHostPid() {
154
+ const fromEnv = parsePositivePid(process.env.MIXDOG_OWNER_HOST_PID)
155
+ if (fromEnv) return fromEnv
156
+
157
+ if (process.platform === 'win32') {
158
+ try {
159
+ const ps = [
160
+ '$procs = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name;',
161
+ '$map = @{};',
162
+ 'foreach ($p in $procs) { $map[[int]$p.ProcessId] = $p }',
163
+ `$cur = ${Number(process.pid)};`,
164
+ 'for ($i = 0; $i -lt 16; $i++) {',
165
+ ' $p = $map[$cur]; if ($null -eq $p) { break }',
166
+ " if ($p.Name -ieq 'claude.exe' -or $p.Name -ieq 'claude') { [Console]::Write($p.ProcessId); exit 0 }",
167
+ ' $cur = [int]$p.ParentProcessId',
168
+ '}',
169
+ ].join(' ')
170
+ const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], {
171
+ encoding: 'utf8',
172
+ timeout: 1500,
173
+ windowsHide: true,
174
+ })
175
+ const pid = parsePositivePid(String(r.stdout || '').trim())
176
+ if (pid) return pid
177
+ } catch {}
178
+ }
179
+
180
+ return parsePositivePid(process.ppid)
181
+ }
182
+
183
+ const OWNER_HOST_PID = resolveOwnerHostPid()
184
+ if (OWNER_HOST_PID) process.env.MIXDOG_OWNER_HOST_PID = String(OWNER_HOST_PID)
185
+
186
+ function activeOwnerLeadPid() {
187
+ try {
188
+ const active = JSON.parse(readFileSync(ACTIVE_INSTANCE_FILE, 'utf8'))
189
+ return parsePositivePid(active?.ownerLeadPid)
190
+ } catch {
191
+ return null
192
+ }
193
+ }
194
+
195
+ function shouldOwnHookPipe(ownerPid = activeOwnerLeadPid()) {
196
+ if (!ownerPid) return process.env.MIXDOG_MULTI_INSTANCE !== '1'
197
+ return ownerPid === HOOK_PIPE_LEAD_PID
198
+ }
199
+
200
+ function reconcileHookPipeOwnership(reason = 'timer') {
201
+ const ownerPid = activeOwnerLeadPid()
202
+ const shouldOwn = shouldOwnHookPipe(ownerPid)
203
+ const isStarted = isHookPipeServerStarted()
204
+ if (shouldOwn && !isStarted) {
205
+ try {
206
+ const server = startHookPipeServer()
207
+ hookPipeStarted = Boolean(server)
208
+ process.stderr.write(`[hook-pipe] owner lead=${HOOK_PIPE_LEAD_PID} start requested reason=${reason}\n`)
209
+ } catch (err) {
210
+ hookPipeStarted = false
211
+ try { process.stderr.write(`[hook-pipe] parent start failed: ${err?.message || err}\n`) } catch {}
212
+ }
213
+ } else if (!shouldOwn && (hookPipeStarted || isStarted)) {
214
+ try { stopHookPipeServer() } catch {}
215
+ hookPipeStarted = false
216
+ process.stderr.write(`[hook-pipe] standby lead=${HOOK_PIPE_LEAD_PID} released hook IPC reason=${reason}\n`)
217
+ } else if (!shouldOwn && reason === 'boot') {
218
+ process.stderr.write(`[hook-pipe] standby lead=${HOOK_PIPE_LEAD_PID} skip hook IPC owner=${ownerPid || 'none'}\n`)
219
+ }
220
+ }
221
+ reconcileHookPipeOwnership('boot')
222
+ hookPipeOwnershipTimer = setInterval(() => reconcileHookPipeOwnership('owner-check'), HOOK_PIPE_OWNER_CHECK_MS)
223
+ try { hookPipeOwnershipTimer.unref?.() } catch {}
224
+ process.once('exit', () => {
225
+ if (hookPipeOwnershipTimer) {
226
+ try { clearInterval(hookPipeOwnershipTimer) } catch {}
227
+ hookPipeOwnershipTimer = null
228
+ }
229
+ if (hookPipeStarted) {
230
+ try { stopHookPipeServer() } catch {}
231
+ }
232
+ })
233
+
234
+ // Singleton lock + lock-release exit handlers are owned by the prelude
235
+ // in server.mjs. server-main.mjs assumes the lock is already held.
236
+
237
+ globalThis.__tribFastEntry = true
238
+
239
+ // ── Module enable flags (B6 General toggles) ──────────────────────
240
+ // Snapshotted once at boot — toggling in the setup UI requires a full
241
+ // plugin restart to take effect. All four default to enabled:true when
242
+ // the `modules` section is absent (backcompat for pre-B6 configs).
243
+ const MODULE_NAMES = ['channels', 'memory', 'search', 'agent']
244
+ const MODULE_ENABLED = (() => {
245
+ const out = { channels: true, memory: true, search: true, agent: true }
246
+ try {
247
+ const raw = JSON.parse(readFileSync(join(PLUGIN_DATA, 'mixdog-config.json'), 'utf8'))
248
+ const mods = raw && typeof raw === 'object' ? raw.modules : null
249
+ if (mods && typeof mods === 'object') {
250
+ for (const name of MODULE_NAMES) {
251
+ const entry = mods[name]
252
+ if (entry && typeof entry === 'object' && entry.enabled === false) out[name] = false
253
+ }
254
+ }
255
+ } catch { /* missing / malformed — keep all enabled */ }
256
+ return out
257
+ })()
258
+ const isModuleEnabled = (name) => MODULE_ENABLED[name] !== false
259
+
260
+ // ── Static manifest ─────────────────────────────────────────────────
261
+ // Dev-only self-heal: if a TOOL_DEFS source was edited after the last
262
+ // tools.json build, regenerate before loading so boot never serves a stale
263
+ // manifest — even when dev-sync wasn't run. The build script lives under
264
+ // dev/, absent from published packages (which ship a fresh prepublish
265
+ // manifest with no drift), so existsSync gates installed users out.
266
+ const _toolsJsonPath = join(PLUGIN_ROOT, 'tools.json')
267
+ let _selfHealError = null
268
+ try {
269
+ const _buildToolsScript = join(PLUGIN_ROOT, 'dev', 'scripts', 'build-tools-manifest.mjs')
270
+ if (existsSync(_buildToolsScript) && existsSync(_toolsJsonPath)) {
271
+ const _manifestMtime = statSync(_toolsJsonPath).mtimeMs
272
+ let _srcMtime = 0
273
+ for (const rel of [
274
+ 'dev/scripts/build-tools-manifest.mjs',
275
+ 'src/channels/index.mjs', 'src/channels/tool-defs.mjs',
276
+ 'src/memory/index.mjs', 'src/memory/tool-defs.mjs',
277
+ 'src/search/index.mjs', 'src/search/tool-defs.mjs', 'src/search/lib/providers.mjs',
278
+ 'src/agent/index.mjs', 'src/agent/tool-defs.mjs',
279
+ 'src/agent/orchestrator/tools/builtin.mjs', 'src/agent/orchestrator/tools/builtin/builtin-tools.mjs',
280
+ 'src/agent/orchestrator/tools/code-graph-tool-defs.mjs',
281
+ 'src/agent/orchestrator/tools/patch-tool-defs.mjs',
282
+ 'src/agent/orchestrator/tools/host-input.mjs',
283
+ 'src/agent/orchestrator/tools/cwd-tool.mjs',
284
+ ]) {
285
+ try { _srcMtime = Math.max(_srcMtime, statSync(join(PLUGIN_ROOT, rel)).mtimeMs) } catch {}
286
+ }
287
+ if (_srcMtime > _manifestMtime) {
288
+ const _healEnv = { ...process.env }
289
+ delete _healEnv.CLAUDE_PLUGIN_DATA // route the rebuild's init writes to a throwaway temp dir
290
+ const _r = spawnSync('bun', [_buildToolsScript], { cwd: PLUGIN_ROOT, encoding: 'utf8', env: _healEnv, windowsHide: true })
291
+ if (_r.status !== 0) {
292
+ _selfHealError = new Error(`[mixdog] tools.json self-heal failed (status=${_r.status}); refusing to load possibly-stale/tampered manifest`)
293
+ }
294
+ }
295
+ }
296
+ } catch { /* missing source/manifest — fall through to load the existing file */ }
297
+ if (_selfHealError) throw _selfHealError
298
+ let RAW_TOOL_DEFS
299
+ try {
300
+ RAW_TOOL_DEFS = JSON.parse(readFileSync(_toolsJsonPath, 'utf8'))
301
+ } catch (e) {
302
+ throw new Error('[mixdog] tools.json parse failure: ' + (e && e.message))
303
+ }
304
+ // Boot-time schema validation + module allowlist. A malformed or
305
+ // unknown-module entry aborts startup rather than silently shipping a
306
+ // broken/tampered manifest into TOOL_MODULE / TOOL_BY_NAME below.
307
+ const VALID_TOOL_MODULES = new Set([
308
+ ...MODULE_NAMES, 'builtin', 'code_graph', 'patch', 'host_input', 'cwd',
309
+ ])
310
+ if (!Array.isArray(RAW_TOOL_DEFS)) {
311
+ throw new Error('[mixdog] tools.json: top-level value is not an array')
312
+ }
313
+ for (const t of RAW_TOOL_DEFS) {
314
+ if (!t || typeof t !== 'object' || Array.isArray(t)) {
315
+ throw new Error(`[mixdog] tools.json: malformed entry (not an object): ${JSON.stringify(t)}`)
316
+ }
317
+ if (typeof t.name !== 'string' || !t.name) {
318
+ throw new Error(`[mixdog] tools.json: entry missing string "name": ${JSON.stringify(t)}`)
319
+ }
320
+ if (typeof t.description !== 'string') {
321
+ throw new Error(`[mixdog] tools.json: entry "${t.name}" missing string "description"`)
322
+ }
323
+ if (!t.inputSchema || typeof t.inputSchema !== 'object' || Array.isArray(t.inputSchema)) {
324
+ throw new Error(`[mixdog] tools.json: entry "${t.name}" missing object "inputSchema"`)
325
+ }
326
+ if (typeof t.module !== 'string' || !t.module) {
327
+ throw new Error(`[mixdog] tools.json: entry "${t.name}" missing string "module"`)
328
+ }
329
+ if (!VALID_TOOL_MODULES.has(t.module)) {
330
+ throw new Error(`[mixdog] tools.json: entry "${t.name}" has unknown module "${t.module}" (allowed: ${[...VALID_TOOL_MODULES].join(', ')})`)
331
+ }
332
+ }
333
+ // Hide tools belonging to disabled modules from BOTH the ListTools
334
+ // response AND the bridge's internal-tools registry. `builtin` / `lsp` /
335
+ // `bash_session` / `patch` are not module-gated — they ride along with
336
+ // the plugin regardless.
337
+ // Gate host_input on MIXDOG_ALLOW_HOST_INPUT env-var or
338
+ // modules.host_input.enabled config flag. Default: off.
339
+ const _hostInputAllowed = (() => {
340
+ if (process.env.MIXDOG_ALLOW_HOST_INPUT === '1') return true
341
+ try {
342
+ const raw = JSON.parse(readFileSync(join(PLUGIN_DATA, 'mixdog-config.json'), 'utf8'))
343
+ return !!(raw?.modules?.host_input?.enabled)
344
+ } catch { return false }
345
+ })()
346
+ const TOOL_DEFS = RAW_TOOL_DEFS.filter(t => {
347
+ if (t.module === 'host_input') return _hostInputAllowed
348
+ // Channels worker depends on memory worker for inbound routing / recall;
349
+ // the spawn gate below (channels gated on memoryOn) skips spawning when
350
+ // memory is disabled. Mirror that gate here so we don't advertise channel
351
+ // tools whose backing worker will never come up — calls would otherwise
352
+ // wait WORKER_NO_ENTRY_GRACE_MS and then reject.
353
+ if (t.module === 'channels' && !isModuleEnabled('memory')) return false
354
+ if (MODULE_NAMES.includes(t.module)) return isModuleEnabled(t.module)
355
+ return true
356
+ })
357
+ const TOOL_MODULE = Object.fromEntries(TOOL_DEFS.map(t => [t.name, t.module]))
358
+ const TOOL_BY_NAME = Object.fromEntries(TOOL_DEFS.map(t => [t.name, t]))
359
+ const PLUGIN_VERSION = JSON.parse(
360
+ readFileSync(join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8'),
361
+ ).version
362
+
363
+ // ── Logging ──────────────────────────────────────────────────────────
364
+ const LOG_FILE = join(PLUGIN_DATA, 'mcp-debug.log')
365
+ const _logOwnerLeadPidRaw = Number(process.env.MIXDOG_SUPERVISOR_PID)
366
+ const LOG_OWNER_LEAD_PID = Number.isFinite(_logOwnerLeadPidRaw) && _logOwnerLeadPidRaw > 0
367
+ ? _logOwnerLeadPidRaw
368
+ : process.pid
369
+ const LOG_CONTEXT = `lead=${LOG_OWNER_LEAD_PID} server=${process.pid} session=${SESSION_ID}`
370
+ const LOG_FILE_SCOPED = join(PLUGIN_DATA, `mcp-debug.${LOG_OWNER_LEAD_PID}.${process.pid}.log`)
371
+ // One-shot rotation: if mcp-debug.log >10 MB, rename to .1 (overwrite) and
372
+ // open a fresh file. Runs once at module init; never repeated per log() call.
373
+ try {
374
+ if (statSync(LOG_FILE).size > 10 * 1024 * 1024) renameSync(LOG_FILE, LOG_FILE + '.1')
375
+ } catch {}
376
+ try {
377
+ if (statSync(LOG_FILE_SCOPED).size > 10 * 1024 * 1024) renameSync(LOG_FILE_SCOPED, LOG_FILE_SCOPED + '.1')
378
+ } catch {}
379
+
380
+ // ── Application-level tool-error sink ────────────────────────────────────────
381
+ // Tool functions return `Error [code N]: ...` as plain strings instead of
382
+ // throwing — the dispatch logger therefore records them as `[dispatch] ok`
383
+ // and the disk has no trace of read/edit invariant failures (code 6/7/8/
384
+ // 9/10). Append one structured line per such result to tool-events.log
385
+ // next to mcp-debug.log. Best-effort: logger never breaks the tool.
386
+ const TOOL_EVENT_LOG = join(PLUGIN_DATA, 'tool-events.log');
387
+ // Lazy rotation for the application-error sink. mcp-debug.log uses the
388
+ // same 10 MiB threshold but tool-events.log is far less chatty (one line
389
+ // per app-level edit failure + one per session close), so a 1 MiB cap is
390
+ // roughly a month of activity. Rotation is one-step rename so callers
391
+ // never see a half-empty file mid-write.
392
+ const TOOL_EVENT_LOG_MAX_BYTES = 1024 * 1024;
393
+ function _rotateToolEventLogIfLarge() {
394
+ try {
395
+ const st = statSync(TOOL_EVENT_LOG);
396
+ if (st.size > TOOL_EVENT_LOG_MAX_BYTES) {
397
+ renameSync(TOOL_EVENT_LOG, TOOL_EVENT_LOG + '.1');
398
+ }
399
+ } catch { /* missing file or stat race — append will recreate */ }
400
+ }
401
+
402
+ function _recordToolApplicationError(name, result) {
403
+ try {
404
+ let text = null;
405
+ if (typeof result === 'string') {
406
+ text = result;
407
+ } else if (result && typeof result === 'object' && Array.isArray(result.content)) {
408
+ const t = result.content.find((c) => c && c.type === 'text' && typeof c.text === 'string');
409
+ if (t) text = t.text;
410
+ }
411
+ if (!text) return;
412
+ const m = text.match(/^Error \[code (\d+)\]:\s*([^\n]+)/);
413
+ if (!m) return;
414
+ _rotateToolEventLogIfLarge();
415
+ const ts = new Date().toISOString();
416
+ const msg = m[2].slice(0, 320);
417
+ appendFileSync(TOOL_EVENT_LOG, `[${ts}] [tool=${name}] [code=${m[1]}] ${msg}\n`);
418
+ } catch { /* logger never breaks the tool */ }
419
+ }
420
+
421
+ // R14: sanitize a single log field — strip ANSI escapes and escape control
422
+ // chars (CR, lone C0/C1) so attacker-controlled bytes from worker stderr can't
423
+ // forge new log lines, hide payloads with \r overwrites, or smuggle ANSI
424
+ // sequences into operator terminals tailing the log. Keep \t and \n intact:
425
+ // \t is benign in log payloads; \n is handled by the line-splitter upstream
426
+ // (writeWorkerLogChunk) and the writer appends its own \n.
427
+ function sanitizeLogField(text) {
428
+ if (text == null) return ''
429
+ let s = String(text)
430
+ // ANSI CSI: ESC [ params intermediates final.
431
+ s = s.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, (m) => '\\x1b' + m.slice(1))
432
+ // ANSI OSC: ESC ] ... BEL | ESC \.
433
+ s = s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, (m) => '\\x1b' + m.slice(1))
434
+ // Other single-char ESC sequences (Fe set).
435
+ s = s.replace(/\x1b[@-_]/g, (m) => '\\x1b' + m.slice(1))
436
+ // Escape lone CR — main log-injection vector (overwrites prior line in terminals).
437
+ s = s.replace(/\r/g, '\\r')
438
+ // Remaining C0 (except TAB \x09 and LF \x0A) and C1 control chars → \xNN.
439
+ s = s.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, (c) => {
440
+ const code = c.charCodeAt(0)
441
+ return '\\x' + code.toString(16).padStart(2, '0')
442
+ })
443
+ return s
444
+ }
445
+
446
+ // ── Buffered mcp-debug.log writer ────────────────────────────────────────────
447
+ // Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
448
+ // Drains synchronously on process exit so no log lines are lost.
449
+ let _logBuf = ''
450
+ let _logBytes = 0
451
+ let _logFlushTimer = null
452
+ let _logStream = null
453
+ let _logScopedStream = null
454
+ function _logGetStream() {
455
+ if (!_logStream) _logStream = createWriteStream(LOG_FILE, { flags: 'a' })
456
+ return _logStream
457
+ }
458
+ function _logGetScopedStream() {
459
+ if (!_logScopedStream) _logScopedStream = createWriteStream(LOG_FILE_SCOPED, { flags: 'a' })
460
+ return _logScopedStream
461
+ }
462
+ // Async WriteStream errors are not caught by callers of write(); attach
463
+ // 'error' listeners on lazy-create so a disk EIO/EPERM during async flush
464
+ // can't escape as uncaughtException. Best-effort: log and fall through to
465
+ // appendFileAsync on next write.
466
+ function _attachLogStreamErrorListener(stream, label) {
467
+ if (!stream) return
468
+ stream.on('error', (e) => {
469
+ try { process.stderr.write(`[server-main] ${label} stream error: ${e && (e.message || e)}\n`) } catch {}
470
+ })
471
+ }
472
+ function _logWriteAsync(line) {
473
+ try {
474
+ const s = _logGetStream()
475
+ if (!s.__mxErr) { _attachLogStreamErrorListener(s, 'mcp-debug'); s.__mxErr = true }
476
+ s.write(line)
477
+ } catch (e) {
478
+ appendFileAsync(LOG_FILE, line).catch(() => {})
479
+ }
480
+ try {
481
+ const s = _logGetScopedStream()
482
+ if (!s.__mxErr) { _attachLogStreamErrorListener(s, 'mcp-debug-scoped'); s.__mxErr = true }
483
+ s.write(line)
484
+ } catch (e) {
485
+ appendFileAsync(LOG_FILE_SCOPED, line).catch(() => {})
486
+ }
487
+ }
488
+ function _logLine(msg) {
489
+ return `[${new Date().toISOString()}] [${LOG_CONTEXT}] ${sanitizeLogField(msg)}\n`
490
+ }
491
+ function _logFlush() {
492
+ if (_logFlushTimer) { clearTimeout(_logFlushTimer); _logFlushTimer = null }
493
+ if (!_logBuf) return
494
+ _logWriteAsync(_logBuf)
495
+ _logBuf = ''
496
+ _logBytes = 0
497
+ }
498
+ // Synchronous exit flush — async WriteStream.write() can be dropped before drain.
499
+ function _logFlushSync() {
500
+ if (_logFlushTimer) { clearTimeout(_logFlushTimer); _logFlushTimer = null }
501
+ if (!_logBuf) return
502
+ try { appendFileSync(LOG_FILE, _logBuf) } catch {}
503
+ try { appendFileSync(LOG_FILE_SCOPED, _logBuf) } catch {}
504
+ _logBuf = ''
505
+ _logBytes = 0
506
+ }
507
+ function _logScheduleFlush() {
508
+ if (_logFlushTimer) return
509
+ _logFlushTimer = setTimeout(_logFlush, 1000)
510
+ if (_logFlushTimer.unref) _logFlushTimer.unref()
511
+ }
512
+ function _logAppend(line) {
513
+ _logBuf += line
514
+ _logBytes += Buffer.byteLength(line)
515
+ if (_logBytes >= 65536) { _logFlush(); return }
516
+ _logScheduleFlush()
517
+ }
518
+ process.on('exit', _logFlushSync)
519
+ // SIGTERM is wired to graceful shutdown() below (line ~1564). A separate
520
+ // _logFlushSync() + process.exit(0) handler used to short-circuit shutdown
521
+ // before workers/PG could drain — graceful path is preferred and ends with
522
+ // _logFlushSync() inside shutdown(). The 'exit' listener above stays as a
523
+ // catch-all for non-SIGTERM exits.
524
+
525
+ const log = msg => { _logAppend(_logLine(msg)) }
526
+
527
+ // ── Status HTTP server (forked child) ──────────────────────────────
528
+ // Start this before memory/channels so the terminal statusline gets its
529
+ // advert port during the first refresh tick. The rich bridge payload may be
530
+ // sparse until workers finish booting, but the statusline no longer waits on
531
+ // channels ownership before it has a data source.
532
+ const STATUS_ADVERTISE_DIR = join(homedir(), '.claude', 'mixdog-status')
533
+ const STATUS_ADVERTISE_PATH = join(STATUS_ADVERTISE_DIR, `${SESSION_ID}.json`)
534
+ let statusServerChild = null
535
+ let statusServerRestartTimer = null
536
+ let statusServerStopping = false
537
+ let recapStatusState = { state: 'idle', running: false, startedAt: null, lastCompletedAt: null, updatedAt: null, errorMessage: null }
538
+
539
+ function scheduleStatusServerRestart() {
540
+ if (statusServerStopping) return
541
+ if (statusServerChild) return
542
+ if (statusServerRestartTimer) return
543
+ statusServerRestartTimer = setTimeout(() => {
544
+ statusServerRestartTimer = null
545
+ spawnStatusServer()
546
+ }, 1000)
547
+ statusServerRestartTimer.unref?.()
548
+ }
549
+
550
+ function normalizeRecapTimestamp(value) {
551
+ if (value === null || value === undefined || value === '') return null
552
+ const n = Number(value)
553
+ return Number.isFinite(n) ? n : null
554
+ }
555
+
556
+ function sanitizeRecapStatusState(recap = {}) {
557
+ const validStates = new Set(['idle', 'running', 'injected', 'empty', 'error']);
558
+ const rawState = typeof recap.state === 'string' && validStates.has(recap.state) ? recap.state : 'idle';
559
+ return {
560
+ state: rawState,
561
+ running: recap.running === true,
562
+ startedAt: normalizeRecapTimestamp(recap.startedAt),
563
+ lastCompletedAt: normalizeRecapTimestamp(recap.lastCompletedAt),
564
+ updatedAt: normalizeRecapTimestamp(recap.updatedAt),
565
+ errorMessage: typeof recap.errorMessage === 'string' ? recap.errorMessage.slice(0, 200) : null,
566
+ }
567
+ }
568
+
569
+ function forwardRecapStatusToStatusServer() {
570
+ if (!statusServerChild || !statusServerChild.connected) return
571
+ try {
572
+ statusServerChild.send({ type: 'recap_status', recap: recapStatusState })
573
+ } catch (e) {
574
+ log(`[status-server] recap status forward failed: ${e && (e.message || e) || e}`)
575
+ }
576
+ }
577
+
578
+ function spawnStatusServer() {
579
+ if (statusServerStopping) return
580
+ if (statusServerChild) return
581
+ if (statusServerRestartTimer) {
582
+ clearTimeout(statusServerRestartTimer)
583
+ statusServerRestartTimer = null
584
+ }
585
+ try {
586
+ try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
587
+ statusServerChild = fork(
588
+ join(PLUGIN_ROOT, 'src/status/server.mjs'),
589
+ [],
590
+ {
591
+ env: {
592
+ ...process.env,
593
+ MIXDOG_STATUS_DATA_DIR: PLUGIN_DATA,
594
+ MIXDOG_OWNER_SESSION_ID: SESSION_ID,
595
+ MIXDOG_OWNER_LEAD_PID: process.env.MIXDOG_SUPERVISOR_PID || '',
596
+ MIXDOG_OWNER_HOST_PID: OWNER_HOST_PID ? String(OWNER_HOST_PID) : '',
597
+ MIXDOG_STATUS_ADVERTISE_PATH: STATUS_ADVERTISE_PATH,
598
+ },
599
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
600
+ windowsHide: true,
601
+ }
602
+ )
603
+ // Split chunks on \n so a child line containing embedded newlines cannot
604
+ // forge unprefixed log entries via sanitizeLogField's preservation of \n.
605
+ const _emitStatusLines = (prefix, chunk) => {
606
+ const text = String(chunk)
607
+ if (!text) return
608
+ const lines = text.split(/\r?\n/)
609
+ // Drop a trailing empty token from a chunk that ended with \n; emit
610
+ // any non-empty residue too (incomplete final line still surfaces).
611
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
612
+ for (const line of lines) {
613
+ if (!line) continue
614
+ log(prefix ? `${prefix}${line}` : line)
615
+ }
616
+ }
617
+ statusServerChild.stdout?.on('data', (d) => _emitStatusLines('', d))
618
+ statusServerChild.stderr?.on('data', (d) => _emitStatusLines('[status-server] stderr: ', d))
619
+ statusServerChild.on('error', (e) => {
620
+ log(`[status-server] child error: ${(e && (e.stack || e.message)) || e}`)
621
+ statusServerChild = null
622
+ try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
623
+ scheduleStatusServerRestart()
624
+ })
625
+ statusServerChild.on('exit', (code, signal) => {
626
+ log(`[status-server] child exited code=${code} signal=${signal}`)
627
+ statusServerChild = null
628
+ try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
629
+ scheduleStatusServerRestart()
630
+ })
631
+ forwardRecapStatusToStatusServer()
632
+ } catch (e) {
633
+ log(`[status-server] failed to fork: ${(e && (e.stack || e.message)) || e}`)
634
+ scheduleStatusServerRestart()
635
+ }
636
+ }
637
+ spawnStatusServer()
638
+
639
+ // ── Crash handlers ──────────────────────────────────────────────────
640
+ // Leave a trace on silent hangs. Previously only child workers
641
+ // (channels/memory) installed these; the main MCP entry had none, so
642
+ // unhandled errors died without writing a stack.
643
+ //
644
+ // Soft net policy (0.1.73): we deliberately do NOT call process.exit()
645
+ // from uncaughtException / unhandledRejection. A misbehaving tool path
646
+ // (e.g. explore concatenating results past V8 max-string-length on a
647
+ // very broad cwd) used to take the whole MCP server down; now it logs
648
+ // a stack and the process keeps serving. Real fatal conditions still
649
+ // bubble out via SIGTERM/SIGINT or the explicit shutdown() path.
650
+ const CRASH_FILE = join(PLUGIN_DATA, 'crash.log')
651
+ const logCrash = (kind, err) => {
652
+ const stack = err?.stack || String(err)
653
+ try { appendFileSync(CRASH_FILE, `[${new Date().toISOString()}] ${kind}\n${stack}\n\n`) } catch {}
654
+ try { log(`${kind}: ${err?.message || err}`) } catch {}
655
+ }
656
+
657
+ // Fatal classification for uncaughtException. Soft-net (log-only) is the
658
+ // 0.1.73 default for recoverable conditions like "Invalid string length"
659
+ // from a runaway explore concat. But genuinely fatal conditions (port
660
+ // already bound, OOM, node assert violations, internal-assertion
661
+ // failures) leave the process in an unrecoverable state — staying alive
662
+ // just delays the inevitable while serving broken responses. For those,
663
+ // log the stack and exit(1) so the supervisor restarts cleanly.
664
+ const FATAL_CODES = new Set([
665
+ 'EADDRINUSE',
666
+ 'EADDRNOTAVAIL',
667
+ 'ENOMEM',
668
+ // EPIPE on stdout/stdin means the MCP transport is gone — no recovery possible.
669
+ 'EPIPE',
670
+ 'ERR_INTERNAL_ASSERTION',
671
+ ])
672
+ const FATAL_NAME_PATTERNS = [
673
+ /AssertionError/i, // node assert violations
674
+ ]
675
+ function isFatalUncaught(err) {
676
+ if (!err) return false
677
+ if (err.code && FATAL_CODES.has(err.code)) return true
678
+ const name = err.name || (err.constructor && err.constructor.name) || ''
679
+ if (FATAL_NAME_PATTERNS.some(rx => rx.test(name))) return true
680
+ return false
681
+ }
682
+ process.on('uncaughtException', (err) => {
683
+ logCrash('uncaughtException', err)
684
+ if (isFatalUncaught(err)) {
685
+ try { log(`uncaughtException classified fatal (code=${err?.code} name=${err?.name}); exiting`) } catch {}
686
+ process.exit(1)
687
+ }
688
+ })
689
+ process.on('unhandledRejection', (reason) => {
690
+ logCrash('unhandledRejection', reason)
691
+ if (isFatalUncaught(reason)) {
692
+ try { log(`unhandledRejection classified fatal (code=${reason?.code} name=${reason?.name}); exiting`) } catch {}
693
+ process.exit(1)
694
+ }
695
+ })
696
+
697
+ // Explicit stdio transport-gone handlers: if stdout errors with EPIPE or stdin
698
+ // closes, the MCP pipe is dead — exit(1) so the host respawns on next call.
699
+ process.stdout.on('error', e => { if (e?.code === 'EPIPE') { logCrash('stdout-epipe', e); process.exit(1) } })
700
+ // A normal MCP host closes stdin to end the session — route that through the
701
+ // graceful shutdown path so workers (memory/channels) and PG get a clean stop
702
+ // and the supervisor sees exit 0 instead of a spurious crash. The fatal
703
+ // stdout EPIPE guard above still covers the abnormal "pipe broken mid-write"
704
+ // condition where shutdown() can't drain reliably.
705
+ process.stdin.on('close', () => {
706
+ log('stdin closed — MCP transport gone, initiating graceful shutdown')
707
+ if (typeof shutdown === 'function') {
708
+ Promise.resolve()
709
+ .then(() => shutdown('stdin closed'))
710
+ .catch(e => {
711
+ try { logCrash('stdin-close-shutdown', e) } catch {}
712
+ process.exit(1)
713
+ })
714
+ } else {
715
+ // shutdown() not yet defined — extremely early stdin close. Treat as abnormal.
716
+ logCrash('stdin-close-early', new Error('stdin closed before shutdown wired'))
717
+ process.exit(1)
718
+ }
719
+ })
720
+ process.on('exit', (code) => {
721
+ if (code !== 0) {
722
+ try { logCrash('exit', new Error(`process exit code=${code}`)) } catch {}
723
+ }
724
+ })
725
+
726
+ // ── Bridge orphan cleanup ───────────────────────────────────────────
727
+ // Non-blocking: cleanup of stale state from a previous server PID.
728
+ // Awaiting these used to gate the boot path (memory worker spawn, agent
729
+ // eager init) behind disk + module-load work that has no semantic
730
+ // dependency on the rest of boot. Fire-and-forget so the critical path
731
+ // proceeds; failures stay logged.
732
+ import(pathToFileURL(join(PLUGIN_ROOT, 'src/shared/llm/pid-cleanup.mjs')).href)
733
+ .then(({ cleanupOrphanedPids }) => {
734
+ const killed = cleanupOrphanedPids()
735
+ if (killed > 0) log(`[bridge-cleanup] cleaned ${killed} orphaned processes`)
736
+ })
737
+ .catch(e => log(`[bridge-cleanup] failed: ${e && (e.stack || e.message) || e}`))
738
+
739
+ // ── Session cleanup: bridge sessions from previous MCP process ─────
740
+ // Non-blocking, same rationale as bridge-cleanup above.
741
+ import(pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/session/manager.mjs')).href)
742
+ .then(({ listSessions, closeSession, startIdleCleanup }) => {
743
+ const sessions = listSessions()
744
+ // Multi-instance guard: only reap bridge sessions whose owning MCP
745
+ // process is actually dead. A live peer (another Claude Code window)
746
+ // may still be running work on its session — closing it here is what
747
+ // produced the "bridge worker aborted mid-run" reports when a new
748
+ // window booted.
749
+ const isPidAlive = (pid) => {
750
+ if (!pid) return false
751
+ try { process.kill(pid, 0); return true } catch (e) { return !!(e && e.code === 'EPERM') }
752
+ }
753
+ let closed = 0
754
+ let preserved = 0
755
+ for (const s of sessions) {
756
+ if (s.owner !== 'bridge') continue
757
+ if (s.mcpPid === process.pid) continue
758
+ if (isPidAlive(s.mcpPid)) { preserved++; continue }
759
+ closeSession(s.id); closed++
760
+ }
761
+ log(`[session-cleanup] closed ${closed} dead-peer bridge sessions (pid≠${process.pid}), preserved ${preserved} live-peer, ${sessions.length - closed} remaining`)
762
+ startIdleCleanup()
763
+ log(`[session-cleanup] idle sweep timer started (interval=5m)`)
764
+ })
765
+ .catch(e => log(`[session-cleanup] failed: ${e && (e.stack || e.message) || e}`))
766
+
767
+ // ── MCP server ──────────────────────────────────────────────────────
768
+ function buildSearchProviderLine() {
769
+ try {
770
+ const cfg = loadSearchConfig()
771
+ const provider = cfg?.provider || 'anthropic-oauth'
772
+ const caps = PROVIDER_CAPS[provider]
773
+ if (!caps) {
774
+ return `Search backend: \`${provider}\` (unknown capabilities — verify with \`/mixdog:config\`).`
775
+ }
776
+ const types = (caps.searchTypes || []).join('/') || 'web'
777
+ const lines = [
778
+ `Search backend: \`${provider}\` (searchTypes=${types}).`,
779
+ 'Two-step model: `search` returns SERP snippets + URLs; follow up with `web_fetch` for raw page bodies. `web_fetch` uses local readability+puppeteer extractors (provider-independent).',
780
+ ]
781
+ if (caps.localeMode === 'none') {
782
+ lines.push('→ Note: `locale` parameter is ignored by this backend.')
783
+ }
784
+ return lines.join('\n')
785
+ } catch (e) {
786
+ return `Search backend: capability probe failed (${e && (e.message || e)}).`
787
+ }
788
+ }
789
+
790
+ const SERVER_INSTRUCTIONS = [
791
+ `mixdog MCP server v${PLUGIN_VERSION}.`,
792
+ '',
793
+ 'Agents: delegate via `bridge` with a `role` argument (roles defined in `user-workflow.json`; active set injected as the `# Roles` rule). `Agent` / `TeamCreate` are NOT used — `bridge` is the single entry point. `TaskCreate` / `TaskUpdate` / `TaskList` are allowed for Lead progress tracking only.',
794
+ '',
795
+ 'Retrieval (HIGHEST PRIORITY): choose the source family first — `explore` for unknown/open-ended codebase work, `search` for web/URL/current external docs, `recall` for past memory. Then descend for bounded code lookup: `code_graph` (esp. `mode:search` for symbol-name keywords; `find_symbol` when exact) → `glob`/`list` → `grep` (free-text / non-symbol) → `read` with `file_path` plus `offset`/`limit` or line window. Mutations descend separately: `apply_patch` for large/multi-hunk/patch-shaped changes, batched `edit` for multiple exact substitutions, scalar `edit` for one small exact substitution. `bash` is shell-only (git, build, test, run); using it for file/code lookup is a violation.',
796
+ '',
797
+ buildSearchProviderLine(),
798
+ '',
799
+ 'Channels: schedule / webhook / queue events arrive in the Lead session via the built-in channel mechanism, each with its own event-class marker.',
800
+ ].join('\n')
801
+
802
+ const server = new Server(
803
+ { name: 'mixdog', version: PLUGIN_VERSION },
804
+ {
805
+ capabilities: {
806
+ tools: {},
807
+ experimental: { 'claude/channel': {}, 'claude/channel/permission': {} },
808
+ },
809
+ instructions: SERVER_INSTRUCTIONS,
810
+ },
811
+ )
812
+
813
+ // ── Channel permission request forwarding ──────────────────────────
814
+ // Claude Code's interactiveHandler races the terminal dialog against every
815
+ // MCP channel server that declares `experimental['claude/channel/permission']`.
816
+ // When CC fires this notification, forward it into the channels worker so it
817
+ // can post the Discord prompt. The worker reports the outcome back through
818
+ // the generic {type:'notify'} IPC path above, which becomes a
819
+ // `notifications/claude/channel/permission` notification on the MCP server.
820
+ const ChannelPermissionRequestNotificationSchema = z.object({
821
+ method: z.literal('notifications/claude/channel/permission_request'),
822
+ params: z.object({
823
+ request_id: z.string(),
824
+ tool_name: z.string(),
825
+ description: z.string().optional(),
826
+ input_preview: z.string().optional(),
827
+ }).passthrough(),
828
+ })
829
+
830
+ // Daemon-mode notification router. In stdio mode the single module-global
831
+ // `server` carries every worker→host notification. In daemon mode there are N
832
+ // per-connection servers, so worker notifications must be ROUTED: permission
833
+ // responses go to the connection that made the request (by request_id), channel
834
+ // events (Discord inbound / schedule / webhook) go to the designated Lead
835
+ // connection. Stays null in stdio mode.
836
+ let daemonNotifyRouter = null
837
+ function createDaemonNotifyRouter() {
838
+ const byReq = new Map() // request_id → perConn server (permission replies)
839
+ const bySession = new Map() // sessionId → perConn server (worker result/status routing)
840
+ const sessions = new Map() // perConn server → Session (owner resolution by leadPid)
841
+ // Resolve a connection by its terminal's client_host_pid (insertion-order scan).
842
+ function resolveConnByHostPid(hostPid) {
843
+ const pid = Number(hostPid) || 0
844
+ if (pid <= 0) return null
845
+ for (const [conn, session] of sessions) {
846
+ if (Number(session?.clientHostPid) === pid) return conn
847
+ }
848
+ return null
849
+ }
850
+ // Owner terminal = the connection whose supervisor pid matches the active
851
+ // instance's ownerLeadPid — the SAME SSOT hook-pipe ownership trusts
852
+ // (activeOwnerLeadPid). Exactly one deterministic owner: no first-connection
853
+ // election, no broadcast guessing.
854
+ function ownerConn() {
855
+ const ownerPid = activeOwnerLeadPid()
856
+ if (!ownerPid) return null
857
+ for (const [conn, session] of sessions) {
858
+ if (parsePositivePid(session?.leadPid) === ownerPid) return conn
859
+ }
860
+ return null
861
+ }
862
+ async function deliver(conn, notification, label) {
863
+ try {
864
+ await conn.notification(channelNotifyParamsForCc(notification))
865
+ const meta = notification?.params?.meta || null
866
+ if (meta?.type === 'dispatch_result' || meta?.caller_session_id != null || meta?.client_host_pid != null || String(label || '').includes('worker notify')) {
867
+ const sid = meta?.caller_session_id != null ? ` sid=${String(meta.caller_session_id)}` : ''
868
+ const host = Number(meta?.client_host_pid) || 0
869
+ log(`[daemon] ${label} delivered${sid}${host > 0 ? ` host=${host}` : ''}`)
870
+ }
871
+ return true
872
+ } catch (err) {
873
+ log(`[daemon] ${label} failed: ${err instanceof Error ? err.message : String(err)}`)
874
+ return false
875
+ }
876
+ }
877
+ return {
878
+ add(conn, session) { sessions.set(conn, session) },
879
+ remove(conn) {
880
+ sessions.delete(conn)
881
+ for (const [k, v] of byReq) if (v === conn) byReq.delete(k)
882
+ for (const [k, v] of bySession) if (v === conn) bySession.delete(k)
883
+ },
884
+ registerPermissionRequest(reqId, conn) { if (reqId) byReq.set(String(reqId), conn) },
885
+ // Bind a session id → its connection so worker results/status (bridge +
886
+ // explore) route back to the dispatching terminal. Re-binding the same
887
+ // connection under a new id (bootstrap UUID → real MIXDOG_SESSION_ID on the
888
+ // control frame) drops the stale key, so a result can never route to a dead
889
+ // bootstrap id.
890
+ registerSession(sessionId, conn) {
891
+ if (!sessionId) return
892
+ const key = String(sessionId)
893
+ for (const [k, v] of bySession) if (v === conn && k !== key) bySession.delete(k)
894
+ bySession.set(key, conn)
895
+ },
896
+ async route(method, params) {
897
+ // 1. Permission replies are request-scoped: the channels worker emits
898
+ // `notifications/claude/channel/permission` with a top-level request_id.
899
+ // Deliver ONLY to the originating connection (handing one terminal's
900
+ // answer to another would be a security/UX break).
901
+ if (method === 'notifications/claude/channel/permission') {
902
+ const reqId = params?.request_id != null ? String(params.request_id) : null
903
+ const target = reqId != null ? byReq.get(reqId) : null
904
+ if (reqId != null) byReq.delete(reqId)
905
+ if (target) {
906
+ return await deliver(target, { method, params }, `permission response for request ${reqId}`)
907
+ }
908
+ log(`[daemon] permission response for unknown/closed request ${reqId} — dropped`)
909
+ return false
910
+ }
911
+ // 2. Worker notifications & status (bridge/explore results, lifecycle
912
+ // echoes) are SESSION-scoped: meta.caller_session_id pins them to the
913
+ // dispatching terminal. No live session → drop; recoverPending replays
914
+ // real results on that session's reconnect (durable path — not a
915
+ // broadcast guess).
916
+ const meta = params?.meta || null
917
+ // Lifecycle status pings (silent_to_agent) never enter ANY terminal's
918
+ // context window — drop before routing, regardless of caller_session_id.
919
+ // Detached workers route here directly (bypassing agentNotify's silent
920
+ // branch); caller-session-less emits (bridge HTTP -> owner) previously fell
921
+ // through to owner delivery and leaked the flag to CC. Discord forwarding
922
+ // for non-detached emits is handled in agentNotify. The worker-side
923
+ // notifyFn / sendNotifyToParent gates coerce this flag to boolean before
924
+ // IPC, so === true is sufficient (no string 'true' reaches here).
925
+ if (meta?.silent_to_agent === true) return true;
926
+ const sid = meta?.caller_session_id != null ? String(meta.caller_session_id) : null
927
+ if (sid != null) {
928
+ const hostPid = Number(meta?.client_host_pid) || 0
929
+ const target = bySession.get(sid)
930
+ if (target) {
931
+ const targetSession = sessions.get(target) || null
932
+ const targetHostPid = Number(targetSession?.clientHostPid) || 0
933
+ if (!hostPid || (targetHostPid > 0 && targetHostPid === hostPid)) {
934
+ return await deliver(target, { method, params }, `worker notify for session ${sid}`)
935
+ }
936
+ log(`[daemon] worker notify for session ${sid} host mismatch target=${targetHostPid || 'unknown'} meta=${hostPid} — routing by host`)
937
+ }
938
+ // The dispatching session id is stale. MIXDOG_SESSION_ID is unset in the
939
+ // terminal, so the control frame never swaps in a stable id and EVERY
940
+ // reconnect mints a fresh bootstrap UUID (serveDaemon randomUUID); the
941
+ // prior conn's close drops its bySession key (remove()). A detached
942
+ // worker that outlived a reconnect would otherwise be lost even though
943
+ // its terminal is still attached. The terminal IS reachable under its
944
+ // stable per-terminal key: client_host_pid (= CC host ppid, invariant
945
+ // across reconnects, stamped on both the session and the notify meta).
946
+ // Route by that BEFORE owner — this delivers a non-owner terminal's own
947
+ // worker result back to it, not misrouted to the owner.
948
+ if (hostPid > 0) {
949
+ const hconn = resolveConnByHostPid(hostPid)
950
+ if (hconn) return await deliver(hconn, { method, params }, `worker notify for host ${hostPid}`)
951
+ log(`[daemon] worker notify for session ${sid} retained — host ${hostPid} not connected`)
952
+ return false
953
+ }
954
+ // Host pid is unknown (legacy client without host-pid): deliver to the
955
+ // active-instance owner terminal — the SAME ownerLeadPid SSOT
956
+ // (active-instance.json) the channel branch below trusts, not a broadcast
957
+ // guess. Host-scoped modern results never take this fallback because that
958
+ // would ack-and-delete a pending result in the wrong terminal.
959
+ const ownerForStaleSid = ownerConn()
960
+ if (ownerForStaleSid) {
961
+ return await deliver(ownerForStaleSid, { method, params }, `legacy worker notify for session ${sid}`)
962
+ }
963
+ log(`[daemon] worker notify for session ${sid} dropped — session + host + owner not connected`)
964
+ return false
965
+ }
966
+ const hostPid = Number(meta?.client_host_pid) || 0
967
+ if (meta?.type === 'dispatch_result' && hostPid > 0) {
968
+ const hconn = resolveConnByHostPid(hostPid)
969
+ if (hconn) return await deliver(hconn, { method, params }, `dispatch result for host ${hostPid}`)
970
+ log(`[daemon] dispatch result retained — host ${hostPid} not connected`)
971
+ return false
972
+ }
973
+ // 3. Channel events (Discord inbound / schedule / webhook / queue) are
974
+ // OWNER-scoped: only the active-instance owner terminal handles them.
975
+ // Owner not connected → drop; the queue / Discord surface it on reconnect.
976
+ const owner = ownerConn()
977
+ if (owner) {
978
+ return await deliver(owner, { method, params }, `channel event ${method}`)
979
+ }
980
+ log(`[daemon] channel event ${method} dropped — owner terminal not connected`)
981
+ return false
982
+ },
983
+ }
984
+ }
985
+
986
+ // Forward a channel permission request to the channels worker, replying with an
987
+ // explicit deny (via replyNotify) if the worker is unavailable so CC never hangs.
988
+ function forwardChannelPermissionRequest(params, replyNotify) {
989
+ const entry = workers.get('channels')
990
+ const reqId = params?.request_id
991
+ const deny = (reason) => replyNotify({
992
+ method: 'notifications/claude/channel',
993
+ params: { content: JSON.stringify({ type: 'permission_response', request_id: reqId, granted: false, reason }), meta: { user: 'mixdog-agent', user_id: 'system', ts: new Date().toISOString(), type: 'permission_response' } },
994
+ })
995
+ if (!entry?.proc?.connected || !entry.ready) {
996
+ log(`permission_request denied: channels worker not available (request_id=${reqId})`)
997
+ deny('channels worker not available')
998
+ return false
999
+ }
1000
+ try {
1001
+ entry.proc.send({ type: 'permission_request_inbound', params })
1002
+ return true
1003
+ } catch (err) {
1004
+ log(`permission_request IPC send failed: ${err instanceof Error ? err.message : String(err)}`)
1005
+ deny('IPC send failed')
1006
+ return false
1007
+ }
1008
+ }
1009
+
1010
+ server.setNotificationHandler(ChannelPermissionRequestNotificationSchema, async (notification) => {
1011
+ forwardChannelPermissionRequest(notification.params, (n) => {
1012
+ if (process.stdout.writable && !process.stdout.writableEnded) server.notification(n).catch(err => log(`[permission] notification dispatch failed: ${err?.message ?? err}`))
1013
+ })
1014
+ })
1015
+
1016
+ // ── Worker process management ──────────────────────────────────────
1017
+ const workers = new Map() // name → { proc, ready, pending }
1018
+ const WORKER_RESTART_WARN_AFTER = 3 // log [WARN] once attempt count crosses this; no hard cap
1019
+ const WORKER_MAX_BACKOFF_MS = 60_000
1020
+ const workerRestarts = new Map() // name → count (telemetry + backoff exponent + warn threshold)
1021
+ const workerIntentionalStop = new Set() // names where parent initiated shutdown; suppress respawn
1022
+ const workerPermanentlyDegraded = new Set() // worker self-declared unrecoverable (init reported degraded:true); suppress respawn
1023
+
1024
+ // Cached bridge-llm factory import — loaded on first agent_ipc_request and
1025
+ // reused thereafter. The agent module must be loaded before the first call
1026
+ // (loadModule('agent') runs at boot, well before any memory cycle fires).
1027
+ let _bridgeLlmFactory = null
1028
+ async function _getBridgeLlmFactory() {
1029
+ if (_bridgeLlmFactory) return _bridgeLlmFactory
1030
+ const mod = await import(
1031
+ pathToFileURL(join(PLUGIN_ROOT, 'src', 'agent', 'orchestrator', 'smart-bridge', 'bridge-llm.mjs')).href
1032
+ )
1033
+ _bridgeLlmFactory = mod.makeBridgeLlm
1034
+ return _bridgeLlmFactory
1035
+ }
1036
+
1037
+ // Per-callId AbortController so a worker-side timeout can plumb
1038
+ // agent_ipc_cancel through to the in-flight bridge LLM provider call,
1039
+ // stopping further token billing instead of letting the call run to
1040
+ // completion after the worker stopped waiting for the result.
1041
+ const AGENT_IPC_MAX_CONCURRENT = 2
1042
+ const _agentIpcInflight = new Map()
1043
+ /** @type {Array<{ msg: object, worker: string, proc: import('child_process').ChildProcess }>} */
1044
+ const _agentIpcQueue = []
1045
+ let _agentIpcRunning = 0
1046
+
1047
+ function _sendAgentIpcResponse(proc, callId, body) {
1048
+ try { proc.send({ type: 'agent_ipc_response', callId, ...body }) } catch {}
1049
+ }
1050
+
1051
+ function _startAgentIpcJob(job) {
1052
+ const { msg, worker, proc } = job
1053
+ const _ctrl = new AbortController()
1054
+ _agentIpcInflight.set(msg.callId, { ctrl: _ctrl, worker })
1055
+ void handleAgentIpcRequest(msg, _ctrl.signal).then(res => {
1056
+ _sendAgentIpcResponse(proc, msg.callId, res)
1057
+ }).catch(err => {
1058
+ try { process.stderr.write(`[agent_ipc] handler rejected callId=${msg.callId}: ${err?.stack || err?.message || err}\n`) } catch {}
1059
+ _sendAgentIpcResponse(proc, msg.callId, { ok: false, error: err?.message || String(err) })
1060
+ }).finally(() => {
1061
+ _agentIpcInflight.delete(msg.callId)
1062
+ _agentIpcRunning = Math.max(0, _agentIpcRunning - 1)
1063
+ _drainAgentIpcQueue()
1064
+ })
1065
+ }
1066
+
1067
+ function _drainAgentIpcQueue() {
1068
+ while (_agentIpcRunning < AGENT_IPC_MAX_CONCURRENT && _agentIpcQueue.length > 0) {
1069
+ const job = _agentIpcQueue.shift()
1070
+ _agentIpcRunning++
1071
+ _startAgentIpcJob(job)
1072
+ }
1073
+ }
1074
+
1075
+ function _enqueueAgentIpcRequest(msg, worker, proc) {
1076
+ _agentIpcQueue.push({ msg, worker, proc })
1077
+ _drainAgentIpcQueue()
1078
+ }
1079
+
1080
+ function _cancelQueuedAgentIpc(callId, proc) {
1081
+ const idx = _agentIpcQueue.findIndex(j => j.msg.callId === callId)
1082
+ if (idx === -1) return false
1083
+ const [job] = _agentIpcQueue.splice(idx, 1)
1084
+ _sendAgentIpcResponse(job.proc || proc, callId, { ok: false, error: 'agent_ipc cancelled before start' })
1085
+ return true
1086
+ }
1087
+
1088
+ function _purgeAgentIpcForWorker(workerName) {
1089
+ for (let i = _agentIpcQueue.length - 1; i >= 0; i--) {
1090
+ if (_agentIpcQueue[i].worker === workerName) {
1091
+ const job = _agentIpcQueue.splice(i, 1)[0]
1092
+ _sendAgentIpcResponse(job.proc, job.msg.callId, { ok: false, error: `worker ${workerName} exited` })
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ async function handleAgentIpcRequest(msg, signal) {
1098
+ const params = msg?.params || {}
1099
+ try {
1100
+ if (msg.tool !== 'bridge_llm') {
1101
+ return { ok: false, error: `unsupported agent_ipc tool "${msg.tool}"` }
1102
+ }
1103
+ if (!params.prompt) {
1104
+ return { ok: false, error: 'bridge_llm: prompt required' }
1105
+ }
1106
+ const makeBridgeLlm = await _getBridgeLlmFactory()
1107
+ const llm = makeBridgeLlm({
1108
+ role: params.role || undefined,
1109
+ taskType: params.taskType || undefined,
1110
+ mode: params.mode || undefined,
1111
+ cwd: params.cwd || undefined,
1112
+ parentSignal: signal || undefined,
1113
+ })
1114
+ const raw = await llm({
1115
+ prompt: params.prompt,
1116
+ mode: params.mode || undefined,
1117
+ preset: params.preset || undefined,
1118
+ timeout: params.timeout || undefined,
1119
+ })
1120
+ return { ok: true, result: raw }
1121
+ } catch (e) {
1122
+ return { ok: false, error: e?.message || String(e) }
1123
+ }
1124
+ }
1125
+
1126
+ function spawnWorker(name) {
1127
+ process.stderr.write(`[boot-time] tag=worker-spawn name=${name} tMs=${Date.now()}\n`)
1128
+ const modulePath = join(PLUGIN_ROOT, 'src', name, 'index.mjs')
1129
+ // Per-worker stderr files so cycle1/cycle2/embed/recap diagnostics are
1130
+ // captured even when the worker hangs before answering an IPC call. The
1131
+ // legacy shared log stays for compatibility; the scoped sibling makes
1132
+ // multi-terminal analysis unambiguous.
1133
+ const stderrPath = join(PLUGIN_DATA, `${name}-worker.log`)
1134
+ const stderrScopedPath = join(PLUGIN_DATA, `${name}-worker.${LOG_OWNER_LEAD_PID}.${process.pid}.log`)
1135
+ // One-shot rotation before opening: if worker log >10 MB, rename to .1 (overwrite).
1136
+ try { if (statSync(stderrPath).size > 10 * 1024 * 1024) renameSync(stderrPath, stderrPath + '.1') } catch {}
1137
+ try { if (statSync(stderrScopedPath).size > 10 * 1024 * 1024) renameSync(stderrScopedPath, stderrScopedPath + '.1') } catch {}
1138
+ let stderrStream = null
1139
+ let stderrScopedStream = null
1140
+ let stderrRemainder = ''
1141
+ const proc = fork(modulePath, [], {
1142
+ // stdio idx 1 = 'ignore' so a worker stdout write (or a stdout write
1143
+ // from any worker dependency such as bun runtime warnings, transformers,
1144
+ // onnxruntime) cannot leak into the parent's MCP JSON-RPC stream and
1145
+ // corrupt the frame the client sees. Worker logs are piped and written
1146
+ // with lead/server/worker metadata; IPC carries everything functional.
1147
+ // The supervisor (run-mcp.mjs) also quarantines any non-JSON line that
1148
+ // does reach its stdout pipe — defense in depth against future regressions.
1149
+ stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
1150
+ env: {
1151
+ ...process.env,
1152
+ CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
1153
+ CLAUDE_PLUGIN_DATA: PLUGIN_DATA,
1154
+ MIXDOG_WORKER_MODE: '1',
1155
+ MIXDOG_SESSION_ID: SESSION_ID,
1156
+ MIXDOG_OWNER_SESSION_ID: SESSION_ID,
1157
+ MIXDOG_SERVER_PID: String(process.pid),
1158
+ MIXDOG_OWNER_LEAD_PID: process.env.MIXDOG_SUPERVISOR_PID || '',
1159
+ },
1160
+ windowsHide: true,
1161
+ })
1162
+ // Build a list of env values to scrub from worker log output. Workers run
1163
+ // with the full parent env (provider keys, OAuth tokens) so those literal
1164
+ // values can otherwise surface verbatim in stderr (stack traces, debug
1165
+ // dumps). The env itself is left untouched — workers still need the keys
1166
+ // to call providers; only the LOG bytes are redacted.
1167
+ const SECRET_ENV_RE = /^(ANTHROPIC_|CLAUDE_|AWS_|OPENAI_|GH_|GITHUB_|NPM_|.*_API_KEY|.*_TOKEN|.*_SECRET)/
1168
+ const secretEnvValues = []
1169
+ try {
1170
+ for (const [k, v] of Object.entries(process.env)) {
1171
+ if (!v || typeof v !== 'string') continue
1172
+ if (v.length < 4) continue
1173
+ if (SECRET_ENV_RE.test(k)) secretEnvValues.push(v)
1174
+ }
1175
+ // Longest first so a longer secret containing a shorter one is masked whole.
1176
+ secretEnvValues.sort((a, b) => b.length - a.length)
1177
+ } catch {}
1178
+ const redactSecrets = (text) => {
1179
+ if (!text) return text
1180
+ let s = String(text)
1181
+ // Wrap all redaction in try/catch: a regex/replace failure must NEVER break
1182
+ // worker logging. On error, return whatever was redacted so far (best-effort).
1183
+ try {
1184
+ // Authorization: Bearer <token>
1185
+ s = s.replace(/(Bearer\s+)[A-Za-z0-9._\-]+/gi, '$1[REDACTED]')
1186
+ // sk-/key-like long opaque tokens (provider key prefixes + generic 32+ char tokens)
1187
+ s = s.replace(/\b(sk|sk-ant|pk|rk|gh[pousr]|xox[abprs]|ghp|ghs|ghu|ghr|github_pat)[-_][A-Za-z0-9_\-]{16,}/g, '[REDACTED]')
1188
+ s = s.replace(/\b[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}\b/g, '[REDACTED]')
1189
+ // --password=VALUE / --password VALUE / -p VALUE
1190
+ s = s.replace(/(--password(?:=|\s+)|--token(?:=|\s+)|--secret(?:=|\s+)|(?:^|\s)-p\s+)\S+/gi, '$1[REDACTED]')
1191
+ // URL userinfo: scheme://user:pass@host
1192
+ s = s.replace(/([a-zA-Z][a-zA-Z0-9+\-.]*:\/\/)([^\s/:@]+):([^\s/@]+)@/g, '$1[REDACTED]:[REDACTED]@')
1193
+ // Literal env secret values
1194
+ for (const v of secretEnvValues) {
1195
+ s = s.split(v).join('[REDACTED]')
1196
+ }
1197
+ } catch { /* best-effort: never throw out of redactSecrets */ }
1198
+ return s
1199
+ }
1200
+ const writeWorkerLogLine = (rawLine) => {
1201
+ // R14: sanitize AFTER redactSecrets so the redaction patterns still see
1202
+ // raw bytes (URL userinfo, Authorization headers), then strip ANSI / escape
1203
+ // lone CR + C0/C1 so a stderr line like "foo\r[fake-log-prefix] payload"
1204
+ // can't forge a new log entry or hide payloads behind a CR overwrite.
1205
+ const line = sanitizeLogField(redactSecrets(rawLine))
1206
+ const out = `[${new Date().toISOString()}] [${LOG_CONTEXT} worker=${name} workerPid=${proc.pid ?? '-'}] ${line}\n`
1207
+ try {
1208
+ if (!stderrStream) {
1209
+ stderrStream = createWriteStream(stderrPath, { flags: 'a' })
1210
+ stderrStream.on('error', () => {})
1211
+ }
1212
+ stderrStream.write(out)
1213
+ } catch {
1214
+ try { appendFileSync(stderrPath, out) } catch {}
1215
+ }
1216
+ try {
1217
+ if (!stderrScopedStream) {
1218
+ stderrScopedStream = createWriteStream(stderrScopedPath, { flags: 'a' })
1219
+ stderrScopedStream.on('error', () => {})
1220
+ }
1221
+ stderrScopedStream.write(out)
1222
+ } catch {
1223
+ try { appendFileSync(stderrScopedPath, out) } catch {}
1224
+ }
1225
+ }
1226
+ const writeWorkerLogChunk = (chunk) => {
1227
+ const text = stderrRemainder + String(chunk)
1228
+ const lines = text.split(/\r?\n/)
1229
+ stderrRemainder = lines.pop() ?? ''
1230
+ for (const line of lines) writeWorkerLogLine(line)
1231
+ }
1232
+ const closeWorkerLog = () => {
1233
+ if (stderrRemainder) {
1234
+ writeWorkerLogLine(stderrRemainder)
1235
+ stderrRemainder = ''
1236
+ }
1237
+ try { stderrStream?.end() } catch {}
1238
+ try { stderrScopedStream?.end() } catch {}
1239
+ }
1240
+ proc.stderr?.setEncoding?.('utf8')
1241
+ proc.stderr?.on('data', writeWorkerLogChunk)
1242
+ proc.stderr?.on('error', () => {})
1243
+ proc.once('exit', closeWorkerLog)
1244
+
1245
+ const entry = { proc, ready: false, pending: [] }
1246
+ // readyPromise lets callWorker await the worker's first 'ready' IPC instead
1247
+ // of rejecting immediately on entry.ready===false. Pre-ready callers (e.g.
1248
+ // SessionStart /cycle1) used to bounce off a 503 and rely on the hook's
1249
+ // 200ms retry loop; now they hold a single in-flight call until the worker
1250
+ // signals ready or the proc exits before that.
1251
+ entry.readyPromise = new Promise((resolve, reject) => {
1252
+ entry._resolveReady = resolve
1253
+ entry._rejectReady = reject
1254
+ })
1255
+ // Attach a no-op catch so a reject before any awaiter is hooked up does not
1256
+ // trigger unhandledRejection. The actual awaiter (callWorker) still observes
1257
+ // the rejection because await on a rejected promise re-throws.
1258
+ entry.readyPromise.catch(() => {})
1259
+ workers.set(name, entry)
1260
+
1261
+ proc.on('message', msg => {
1262
+ // R13: validate worker IPC frame shape before dispatch. A malformed frame
1263
+ // (null, array, primitive, or missing string type) used to throw at the
1264
+ // first field access or misroute into a privileged branch. Reject early.
1265
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg) || typeof msg.type !== 'string') {
1266
+ try { process.stderr.write(`[worker-ipc] dropped malformed frame from worker=${name}\n`) } catch {}
1267
+ return
1268
+ }
1269
+ try {
1270
+ if (msg.type === 'ready') {
1271
+ process.stderr.write(`[boot-time] tag=worker-ready name=${name} tMs=${Date.now()}\n`)
1272
+ if (msg.degraded) {
1273
+ log(`worker ${name} signalled degraded on boot: ${msg.error || 'unknown'}`)
1274
+ // Treat init failures as permanent (no retries): init errors indicate
1275
+ // unrecoverable state (e.g. pgdata corruption, missing schema) that
1276
+ // will not heal across restarts. Mark restart count at cap immediately
1277
+ // so the 'exit' handler skips respawn. This avoids 3 pointless retries
1278
+ // that each take several seconds and leave pgdata in a worse state.
1279
+ // Transient network / port-bind errors are expected to NOT send
1280
+ // degraded:true — they crash the worker without a 'ready' signal, so
1281
+ // the normal restart counter handles them.
1282
+ workerPermanentlyDegraded.add(name) // permanent — exit handler skips respawn
1283
+ try { entry._rejectReady(new Error(`worker ${name} degraded: ${msg.error || 'init failed'}`)) } catch {}
1284
+ return
1285
+ }
1286
+ // Cache the channels worker's detected channel flag. Daemon ancestry is
1287
+ // constant for the daemon's lifetime, so respawned workers can inherit
1288
+ // this via the env spread and skip the (slow) ancestor-process walk.
1289
+ if (typeof msg.channelFlag === 'boolean') {
1290
+ process.env.MIXDOG_CHANNEL_FLAG = msg.channelFlag ? '1' : '0'
1291
+ }
1292
+ entry.ready = true
1293
+ workerIntentionalStop.delete(name)
1294
+ workerRestarts.delete(name) // stable boot resets the backoff/warn counter
1295
+ try { entry._resolveReady() } catch {}
1296
+ log(`worker ${name} ready (pid=${proc.pid})`)
1297
+ if (name === 'memory' && Number.isFinite(msg.port) && msg.port > 0) {
1298
+ const file = ACTIVE_INSTANCE_FILE
1299
+ const memoryServerPid = parsePositivePid(process.pid)
1300
+ // EPERM/EBUSY/EACCES on rename means an AV scanner briefly holds
1301
+ // the new .tmp file open while inspecting it. The lock typically
1302
+ // clears within 100-300ms. Without retry the merge is lost and
1303
+ // statusline / memory_port discovery falls back to stale state for
1304
+ // the rest of the process lifetime. Async retry chain keeps the
1305
+ // message handler unblocked while the next attempts run.
1306
+ const _retryMerge = (attempt) => {
1307
+ try {
1308
+ withFileLockSync(`${file}.lock`, () => {
1309
+ let cur = {}
1310
+ try { cur = JSON.parse(readFileSync(file, 'utf8')) } catch {}
1311
+ const curMemPort = Number(cur?.memory_port)
1312
+ const curMemPid = parsePositivePid(cur?.memory_server_pid)
1313
+ const portConflict =
1314
+ Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== msg.port
1315
+ const otherOwnerAlive =
1316
+ curMemPid != null &&
1317
+ curMemPid !== memoryServerPid &&
1318
+ isMemoryOwnerPidAlive(curMemPid)
1319
+ if (portConflict && otherOwnerAlive) {
1320
+ log(`[server-main] skip memory_port ready merge port=${msg.port} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${memoryServerPid || 'none'}`)
1321
+ return
1322
+ }
1323
+ const next = {
1324
+ ...cur,
1325
+ memory_port: msg.port,
1326
+ memory_server_pid: memoryServerPid,
1327
+ updatedAt: Date.now(),
1328
+ }
1329
+ writeJsonAtomicSync(file, next, { compact: true, fsyncDir: true })
1330
+ })
1331
+ } catch (e) {
1332
+ const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
1333
+ if (transient && attempt < 3) {
1334
+ setTimeout(() => _retryMerge(attempt + 1), 50 * (attempt + 1))
1335
+ return
1336
+ }
1337
+ log(`[server-main] active-instance memory_port merge failed (attempt ${attempt + 1}): ${e?.message || e}`)
1338
+ }
1339
+ }
1340
+ _retryMerge(0)
1341
+ }
1342
+ return
1343
+ }
1344
+ if (msg.type === 'result' && typeof msg.callId === 'string' &&
1345
+ (Object.prototype.hasOwnProperty.call(msg, 'result') || typeof msg.error === 'string')) {
1346
+ // Clear any pending force-kill timer for this callId: a late result
1347
+ // after cooperative cancel means the worker is healthy and the
1348
+ // 5s SIGTERM timer from callWorker's timeout path must not fire.
1349
+ const killTimer = entry.killTimers?.get(msg.callId)
1350
+ if (killTimer) {
1351
+ clearTimeout(killTimer)
1352
+ entry.killTimers.delete(msg.callId)
1353
+ }
1354
+ const pending = entry.pending.find(p => p.callId === msg.callId)
1355
+ if (pending) {
1356
+ entry.pending = entry.pending.filter(p => p.callId !== msg.callId)
1357
+ if (msg.error) pending.reject(new Error(msg.error))
1358
+ else pending.resolve(msg.result)
1359
+ }
1360
+ return
1361
+ }
1362
+ if (msg.type === 'recap_status' && msg.recap) {
1363
+ recapStatusState = sanitizeRecapStatusState(msg.recap)
1364
+ forwardRecapStatusToStatusServer()
1365
+ return
1366
+ }
1367
+ if (msg.type === 'notify' && typeof msg.method === 'string') {
1368
+ // Worker → parent notification forwarding. The worker has no MCP
1369
+ // transport of its own; this is the single path that delivers Discord
1370
+ // inbound, schedule injects, webhook events, and interaction events
1371
+ // to the host (Claude Code) over the parent's connected Server.
1372
+ if (daemonNotifyRouter) {
1373
+ // Daemon mode: route to the request's origin connection (permission
1374
+ // responses) or the Lead connection (channel events). No stdio.
1375
+ daemonNotifyRouter.route(msg.method, msg.params || {})
1376
+ .catch(err => {
1377
+ log(`worker ${name} notify route failed (${msg.method}): ${err instanceof Error ? err.message : String(err)}`)
1378
+ })
1379
+ } else if (process.stdout.writableEnded || !process.stdout.writable) {
1380
+ log(`worker ${name} notify forward skipped — stdout closed (${msg.method})`)
1381
+ } else {
1382
+ server.notification(channelNotifyParamsForCc({ method: msg.method, params: msg.params || {} }))
1383
+ .catch(err => {
1384
+ log(`worker ${name} notify forward failed (${msg.method}): ${err instanceof Error ? err.message : String(err)}`)
1385
+ })
1386
+ }
1387
+ return
1388
+ }
1389
+ if (msg.type === 'agent_ipc_request' && typeof msg.callId === 'string' && typeof msg.tool === 'string') {
1390
+ // Worker → parent bridge LLM request. Memory worker cannot own the
1391
+ // provider registry / session manager (those live in the parent
1392
+ // process via loadModule('agent')), so cycle1 / cycle2 route every
1393
+ // LLM call here. We run the bridge call in-process, then ship the
1394
+ // raw assistant content back to the caller.
1395
+ _enqueueAgentIpcRequest(msg, name, proc)
1396
+ return
1397
+ }
1398
+ if (msg.type === 'agent_ipc_cancel' && typeof msg.callId === 'string') {
1399
+ // Worker timeout fired — stop the in-flight bridge LLM call before
1400
+ // it bills further tokens. parentSignal cascade aborts the
1401
+ // sub-session's own controller (bridge-llm.mjs:217-224).
1402
+ const _entry = _agentIpcInflight.get(msg.callId)
1403
+ if (_entry) {
1404
+ try { _entry.ctrl.abort() } catch {}
1405
+ _agentIpcInflight.delete(msg.callId)
1406
+ return
1407
+ }
1408
+ _cancelQueuedAgentIpc(msg.callId, proc)
1409
+ return
1410
+ }
1411
+ if (msg.type === 'memory_call_request' && msg.callId) {
1412
+ // Worker → parent → memory worker bridge. Lets non-memory workers
1413
+ // (e.g. channels) trigger memory tool actions like cycle1 without
1414
+ // owning the memory worker handle directly.
1415
+ // Worker handleToolCall only knows mcp tool names ('memory',
1416
+ // 'search_memories'); the action ('cycle1', 'flush', ...) lives in
1417
+ // args.action. Forwarding msg.action as the tool name made every
1418
+ // /cycle1 hit return "unknown tool: cycle1" instantly.
1419
+ // asyncAck: caller (e.g. channels worker chat ingest) opts in to an
1420
+ // immediate ack so it doesn't block on long-running memory work like
1421
+ // cycle2 / flush. Default path keeps the await semantics so cold-entry
1422
+ // cycle1 / recap flows still get the real result before continuing.
1423
+ if (msg.asyncAck) {
1424
+ try { proc.send({ type: 'memory_call_response', callId: msg.callId, ok: true, result: { acked: true } }) } catch {}
1425
+ callWorker('memory', 'memory', { action: msg.action, ...(msg.args || {}) })
1426
+ .catch(err => process.stderr.write(`[memory_call] async ${msg.action} rejected: ${err?.message || err}\n`))
1427
+ return
1428
+ }
1429
+ callWorker('memory', 'memory', { action: msg.action, ...(msg.args || {}) })
1430
+ .then(result => {
1431
+ try { proc.send({ type: 'memory_call_response', callId: msg.callId, ok: true, result }) } catch {}
1432
+ })
1433
+ .catch(err => {
1434
+ try { proc.send({ type: 'memory_call_response', callId: msg.callId, ok: false, error: err?.message || String(err) }) } catch {}
1435
+ })
1436
+ return
1437
+ }
1438
+ } catch (err) {
1439
+ try { process.stderr.write(`[worker-ipc] handler error worker=${name} type=${msg && msg.type}: ${err?.message || err}\n`) } catch {}
1440
+ }
1441
+ })
1442
+
1443
+ // Attach 'exit' before 'error' so a synchronous spawn-fail sees 'exit'
1444
+ // before 'error' — prevents dangling exit handler on early-fail path.
1445
+ proc.on('exit', (code) => {
1446
+ log(`worker ${name} exited (code=${code})`)
1447
+ workers.delete(name)
1448
+ // Abort any in-flight bridge LLM provider calls owned by this worker so
1449
+ // a dying worker stops billing tokens for results nobody is waiting on.
1450
+ let _abortedIpc = 0
1451
+ for (const [_cid, _e] of _agentIpcInflight) {
1452
+ if (_e.worker === name) {
1453
+ try { _e.ctrl.abort() } catch {}
1454
+ _agentIpcInflight.delete(_cid)
1455
+ _abortedIpc++
1456
+ }
1457
+ }
1458
+ if (_abortedIpc > 0) log(`worker ${name} exit — aborted ${_abortedIpc} in-flight agent_ipc call(s)`)
1459
+ _purgeAgentIpcForWorker(name)
1460
+ // Intentional stop: parent sent shutdown IPC/SIGTERM — do not respawn.
1461
+ if (workerIntentionalStop.has(name)) {
1462
+ log(`worker ${name} stopped intentionally — skipping respawn`)
1463
+ return
1464
+ }
1465
+ if (!entry.ready) {
1466
+ try { entry._rejectReady(new Error(`worker ${name} exited before ready (code=${code})`)) } catch {}
1467
+ }
1468
+ for (const p of entry.pending) {
1469
+ p.reject(new Error(`worker ${name} exited unexpectedly`))
1470
+ }
1471
+ if (workerPermanentlyDegraded.has(name)) {
1472
+ log(`worker ${name} permanently degraded — skipping respawn`)
1473
+ return
1474
+ }
1475
+ const count = (workerRestarts.get(name) || 0) + 1
1476
+ workerRestarts.set(name, count)
1477
+ const backoffMs = Math.min(1000 * Math.pow(2, Math.min(count - 1, 6)), WORKER_MAX_BACKOFF_MS)
1478
+ if (count <= WORKER_RESTART_WARN_AFTER) {
1479
+ log(`restarting worker ${name} (attempt ${count}, backoff ${backoffMs}ms)`)
1480
+ } else {
1481
+ log(`[WARN] worker ${name} repeated restart (attempt ${count}, backoff ${backoffMs}ms) — investigate root cause`)
1482
+ }
1483
+ setTimeout(() => spawnWorker(name), backoffMs)
1484
+ })
1485
+
1486
+ proc.on('error', (err) => {
1487
+ log(`worker ${name} error: ${err.message}`)
1488
+ })
1489
+
1490
+ // IPC disconnect handler: if the channel drops while calls are in flight,
1491
+ // reject pending immediately with a stable message so callers don't wait
1492
+ // for the full WORKER_CALL_TIMEOUT before surfacing an error.
1493
+ proc.once('disconnect', () => {
1494
+ const snap = [...entry.pending]
1495
+ entry.pending = []
1496
+ for (const p of snap) {
1497
+ p.reject(new Error(`worker ${name} disconnected`))
1498
+ }
1499
+ if (!entry.ready) {
1500
+ try { entry._rejectReady(new Error(`worker ${name} disconnected before ready`)) } catch {}
1501
+ }
1502
+ // Abort in-flight bridge LLM provider calls owned by this worker — the
1503
+ // IPC channel is gone so the response can never be delivered; letting
1504
+ // the provider call run on would only burn tokens.
1505
+ let _abortedIpc = 0
1506
+ for (const [_cid, _e] of _agentIpcInflight) {
1507
+ if (_e.worker === name) {
1508
+ try { _e.ctrl.abort() } catch {}
1509
+ _agentIpcInflight.delete(_cid)
1510
+ _abortedIpc++
1511
+ }
1512
+ }
1513
+ _purgeAgentIpcForWorker(name)
1514
+ log(`worker ${name} IPC disconnected — rejected ${snap.length} pending call(s), aborted ${_abortedIpc} agent_ipc call(s)`)
1515
+ })
1516
+
1517
+ return entry
1518
+ }
1519
+
1520
+ let _callIdSeq = 0
1521
+ const WORKER_CALL_TIMEOUT = 600000 // 10m per tool call
1522
+ // Window for awaiting a missing worker entry. Covers the 1s exit→spawn
1523
+ // timer plus typical memory boot (~2-3s). Long enough that the entry
1524
+ // reappears under normal restart flow, short enough that a permanently
1525
+ // dead worker still surfaces within bounds.
1526
+ const WORKER_NO_ENTRY_GRACE_MS = 8000
1527
+
1528
+ async function callWorker(name, toolName, args) {
1529
+ let entry = workers.get(name)
1530
+ // worker-unavailable: only restart-cap-exceeded and ipc-gone cases reject
1531
+ // synchronously. The pre-ready and mid-restart cases hold under bounded
1532
+ // waits so callers (e.g. SessionStart /cycle1) stop bouncing 503 across
1533
+ // the exit→spawn gap. exit-before-ready rejects readyPromise, which
1534
+ // surfaces here as a normal throw with the original 'exited before ready'
1535
+ // message preserved.
1536
+ if (!entry) {
1537
+ if (workerPermanentlyDegraded.has(name)) {
1538
+ throw new Error(`worker ${name} not available (permanently degraded)`)
1539
+ }
1540
+ const deadline = Date.now() + WORKER_NO_ENTRY_GRACE_MS
1541
+ while (Date.now() < deadline) {
1542
+ await new Promise(r => setTimeout(r, 100))
1543
+ entry = workers.get(name)
1544
+ if (entry) break
1545
+ if (workerPermanentlyDegraded.has(name)) {
1546
+ throw new Error(`worker ${name} not available (permanently degraded)`)
1547
+ }
1548
+ }
1549
+ if (!entry) {
1550
+ throw new Error(`worker ${name} not available (no entry after ${WORKER_NO_ENTRY_GRACE_MS}ms)`)
1551
+ }
1552
+ }
1553
+ if (!entry.proc.connected) {
1554
+ throw new Error(`worker ${name} not available (ipc disconnected)`)
1555
+ }
1556
+ if (!entry.ready) {
1557
+ // Bound the readyPromise wait: a worker process can be alive (no exit
1558
+ // event, no IPC disconnect) yet never send its 'ready' IPC if init
1559
+ // hangs (DB connect stall, vector index rebuild). Without a deadline
1560
+ // here, worker-to-worker calls would block indefinitely.
1561
+ const READY_WAIT_MS = 30000
1562
+ let readyTimer = null
1563
+ const readyTimeout = new Promise((_, reject) => {
1564
+ readyTimer = setTimeout(() => {
1565
+ reject(new Error(`worker ${name} not ready within ${READY_WAIT_MS}ms`))
1566
+ }, READY_WAIT_MS)
1567
+ readyTimer.unref?.()
1568
+ })
1569
+ try {
1570
+ await Promise.race([entry.readyPromise, readyTimeout])
1571
+ } finally {
1572
+ if (readyTimer) clearTimeout(readyTimer)
1573
+ }
1574
+ if (!entry.proc.connected) {
1575
+ throw new Error(`worker ${name} not available (ipc disconnected)`)
1576
+ }
1577
+ }
1578
+ return new Promise((resolve, reject) => {
1579
+ const callId = String(++_callIdSeq)
1580
+ const timer = setTimeout(() => {
1581
+ entry.pending = entry.pending.filter(p => p.callId !== callId)
1582
+ // Signal the worker to cancel in-flight work for this callId.
1583
+ try {
1584
+ if (entry.proc?.connected) {
1585
+ entry.proc.send({ type: 'cancel', callId })
1586
+ // Force-kill if worker doesn't ack within 5s.
1587
+ // Track the kill timer on the entry keyed by callId so the IPC
1588
+ // result handler can clear it when the worker delivers a late
1589
+ // result (cooperative cancel succeeded after we gave up waiting).
1590
+ // Without this clear, a single timed-out call always killed the
1591
+ // whole worker even when it returned a clean result in time.
1592
+ if (!entry.killTimers) entry.killTimers = new Map()
1593
+ const killTimer = setTimeout(() => {
1594
+ entry.killTimers?.delete(callId)
1595
+ log(`worker ${name} did not ack cancel for ${callId} — force-killing`)
1596
+ try { entry.proc.kill('SIGTERM') } catch {}
1597
+ }, 5000)
1598
+ if (killTimer.unref) killTimer.unref()
1599
+ entry.killTimers.set(callId, killTimer)
1600
+ }
1601
+ } catch {}
1602
+ // Persist dispatch state so recoverPending surfaces it on next boot.
1603
+ const _dataDir = process.env.CLAUDE_PLUGIN_DATA
1604
+ if (_dataDir) {
1605
+ import('./src/agent/orchestrator/dispatch-persist.mjs').then(({ addPending }) => {
1606
+ addPending(_dataDir, `timeout_${callId}_${Date.now()}`, 'bridge', [`worker ${name} call ${toolName} timed out`])
1607
+ }).catch(() => {})
1608
+ }
1609
+ reject(new Error(`worker ${name} call ${toolName} timed out after ${WORKER_CALL_TIMEOUT}ms`))
1610
+ }, WORKER_CALL_TIMEOUT)
1611
+ entry.pending.push({ callId, resolve: v => { clearTimeout(timer); resolve(v) }, reject: e => { clearTimeout(timer); reject(e) } })
1612
+ try {
1613
+ // child.send() returning false means the IPC channel applied
1614
+ // backpressure — the message is queued internally by Node and will be
1615
+ // flushed when the channel drains; it is NOT a delivery failure.
1616
+ // Rejecting here while the worker may still receive (and execute)
1617
+ // the call leaves the parent waiting on a pending it just forgot
1618
+ // about, AND lets a side-effecting tool run with the parent
1619
+ // believing it failed. Keep the pending entry in place and let the
1620
+ // existing WORKER_CALL_TIMEOUT bound the wait if the channel never
1621
+ // drains; an actual transport failure surfaces through the catch
1622
+ // below or via the 'exit'/'disconnect' handlers that reject pending.
1623
+ entry.proc.send({ type: 'call', callId, name: toolName, args })
1624
+ } catch (sendErr) {
1625
+ clearTimeout(timer)
1626
+ entry.pending = entry.pending.filter(p => p.callId !== callId)
1627
+ reject(new Error(`worker ${name} send failed: ${sendErr.message}`))
1628
+ }
1629
+ })
1630
+ }
1631
+
1632
+ // ── Module loader (cached, init+start runs once per module) ─────────
1633
+ const modules = new Map()
1634
+
1635
+ function pushChannelNotification(content, extraMeta) {
1636
+ // Single exit path for BOTH channel notifications (schedule / webhook /
1637
+ // queue / bridge lifecycle) AND dispatch results (recall / search
1638
+ // / explore merged answers tagged `meta.type: 'dispatch_result'`). Despite
1639
+ // the name, this function is bidirectional — the `extraMeta.type` field
1640
+ // distinguishes the two flavours for downstream routing, not this function.
1641
+ //
1642
+ // `silent_to_agent: true` — bridge lifecycle status pings (worker started,
1643
+ // iter N, role-start echoes) that should surface on Discord but NOT land
1644
+ // in the Lead agent's context window. When set we skip the Lead-notify
1645
+ // hop entirely and ask the channels worker to post the content directly
1646
+ // to the currently-active bridge channel. The meta flag is otherwise
1647
+ // forwarded downstream so any future consumer that sees it can recognise
1648
+ // and drop it. Default (flag absent/false) → legacy behaviour preserved.
1649
+ const meta = { user: 'mixdog-agent', user_id: 'system', ts: new Date().toISOString(), ...(extraMeta || {}) }
1650
+ const silent = meta.silent_to_agent === true
1651
+ if (silent) {
1652
+ const entry = workers.get('channels')
1653
+ if (entry?.proc?.connected) {
1654
+ try {
1655
+ const sent = entry.proc.send({ type: 'forward_to_discord', content, channelId: meta.chat_id || null })
1656
+ if (sent === false) {
1657
+ log(`[agent-notify] silent forward IPC channel full or closed — dropping`)
1658
+ }
1659
+ } catch (err) {
1660
+ log(`[agent-notify] silent forward IPC failed: ${err instanceof Error ? err.message : String(err)}`)
1661
+ }
1662
+ }
1663
+ return Promise.resolve()
1664
+ }
1665
+ // Daemon mode: the module-global `server` is NOT connected (each client has
1666
+ // its own per-connection server). Route through the daemon notify router,
1667
+ // which delivers a session-scoped result (meta.caller_session_id) to the
1668
+ // dispatching terminal and a channel event to the active-instance owner.
1669
+ // Without this, the server.notification() below targets an unconnected
1670
+ // server and the notification is silently lost.
1671
+ //
1672
+ // If a SESSION-scoped dispatch result could not be delivered (originating
1673
+ // terminal not connected), reject so the persist layer's notify→removePending
1674
+ // chain LEAVES the entry on disk; per-session recoverPending re-delivers it
1675
+ // when that terminal reconnects (control-frame trigger in serveDaemon).
1676
+ // Channel events (no caller_session_id) just resolve — a missing owner drop
1677
+ // is surfaced again via the queue / Discord, not the pending file.
1678
+ if (daemonNotifyRouter) {
1679
+ return daemonNotifyRouter.route('notifications/claude/channel', { content, meta })
1680
+ .then((delivered) => {
1681
+ if (!delivered && (meta.caller_session_id != null || meta.type === 'dispatch_result')) {
1682
+ throw new Error('dispatch result for session ' + meta.caller_session_id + ' undelivered — retained for replay')
1683
+ }
1684
+ })
1685
+ }
1686
+ // Pre-flight: if stdout is already closed, skip the write to avoid EPIPE.
1687
+ if (process.stdout.writableEnded || !process.stdout.writable) {
1688
+ log('[agent-notify] stdout closed; skipping notification')
1689
+ return Promise.resolve()
1690
+ }
1691
+ try {
1692
+ return server.notification(channelNotifyParamsForCc({
1693
+ method: 'notifications/claude/channel',
1694
+ params: { content, meta },
1695
+ })).catch(err => {
1696
+ log(`[agent-notify] channel failed: ${err instanceof Error ? err.message : String(err)}`)
1697
+ })
1698
+ } catch (err) {
1699
+ log(`[agent-notify] sync throw (likely EPIPE): ${err instanceof Error ? err.message : String(err)}`)
1700
+ return Promise.resolve()
1701
+ }
1702
+ }
1703
+
1704
+ function agentContext() {
1705
+ return {
1706
+ notifyFn: (text, extraMeta) => pushChannelNotification(text, extraMeta),
1707
+ elicitFn: opts => server.elicitInput(opts),
1708
+ // In-process tool bridge. External LLMs see the plugin's non-agent tools
1709
+ // (search, search_memories, channels actions, etc.) and their tool_calls
1710
+ // land back in dispatchTool, which routes to the same worker IPC /
1711
+ // in-process module the MCP call handler uses. Replaces the MCP HTTP
1712
+ // loopback path. agent-module tools are refused to prevent recursion.
1713
+ toolExecutor: async (name, args, callerCtx = {}) => {
1714
+ // agent-module tools normally refused via bridge to prevent recursion.
1715
+ // Exception: aiWrapped retrieval wrappers (`explore`) spawn one hidden
1716
+ // role and are guarded against re-entry in ai-wrapped-dispatch.mjs —
1717
+ // safe to dispatch from public bridge workers for open-locate briefs.
1718
+ if (TOOL_MODULE[name] === 'agent' && !TOOL_BY_NAME[name]?.aiWrapped) {
1719
+ throw new Error(`tool "${name}" is agent-internal and cannot be invoked via bridge`)
1720
+ }
1721
+ return dispatchTool(name, args, callerCtx)
1722
+ },
1723
+ internalTools: TOOL_DEFS.filter(t => {
1724
+ // Same exception as toolExecutor above: agent-module aiWrapped tools
1725
+ // (`explore`) are surfaced to public workers; plain agent-module tools
1726
+ // remain hidden to prevent recursion.
1727
+ if (t.module === 'agent') return t.aiWrapped === true
1728
+ return true
1729
+ }),
1730
+ }
1731
+ }
1732
+
1733
+ async function loadModule(name) {
1734
+ let entry = modules.get(name)
1735
+ if (entry) return entry
1736
+ const url = pathToFileURL(join(PLUGIN_ROOT, 'src', name, 'index.mjs')).href
1737
+ const mod = await import(url)
1738
+ if (mod.init) await mod.init(server)
1739
+ if (mod.start) await mod.start()
1740
+ entry = mod
1741
+ modules.set(name, entry)
1742
+ log(`module ${name} ready`)
1743
+ return entry
1744
+ }
1745
+
1746
+ // Tilde expansion for caller-supplied `cwd`. Mirrors the `~` branch of
1747
+ // normalizeInputPath() in builtin.mjs but kept inline so the dispatcher
1748
+ // does not have to pre-load the whole builtin module at boot.
1749
+ function _expandCwdTilde(p) {
1750
+ if (typeof p !== 'string') return p
1751
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) return homedir() + p.slice(1)
1752
+ return p
1753
+ }
1754
+
1755
+ // Shared dispatcher — used by the MCP call handler AND the agent's
1756
+ // toolExecutor passed through agentContext(). Single source of tool routing.
1757
+ // Public entry wraps body with start/end/error logs so BOTH call paths
1758
+ // (MCP CallToolRequest + bridge role toolExecutor) emit complete telemetry.
1759
+ function _shortHash(value) {
1760
+ return createHash('sha256').update(String(value || '')).digest('hex').slice(0, 8)
1761
+ }
1762
+
1763
+ // Re-exec tracking for bash: same cmdHash arriving within TTL is recorded
1764
+ // as a marker line, so silent client-side response loss (server logged
1765
+ // start+ok but the MCP transport never delivered the result to the host)
1766
+ // surfaces as the same command being re-issued. Pure Map lookup; no
1767
+ // heuristic branch — the marker fires iff a prior entry exists within
1768
+ // TTL. Bound size; oldest-first eviction is an invariant of Map insertion
1769
+ // order.
1770
+ const _RECENT_BASH_TTL_MS = 10 * 60_000
1771
+ const _RECENT_BASH_MAX = 500
1772
+ const _recentBashCommands = new Map()
1773
+
1774
+ function _trackBashRecurrence(cmdHash, now) {
1775
+ if (!cmdHash) return null
1776
+ const prev = _recentBashCommands.get(cmdHash)
1777
+ let marker = null
1778
+ if (prev && now - prev.ts <= _RECENT_BASH_TTL_MS) {
1779
+ const gap = now - prev.ts
1780
+ prev.count += 1
1781
+ prev.ts = now
1782
+ _recentBashCommands.delete(cmdHash)
1783
+ _recentBashCommands.set(cmdHash, prev)
1784
+ marker = `[bash] re-exec same-hash cmdHash=${cmdHash} gap=${gap}ms count=${prev.count}`
1785
+ } else {
1786
+ if (prev) _recentBashCommands.delete(cmdHash)
1787
+ _recentBashCommands.set(cmdHash, { ts: now, count: 1 })
1788
+ }
1789
+ while (_recentBashCommands.size > _RECENT_BASH_MAX) {
1790
+ const firstKey = _recentBashCommands.keys().next().value
1791
+ if (firstKey === undefined) break
1792
+ _recentBashCommands.delete(firstKey)
1793
+ }
1794
+ return marker
1795
+ }
1796
+
1797
+ function _flatPreview(value, cap = 80) {
1798
+ return String(value || '').replace(/\s+/g, ' ').trim().slice(0, cap).replace(/[^\x20-\x7e]/g, '?')
1799
+ }
1800
+
1801
+ function _dispatchInputShape(name, args) {
1802
+ try {
1803
+ if (name === 'bash') {
1804
+ const command = String(args?.command || '')
1805
+ return command ? ` cmdHash=${_shortHash(command)} cmdPreview="${_flatPreview(command)}"` : ''
1806
+ }
1807
+ if (name === 'search') {
1808
+ const hasUrl = args && Object.prototype.hasOwnProperty.call(args, 'url') && args.url !== undefined
1809
+ const input = hasUrl ? args.url : (args?.query ?? args?.keywords ?? '')
1810
+ const count = Array.isArray(input) ? input.length : input ? 1 : 0
1811
+ const mode = hasUrl ? 'url' : 'query'
1812
+ const site = args?.site ? ` site="${_flatPreview(args.site, 60)}"` : ''
1813
+ const type = args?.type ? ` type=${_flatPreview(args.type, 20)}` : ''
1814
+ return ` mode=${mode} itemCount=${count} inputHash=${_shortHash(JSON.stringify(input))}${site}${type}`
1815
+ }
1816
+ } catch {}
1817
+ return ''
1818
+ }
1819
+
1820
+ function _bashDispatchCeilingMs(args) {
1821
+ const MAX_BASH_TIMEOUT_MS = 1_800_000
1822
+ const DISPATCH_GRACE_MS = 30_000
1823
+ if (!(typeof args?.timeout === 'number' && args.timeout > 0)) {
1824
+ return null
1825
+ }
1826
+ const raw = args.timeout
1827
+ const timeoutMs = raw <= 600 ? raw * 1000 : raw
1828
+ const effectiveTimeoutMs = Math.min(Math.max(timeoutMs, 1_000), MAX_BASH_TIMEOUT_MS)
1829
+ return effectiveTimeoutMs + DISPATCH_GRACE_MS
1830
+ }
1831
+
1832
+ // Build the cancellation/ceiling wiring for a dispatch: ceiling timer +
1833
+ // combined abort signal that folds caller-provided abortSignal and the MCP
1834
+ // requestSignal together with the ceiling's own AbortController. Returned
1835
+ // `clearCeiling` is idempotent so both the success and error paths in
1836
+ // dispatchTool can call it. Preserves the recent fixes (killTimer/ceiling
1837
+ // clearing and combined abort signal) by routing every cleanup through
1838
+ // the same closure.
1839
+ function _buildDispatchCancellation(name, args, callerCtx) {
1840
+ const CEILING_MS = name === 'bash' ? _bashDispatchCeilingMs(args) : 630_000
1841
+ const _abortCtl = new AbortController()
1842
+ let _ceilingTimer
1843
+ let _ceilingPromise = null
1844
+ if (typeof CEILING_MS === 'number' && CEILING_MS > 0) {
1845
+ _ceilingPromise = new Promise((_, reject) => {
1846
+ _ceilingTimer = setTimeout(() => {
1847
+ try { _abortCtl.abort(new Error(`dispatch ceiling exceeded (${CEILING_MS}ms) for tool=${name}`)) } catch {}
1848
+ reject(new Error(`dispatch ceiling exceeded (${CEILING_MS}ms) for tool=${name}`))
1849
+ }, CEILING_MS)
1850
+ _ceilingTimer.unref?.()
1851
+ })
1852
+ }
1853
+ // Combine ceiling abort with any caller-provided abortSignal so neither
1854
+ // masks the other. Both signals propagate to the tool impl; whichever
1855
+ // aborts first wins. Node 20.3+ provides AbortSignal.any.
1856
+ // Also fold in callerCtx.requestSignal (the MCP client-side cancellation
1857
+ // forwarded from the CallTool handler) so direct MCP cancellation
1858
+ // reaches builtins / code-graph / patch paths that only inspect
1859
+ // callerCtx.abortSignal. Without this, the requestSignal was only
1860
+ // visible to the agent module via the explicit ctx.requestSignal handoff.
1861
+ const _abortSignals = []
1862
+ if (_ceilingPromise) _abortSignals.push(_abortCtl.signal)
1863
+ if (callerCtx.abortSignal) _abortSignals.push(callerCtx.abortSignal)
1864
+ if (callerCtx.requestSignal && callerCtx.requestSignal !== callerCtx.abortSignal) {
1865
+ _abortSignals.push(callerCtx.requestSignal)
1866
+ }
1867
+ const _combinedSignal = _abortSignals.length > 1
1868
+ ? AbortSignal.any(_abortSignals)
1869
+ : (_abortSignals[0] || null)
1870
+ const _ctxWithSignal = _combinedSignal
1871
+ ? { ...callerCtx, abortSignal: _combinedSignal }
1872
+ : callerCtx
1873
+ const clearCeiling = () => { if (_ceilingTimer) clearTimeout(_ceilingTimer) }
1874
+ return { ctxWithSignal: _ctxWithSignal, ceilingPromise: _ceilingPromise, clearCeiling }
1875
+ }
1876
+
1877
+ async function dispatchTool(name, args, callerCtx = {}) {
1878
+ const _t0 = Date.now()
1879
+ const _id = `${process.pid}-${_callIdSeq++}`
1880
+ // Diagnostic logging: every dispatch emits start + ok/error unconditionally
1881
+ // so any hang is visible as start-without-ok in mcp-debug.log. Writes go
1882
+ // direct to _logAppend, bypassing the pair-suppression wrapper.
1883
+ _logAppend(_logLine(`[dispatch] start id=${_id} tool=${name}${_dispatchInputShape(name, args)}`))
1884
+ if (name === 'bash') {
1885
+ const cmd = String(args?.command || '')
1886
+ if (cmd) {
1887
+ const marker = _trackBashRecurrence(_shortHash(cmd), _t0)
1888
+ if (marker) _logAppend(_logLine(marker))
1889
+ }
1890
+ }
1891
+ // Transport safety ceiling, not a tool-efficiency classifier. `bash` owns
1892
+ // its requested timeout; the dispatcher only adds grace so bash treeKill /
1893
+ // persistent-shell cleanup can settle before the outer race rejects. Other
1894
+ // tools keep a fixed ceiling to avoid orphaned dispatch slots.
1895
+ const { ctxWithSignal: _ctxWithSignal, ceilingPromise: _ceilingPromise, clearCeiling: _clearCeiling } =
1896
+ _buildDispatchCancellation(name, args, callerCtx)
1897
+ // Central live-progress emit: when the caller threaded a progress reporter
1898
+ // (MCP progressToken present), fire ONE per-tool start message here and mark
1899
+ // the downstream ctx so executeBuiltinTool's fallback emit does not double up.
1900
+ // No-op when ctxWithSignal.progress is null (no token) — reporter is null →
1901
+ // not a function → path stays byte-identical to the no-progress behaviour.
1902
+ let _ctxForImpl = _ctxWithSignal
1903
+ if (typeof _ctxWithSignal.progress === 'function') {
1904
+ try { _ctxWithSignal.progress(formatToolStartProgress(name, args)) } catch { /* progress is best-effort */ }
1905
+ _ctxForImpl = { ..._ctxWithSignal, progressStarted: true }
1906
+ }
1907
+ try {
1908
+ const _dispatchPromise = _dispatchToolImpl(name, args, _ctxForImpl)
1909
+ const _result = _ceilingPromise
1910
+ ? await Promise.race([_dispatchPromise, _ceilingPromise])
1911
+ : await _dispatchPromise
1912
+ _clearCeiling()
1913
+ const elapsed = Date.now() - _t0
1914
+ _logAppend(_logLine(`[dispatch] ok id=${_id} tool=${name} elapsed=${elapsed}ms`))
1915
+ // App-level errors travel as strings (`Error [code N]: ...`) without
1916
+ // raising — record them in tool-events.log so cross-session audits
1917
+ // can find read/edit invariant failures by grep.
1918
+ _recordToolApplicationError(name, _result)
1919
+ return _result
1920
+ } catch (err) {
1921
+ _clearCeiling()
1922
+ const msg = err instanceof Error ? err.message : String(err)
1923
+ _logAppend(_logLine(`[dispatch] error id=${_id} tool=${name} elapsed=${Date.now() - _t0}ms msg=${msg.slice(0, 200)}`))
1924
+ throw err
1925
+ }
1926
+ }
1927
+
1928
+ // Schema/lookup normalisation: tilde-expand a caller-supplied cwd once at
1929
+ // dispatch entry and resolve the tool definition. Throws the same
1930
+ // disabled-module vs unknown-tool error messages the inline branch did so
1931
+ // callers see no behaviour change.
1932
+ function _resolveDispatchToolDef(name, args) {
1933
+ // Normalise caller-supplied `cwd` once at the entry so every downstream
1934
+ // module (builtin / lsp / code_graph / patch / bash_session /
1935
+ // host_input / agent) receives the expanded path. Previously only the
1936
+ // agent ingresses (the unified `bridge` tool) ran tilde
1937
+ // expansion, so explore / list / grep / glob with a `~` cwd silently
1938
+ // fell back to process.cwd().
1939
+ if (args && typeof args.cwd === 'string') args.cwd = _expandCwdTilde(args.cwd)
1940
+ const def = TOOL_BY_NAME[name]
1941
+ if (!def) {
1942
+ // Distinguish "disabled module" from "unknown tool" so callers (and
1943
+ // the Lead) get an actionable message instead of a generic miss.
1944
+ const rawDef = RAW_TOOL_DEFS.find(t => t.name === name)
1945
+ if (rawDef && rawDef.module && MODULE_NAMES.includes(rawDef.module) && !isModuleEnabled(rawDef.module)) {
1946
+ throw new Error(`module '${rawDef.module}' is disabled — enable it in the setup UI (General → Modules) and restart the plugin`)
1947
+ }
1948
+ throw new Error(`Unknown tool: ${name}`)
1949
+ }
1950
+ return def
1951
+ }
1952
+
1953
+ // recall / search bypass the ai-wrapped dispatcher entirely. Both are
1954
+ // pure mechanical fan-out (array → worker handleSearch array branch /
1955
+ // search-backend Promise.allSettled), so the LLM-routing scaffolding
1956
+ // (makeBridgeLlm import, ROLE_BY_TOOL lookup, recursion guard) that
1957
+ // ai-wrapped still ships for explore adds no value here. Inlining the
1958
+ // worker / backend call also lets recall's array branch keep the
1959
+ // embedTexts pre-warm path (worker, not Lead, runs the batch ONNX call)
1960
+ // intact: previously the Lead-side dispatcher fanned out into N
1961
+ // single-query callMemoryWorker requests, which forced the worker into
1962
+ // its single-flight inference queue and re-introduced the per-query
1963
+ // stagger the embed-batch patch was meant to fix.
1964
+ //
1965
+ // search now also subsumes web_fetch: pass `url` (string or array) to
1966
+ // route to the fetch backend, `query` (string or array) to route to the
1967
+ // search backend. Exactly one of the two must be supplied; mixing them
1968
+ // is a contract violation since the two backends produce different
1969
+ // result shapes.
1970
+ function _capSyncRetrievalBody(text) {
1971
+ const bodyStr = typeof text === 'string' ? text : String(text ?? '')
1972
+ const bodyBytes = Buffer.byteLength(bodyStr, 'utf8')
1973
+ let bodyLines = bodyStr.length === 0 ? 0 : 1
1974
+ for (let i = 0; i < bodyStr.length; i += 1) {
1975
+ if (bodyStr.charCodeAt(i) === 10) bodyLines += 1
1976
+ }
1977
+ return smartReadTruncate(bodyStr, bodyLines, bodyBytes).text
1978
+ }
1979
+
1980
+ // ② completion progress (claude "Found N" parity) for recall. Counts the
1981
+ // `#<id>` entry markers in the rendered body; best-effort, no-op when
1982
+ // callerCtx.progress is absent (no progressToken). Never throws.
1983
+ function _emitRecallProgress(callerCtx, body) {
1984
+ if (typeof callerCtx?.progress !== 'function') return
1985
+ try {
1986
+ const _n = (String(body).match(/#\d+\b/g) || []).length
1987
+ callerCtx.progress(`recalled ${_n} memories`)
1988
+ } catch { /* best-effort */ }
1989
+ }
1990
+
1991
+ async function _dispatchRecallOrSearch(name, args, callerCtx = {}) {
1992
+ // MCP schema-less query/url field: some clients JSON-stringify arrays
1993
+ // when the inputSchema does not declare an explicit `type`. Parse
1994
+ // `'["a","b"]'` back into an array so fan-out works regardless of
1995
+ // how the caller serialized the input.
1996
+ const _maybeParseArray = (v) => {
1997
+ if (typeof v !== 'string') return v
1998
+ const trimmed = v.trim()
1999
+ if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return v
2000
+ try {
2001
+ const parsed = JSON.parse(trimmed)
2002
+ return Array.isArray(parsed) ? parsed : v
2003
+ } catch { return v }
2004
+ }
2005
+ const _toList = (v) => {
2006
+ if (v == null) return []
2007
+ if (Array.isArray(v)) return v.map(x => String(x ?? ''))
2008
+ return [String(v ?? '')]
2009
+ }
2010
+ if (name === 'recall') {
2011
+ const queries = _toList(_maybeParseArray(args?.query))
2012
+ // id mode (follow-up lookup): a previous recall returned `#N`
2013
+ // markers; passing them back here fetches the entry + its chunk
2014
+ // members directly, no ranked lookup/search. Accepts a single number or
2015
+ // array; strings are coerced. Mutually exclusive with `query`.
2016
+ const rawId = _maybeParseArray(args?.id)
2017
+ const idList = rawId == null
2018
+ ? []
2019
+ : (Array.isArray(rawId) ? rawId : [rawId])
2020
+ .map(v => Number(v))
2021
+ .filter(v => Number.isFinite(v) && v > 0)
2022
+ if (queries.length === 0 && idList.length === 0) {
2023
+ return { content: [{ type: 'text', text: '[recall] either `query` or `id` is required' }], isError: true }
2024
+ }
2025
+ if (queries.length > 0 && idList.length > 0) {
2026
+ return { content: [{ type: 'text', text: '[recall] specify either `query` or `id`, not both' }], isError: true }
2027
+ }
2028
+ const passThrough = {}
2029
+ if (args.period != null) passThrough.period = args.period
2030
+ if (args.limit != null) passThrough.limit = args.limit
2031
+ if (args.offset != null) passThrough.offset = args.offset
2032
+ if (args.sort != null) passThrough.sort = args.sort
2033
+ if (args.category != null) passThrough.category = args.category
2034
+ if (args.includeMembers != null) passThrough.includeMembers = args.includeMembers
2035
+ if (args.includeRaw != null) passThrough.includeRaw = args.includeRaw
2036
+ if (args.includeArchived != null) passThrough.includeArchived = args.includeArchived
2037
+ if (typeof args.projectScope === 'string' && args.projectScope) {
2038
+ passThrough.projectScope = args.projectScope
2039
+ } else {
2040
+ passThrough.cwd = (typeof args.cwd === 'string' && args.cwd) ? args.cwd : (callerCtx.callerCwd || pwd())
2041
+ }
2042
+ if (idList.length > 0) {
2043
+ const result = await callWorker('memory', 'memory', { action: 'search', ids: idList, ...passThrough })
2044
+ const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
2045
+ _emitRecallProgress(callerCtx, body)
2046
+ return { content: [{ type: 'text', text: body }] }
2047
+ }
2048
+ // Single-query stays a string so the worker's single branch runs;
2049
+ // multi-query goes in as an array so the worker's array branch runs
2050
+ // (pre-warm embedTexts + Promise.all sub-search inside one process).
2051
+ const queryArg = queries.length > 1 ? queries : queries[0]
2052
+ const result = await callWorker('memory', 'memory', { action: 'search', query: queryArg, ...passThrough })
2053
+ const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
2054
+ _emitRecallProgress(callerCtx, body)
2055
+ return { content: [{ type: 'text', text: body }] }
2056
+ }
2057
+ // search — query (text) vs url (fetch) mutually exclusive.
2058
+ const queries = _toList(_maybeParseArray(args?.query !== undefined ? args.query : args?.keywords))
2059
+ const urls = _toList(_maybeParseArray(args?.url))
2060
+ if (queries.length === 0 && urls.length === 0) {
2061
+ return { content: [{ type: 'text', text: '[search] either `query` or `url` is required' }], isError: true }
2062
+ }
2063
+ if (queries.length > 0 && urls.length > 0) {
2064
+ return { content: [{ type: 'text', text: '[search] specify either `query` or `url`, not both' }], isError: true }
2065
+ }
2066
+ const { loadConfig: loadSearchConfig } = await import(
2067
+ pathToFileURL(join(PLUGIN_ROOT, 'src/search/lib/config.mjs')).href,
2068
+ )
2069
+ const searchConfig = loadSearchConfig()
2070
+ if (urls.length > 0) {
2071
+ const searchMod = await loadModule('search')
2072
+ const urlArg = urls.length > 1 ? urls : urls[0]
2073
+ const result = await searchMod.handleToolCall('web_fetch', {
2074
+ url: urlArg,
2075
+ startIndex: args?.startIndex,
2076
+ maxLength: args?.maxLength,
2077
+ })
2078
+ const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
2079
+ // ② completion progress (claude "Found N" parity). callerCtx.progress is
2080
+ // the central reporter; best-effort, no-op when absent (no progressToken).
2081
+ if (typeof callerCtx.progress === 'function') {
2082
+ try { callerCtx.progress(urls.length > 1 ? `fetched ${urls.length} URLs` : `fetched ${urls[0]}`) } catch { /* best-effort */ }
2083
+ }
2084
+ return { content: [{ type: 'text', text: body }], ...(result?.isError ? { isError: true } : {}) }
2085
+ }
2086
+ const searchMod = await loadModule('search')
2087
+ const queryArg = queries.length > 1 ? queries : queries[0]
2088
+ const result = await searchMod.handleToolCall('search', {
2089
+ keywords: queryArg,
2090
+ // Thread the per-call provider override through to the search module so
2091
+ // an explicit `provider:"xai-api"` etc. on the public `search` tool is
2092
+ // honoured instead of silently falling back to the configured default
2093
+ // (searchArgsSchema in src/search/index.mjs already accepts this field).
2094
+ provider: args?.provider,
2095
+ site: args?.site,
2096
+ type: args?.type,
2097
+ locale: args?.locale,
2098
+ maxResults: args?.maxResults || searchConfig?.rawSearch?.maxResults || 10,
2099
+ contextSize: args?.contextSize,
2100
+ })
2101
+ const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
2102
+ // ② completion progress (claude "Found N" parity). Count numbered result
2103
+ // lines in the formatted body; best-effort, no-op when progress absent.
2104
+ if (typeof callerCtx.progress === 'function') {
2105
+ try {
2106
+ const _n = (body.match(/^\s*\d+\.\s/gm) || []).length
2107
+ callerCtx.progress(`found ${_n} results`)
2108
+ } catch { /* best-effort */ }
2109
+ }
2110
+ return { content: [{ type: 'text', text: body }], ...(result?.isError ? { isError: true } : {}) }
2111
+ }
2112
+
2113
+ // ai-wrapped dispatch: explore + any other tool flagged aiWrapped in
2114
+ // tools.json. Imports the dispatcher lazily so non-aiWrapped calls don't
2115
+ // pay the cost.
2116
+ async function _dispatchAiWrappedRoute(name, args, callerCtx) {
2117
+ const { dispatchAiWrapped } = await import(
2118
+ pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/ai-wrapped-dispatch.mjs')).href,
2119
+ )
2120
+ return dispatchAiWrapped(name, args ?? {}, {
2121
+ PLUGIN_ROOT,
2122
+ callMemoryWorker: (n, a) => callWorker('memory', n, a),
2123
+ // Caller session id propagates from loop.mjs → executeInternalTool →
2124
+ // toolExecutor → dispatchTool → dispatchAiWrapped. Used there to reject
2125
+ // recursion when a hidden-role session (explorer / cycle1 / cycle2)
2126
+ // tries to re-enter an aiWrapped dispatcher.
2127
+ callerSessionId: callerCtx.callerSessionId,
2128
+ callerCwd: callerCtx.callerCwd,
2129
+ // A2: forward the MCP request signal so the sync fan-out can detect a
2130
+ // harness sever of the transport. callerCtx.requestSignal is extra.signal
2131
+ // from the CallTool handler (~2038) preserved through _ctxWithSignal's
2132
+ // spread — it is the signal that fires AT the 120s harness ceiling (the
2133
+ // transport tear-down). We forward requestSignal specifically, NOT the
2134
+ // combined abortSignal, because the combined signal also folds in the
2135
+ // plugin's own 630s ceiling (a different failure mode); requestSignal
2136
+ // alone is the precise "transport severed" event the sync path must react
2137
+ // to by pushing its finalized result through the channel instead of
2138
+ // returning into a dead in-turn transport.
2139
+ requestSignal: callerCtx.requestSignal,
2140
+ // Push merged answer into the Lead session when a dispatch
2141
+ // (wait:false) completes, so Lead integrates the result on its next
2142
+ // turn via a channel notification (no polling tool exposed).
2143
+ notifyFn: pushChannelNotification,
2144
+ // Originating MCP session id (daemon routing). Distinct from callerSessionId
2145
+ // (which gates the orchestrator-session guard); this only tags the
2146
+ // dispatch_result so the daemon router delivers it to the dispatching
2147
+ // terminal instead of broadcasting.
2148
+ routingSessionId: callerCtx.callerSession?.sessionId,
2149
+ clientHostPid: callerCtx.callerSession?.clientHostPid,
2150
+ })
2151
+ }
2152
+
2153
+ // Module-routed dispatch: builtin / code_graph / patch / host_input plus
2154
+ // the worker-IPC (memory, channels) and module.handleToolCall fallback
2155
+ // paths. Same early-return order as the inline branches it replaces.
2156
+ async function _dispatchByModule(name, args, callerCtx, def) {
2157
+ if (def.module === 'builtin') {
2158
+ // Plugin builtin file tools exposed to external MCP clients (e.g. the
2159
+ // Lead / Claude Code harness). Write semantics live inside executeBuiltinTool.
2160
+ const { executeBuiltinTool } = await import(
2161
+ pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/builtin.mjs')).href,
2162
+ )
2163
+ const effectiveCwd = (typeof args?.cwd === 'string' && args.cwd) ? args.cwd : (callerCtx.callerCwd || pwd())
2164
+ // Read-state / persistent-shell scope id precedence (NOT the recursion-guard
2165
+ // callerSessionId — that must stay null for Lead-direct so the orchestrator
2166
+ // guard does not fail closed): orchestrator session (bridge worker) > the
2167
+ // dispatching TERMINAL's session (per-connection daemon Session) > the
2168
+ // process-global SESSION_ID (stdio bootSession). The terminal tier is what
2169
+ // isolates each daemon terminal's `read` snapshots and `__default__<sid>`
2170
+ // persistent bash; without it all terminals shared one global scope and
2171
+ // tripped cross-session "file unchanged" stubs and a shared shell.
2172
+ const scopeSessionId = callerCtx.callerSessionId ?? callerCtx.callerSession?.sessionId ?? SESSION_ID
2173
+ // Live-progress reporter (MCP notifications/progress). Threaded only when
2174
+ // the client supplied a progressToken; null otherwise so the path stays
2175
+ // byte-identical to the no-progress behaviour.
2176
+ // Background-job completion push: the `bash` run_in_background path arms an
2177
+ // in-process watcher that calls notifyFn once the job finishes, mirroring
2178
+ // the explore-tool dispatch_result mechanism. Thread the same notify ctx
2179
+ // (notifyFn + daemon-routing identity) the aiWrapped dispatcher receives so
2180
+ // the completion result routes back to the dispatching terminal.
2181
+ const text = await executeBuiltinTool(name, args ?? {}, effectiveCwd, {
2182
+ sessionId: scopeSessionId,
2183
+ abortSignal: callerCtx.abortSignal ?? null,
2184
+ onProgress: callerCtx.progress ?? null,
2185
+ progressStarted: callerCtx.progressStarted === true,
2186
+ notifyFn: pushChannelNotification,
2187
+ routingSessionId: callerCtx.callerSession?.sessionId,
2188
+ clientHostPid: callerCtx.callerSession?.clientHostPid,
2189
+ })
2190
+ // Image-aware `read` returns a pre-built MCP result ({content:[{type:'image',...}]}).
2191
+ // Pass any such object through untouched; only string results get text-wrapped
2192
+ // (String()-flattening an object yields "[object Object]" and drops the image).
2193
+ if (text && typeof text === 'object' && Array.isArray(text.content)) return text
2194
+ return { content: [{ type: 'text', text: String(text) }] }
2195
+ }
2196
+
2197
+ if (def.module === 'code_graph') {
2198
+ const { executeCodeGraphTool } = await import(
2199
+ pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/code-graph.mjs')).href,
2200
+ )
2201
+ let resolvedName = name
2202
+ const resolvedArgs = args ?? {}
2203
+ if (name === 'find_symbol' && resolvedArgs.mode && resolvedArgs.mode !== 'symbol') {
2204
+ const m = resolvedArgs.mode
2205
+ if (m === 'callers') resolvedName = 'find_callers'
2206
+ else if (m === 'references') resolvedName = 'find_references'
2207
+ else if (m === 'imports') resolvedName = 'find_imports'
2208
+ else if (m === 'dependents') resolvedName = 'find_dependents'
2209
+ else resolvedName = 'code_graph'
2210
+ }
2211
+ const text = await executeCodeGraphTool(resolvedName, resolvedArgs, callerCtx.callerCwd || pwd(), callerCtx.abortSignal ?? null)
2212
+ // ② completion progress (claude "Found N" parity). Best-effort, no-op
2213
+ // when callerCtx.progress is absent (no progressToken). Never throws —
2214
+ // the tool result is returned regardless.
2215
+ if (typeof callerCtx.progress === 'function') {
2216
+ try {
2217
+ const _mode = String(resolvedArgs?.mode || '').trim()
2218
+ || (resolvedName === 'find_callers' ? 'callers'
2219
+ : resolvedName === 'find_references' ? 'references' : '')
2220
+ const _fileArg = (typeof resolvedArgs?.file === 'string' && resolvedArgs.file.trim()) ? resolvedArgs.file.trim() : ''
2221
+ const _countLocLines = (t) => (String(t).match(/^[^\n]*?:\d+:\d+/gm) || []).length
2222
+ let _msg = null
2223
+ if (_mode === 'callers') _msg = `found ${_countLocLines(text)} callers`
2224
+ else if (_mode === 'references') _msg = `found ${_countLocLines(text)} references`
2225
+ else if (_mode === 'search') {
2226
+ const _m = /\bmatches=(\d+)/.exec(String(text))
2227
+ _msg = `found ${_m ? _m[1] : 0} symbols`
2228
+ } else if (_fileArg) _msg = `mapped ${_fileArg}`
2229
+ if (_msg) callerCtx.progress(_msg)
2230
+ } catch { /* best-effort */ }
2231
+ }
2232
+ return { content: [{ type: 'text', text: String(text) }] }
2233
+ }
2234
+
2235
+ if (def.module === 'patch') {
2236
+ // Unified-diff apply tool. One-turn multi-file
2237
+ // edits without Read-before-Edit (the patch's context lines are the
2238
+ // read-proof). Mtime-guarded
2239
+ // against concurrent writes. See src/agent/orchestrator/tools/patch.mjs.
2240
+ const { executePatchTool } = await import(
2241
+ pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/patch.mjs')).href,
2242
+ )
2243
+ // Same scope-id precedence as the builtin path above: orchestrator session
2244
+ // > dispatching terminal session > process-global SESSION_ID. Keeps each
2245
+ // terminal's mtime read-state isolated without touching the recursion-guard
2246
+ // callerSessionId.
2247
+ const sessionId = callerCtx.callerSessionId ?? callerCtx.callerSession?.sessionId ?? SESSION_ID
2248
+ const text = await executePatchTool(name, args ?? {}, callerCtx.callerCwd || pwd(), {
2249
+ sessionId,
2250
+ readStateScope: sessionId,
2251
+ abortSignal: callerCtx.abortSignal ?? null,
2252
+ onProgress: callerCtx.progress ?? null,
2253
+ })
2254
+ return { content: [{ type: 'text', text: String(text) }] }
2255
+ }
2256
+
2257
+ if (def.module === 'host_input') {
2258
+ // Host-terminal input injection. Walks the parent chain from this Node
2259
+ // process, finds the first ancestor matching a supported terminal host
2260
+ // (currently powershell.exe / pwsh.exe), and replays the supplied text
2261
+ // into its console via AttachConsole + WriteConsoleInputW. Reuses the
2262
+ // proven dev/scripts/inject-input.ps1 helper.
2263
+ // See src/agent/orchestrator/tools/host-input.mjs.
2264
+ const { executeHostInputTool } = await import(
2265
+ pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/host-input.mjs')).href,
2266
+ )
2267
+ const text = await executeHostInputTool(name, args ?? {}, callerCtx.callerCwd || pwd())
2268
+ return { content: [{ type: 'text', text: String(text) }] }
2269
+ }
2270
+
2271
+ if (def.module === 'cwd') {
2272
+ // Session-cwd tool. Backed by process.env.MIXDOG_SESSION_CWD, which
2273
+ // captureOriginalUserCwd() consults first — so a successful `set`
2274
+ // immediately changes pwd() for every downstream tool dispatch.
2275
+ // See src/agent/orchestrator/tools/cwd-tool.mjs.
2276
+ const { executeCwdTool } = await import(
2277
+ pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/cwd-tool.mjs')).href,
2278
+ )
2279
+ const text = await executeCwdTool(name, args ?? {}, callerCtx.callerCwd || pwd(), { session: callerCtx.callerSession })
2280
+ return { content: [{ type: 'text', text: String(text) }] }
2281
+ }
2282
+
2283
+ const moduleName = def.module
2284
+
2285
+ if (moduleName === 'memory' || moduleName === 'channels') {
2286
+ return callWorker(moduleName, name, args ?? {})
2287
+ }
2288
+
2289
+ const mod = await loadModule(moduleName)
2290
+ if (moduleName === 'agent') {
2291
+ // Merge shared agent context with the per-request abort signal so the
2292
+ // bridge handler can tear down its async IIFE on client-side cancel.
2293
+ // Forward callerCwd from the MCP dispatch frame so the bridge handler
2294
+ // (src/agent/index.mjs:875) can resolve the worker cwd from the Lead's
2295
+ // current working directory instead of falling back to a stale frozen
2296
+ // user-cwd.txt or process.cwd().
2297
+ const ctx = agentContext()
2298
+ if (callerCtx?.requestSignal) ctx.requestSignal = callerCtx.requestSignal
2299
+ if (callerCtx?.callerCwd) ctx.callerCwd = callerCtx.callerCwd
2300
+ // Tag the dispatching MCP session so a detached bridge worker's result
2301
+ // routes back to THIS terminal (daemon router), not the Lead connection.
2302
+ if (callerCtx?.callerSession?.sessionId) ctx.routingSessionId = callerCtx.callerSession.sessionId
2303
+ if (typeof callerCtx?.callerSession?.clientHostPid === 'number' && callerCtx.callerSession.clientHostPid > 0) {
2304
+ ctx.clientHostPid = callerCtx.callerSession.clientHostPid
2305
+ }
2306
+ return mod.handleToolCall(name, args ?? {}, ctx)
2307
+ }
2308
+ return mod.handleToolCall(name, args ?? {})
2309
+ }
2310
+
2311
+ async function _dispatchToolImpl(name, args, callerCtx = {}) {
2312
+ const def = _resolveDispatchToolDef(name, args)
2313
+ if (name === 'recall' || name === 'search') {
2314
+ return _dispatchRecallOrSearch(name, args, callerCtx)
2315
+ }
2316
+ if (def.aiWrapped) {
2317
+ return _dispatchAiWrappedRoute(name, args, callerCtx)
2318
+ }
2319
+ return _dispatchByModule(name, args, callerCtx, def)
2320
+ }
2321
+
2322
+ // ── Handlers ────────────────────────────────────────────────────────
2323
+ const ALWAYS_LOAD_TOOLS = new Set([
2324
+ 'read', 'bash', 'grep', 'bridge', 'list',
2325
+ 'glob', 'recall', 'code_graph', 'explore', 'write', 'search',
2326
+ // R1-reviewer follow-up: Decision Table first-tools that were deferred —
2327
+ // apply_patch is the multi-file atomic edit primitive, job_wait the wait
2328
+ // hook for run_in_background. Both deserve always-loaded status by usage.
2329
+ 'apply_patch', 'job_wait',
2330
+ // Session-cwd controls — always loaded so a Lead can rebase the working
2331
+ // directory without paying a manifest-fetch round trip first.
2332
+ 'cwd',
2333
+ ])
2334
+
2335
+ const COMPACT_INPUT_SCHEMA_DESCRIPTION_TOOLS = new Set([
2336
+ 'read', 'grep', 'list', 'recall', 'code_graph', 'apply_patch',
2337
+ ])
2338
+
2339
+ function stripSchemaDescriptions(value) {
2340
+ if (!value || typeof value !== 'object') return value
2341
+ if (Array.isArray(value)) return value.map(stripSchemaDescriptions)
2342
+ const out = {}
2343
+ for (const [key, child] of Object.entries(value)) {
2344
+ if (key === 'description') continue
2345
+ out[key] = stripSchemaDescriptions(child)
2346
+ }
2347
+ return out
2348
+ }
2349
+
2350
+ function toListToolDef(tool) {
2351
+ const out = ALWAYS_LOAD_TOOLS.has(tool.name)
2352
+ ? { ...tool, _meta: { ...(tool._meta || {}), 'anthropic/alwaysLoad': true } }
2353
+ : tool
2354
+ if (!COMPACT_INPUT_SCHEMA_DESCRIPTION_TOOLS.has(tool.name)) return out
2355
+ return {
2356
+ ...out,
2357
+ inputSchema: stripSchemaDescriptions(out.inputSchema),
2358
+ }
2359
+ }
2360
+
2361
+ // Precompute ListTools response once at boot (TOOL_DEFS and ALWAYS_LOAD_TOOLS
2362
+ // are static after module init; rebuilding the spread on every request is waste).
2363
+ const LIST_TOOLS_RESPONSE = {
2364
+ tools: TOOL_DEFS.map(toListToolDef),
2365
+ }
2366
+ server.setRequestHandler(ListToolsRequestSchema, async () => LIST_TOOLS_RESPONSE)
2367
+
2368
+ // Lazy-loaded result-compression module so the boot path doesn't pay the
2369
+ // cost of importing bridge-trace + dependencies until the first tool call.
2370
+ let _compressionModule = null
2371
+ async function _getCompressionModule() {
2372
+ if (!_compressionModule) {
2373
+ _compressionModule = await import(
2374
+ pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/result-compression.mjs')).href,
2375
+ )
2376
+ }
2377
+ return _compressionModule
2378
+ }
2379
+
2380
+ let _leadDirectOutputSeq = 0
2381
+ // Skip compress/trim post-processing for small MCP tool bodies (fast path).
2382
+ const LEAD_DIRECT_POST_COMPRESS_MIN_BYTES = 4 * 1024
2383
+ function _saveLeadDirectFullOutput(toolName, text) {
2384
+ const safeTool = String(toolName || 'tool').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 40) || 'tool'
2385
+ const dir = join(PLUGIN_DATA, 'tool-results', 'lead-direct')
2386
+ mkdirSync(dir, { recursive: true })
2387
+ const file = join(dir, `${Date.now()}-${process.pid}-${++_leadDirectOutputSeq}-${safeTool}.txt`)
2388
+ writeFileAsync(file, text, 'utf8').catch((err) => log(`[compress] full-output save failed tool=${toolName} msg=${err?.message || err}`))
2389
+ return file
2390
+ }
2391
+
2392
+ // Memory-family tools carry session_id as an inline arg so the memory worker's
2393
+ // HTTP handlers can persist / filter by it without a separate IPC channel.
2394
+ // Non-memory tools see args unchanged — extra fields would noisily mismatch
2395
+ // strict schemas (e.g. bash, glob).
2396
+ const MEMORY_FAMILY_TOOLS = new Set(['memory', 'search_memories', 'recall'])
2397
+
2398
+ // Boot session = today's single stdio client. cwdFn=pwd keeps it tracking the
2399
+ // live MIXDOG_SESSION_CWD env, so this path is behaviour-identical to the
2400
+ // pre-Session code. The daemon entry creates one Session per connection.
2401
+ const bootSession = new Session(null, {
2402
+ sessionId: SESSION_ID,
2403
+ cwdFn: pwd,
2404
+ setCwdFn: (v) => { process.env.MIXDOG_SESSION_CWD = v },
2405
+ })
2406
+
2407
+ // Shared tool-call body for both the stdio entry (bootSession) and the daemon
2408
+ // per-connection entry. Identity (sessionId) and cwd come from `session` and
2409
+ // thread through dispatchTool's callerCtx, which every route already honours.
2410
+ // Build a monotonic MCP progress reporter from a CallTool `extra`. Returns
2411
+ // null when the client did not subscribe (no progressToken) so downstream
2412
+ // tools take their existing no-progress path unchanged. The reporter is a
2413
+ // fire-and-forget async function; notifications carry no `id` and the SDK
2414
+ // stamps relatedRequestId so the daemon/run-mcp layers route the frame back
2415
+ // to the originating connection.
2416
+ function _buildProgressReporter(progressCtx) {
2417
+ const token = progressCtx?._meta?.progressToken
2418
+ const sendNotification = progressCtx?.sendNotification
2419
+ if (token === undefined || token === null || typeof sendNotification !== 'function') return null
2420
+ let _seq = 0
2421
+ return (message) => {
2422
+ const progress = ++_seq
2423
+ try {
2424
+ const r = sendNotification({
2425
+ method: 'notifications/progress',
2426
+ params: { progressToken: token, progress, message: String(message ?? '') },
2427
+ })
2428
+ if (r && typeof r.catch === 'function') r.catch(() => {})
2429
+ } catch { /* progress is best-effort — never disturb the tool result */ }
2430
+ }
2431
+ }
2432
+
2433
+ async function runToolCall(session, name, rawArgs, requestSignal, progressCtx = null) {
2434
+ const _entryT = Date.now()
2435
+ const args = MEMORY_FAMILY_TOOLS.has(name) && rawArgs && typeof rawArgs === 'object'
2436
+ ? { ...rawArgs, sessionId: rawArgs.sessionId ?? session.sessionId }
2437
+ : rawArgs
2438
+ // Log absolute entry timestamp so we can compare against the caller's
2439
+ // emit timestamp (passed via args._mcpEmitTs if instrumented) to size the
2440
+ // Claude-Code → plugin transport window — currently the largest opaque
2441
+ // segment in the per-call cost stack.
2442
+ log(`[mcp-entry] tool=${name} t=${_entryT}`)
2443
+ // `extra.signal` is an AbortSignal that fires when the MCP client cancels
2444
+ // this request (e.g. user rejects / interrupts a tool call in Claude Code).
2445
+ // Thread it down so long-running tools — specifically the async IIFE the
2446
+ // `bridge` tool spawns to run askSession — can close their session and
2447
+ // stop hitting the provider after the user bails out.
2448
+ //
2449
+ // `callerCwd` defaults to the mixdog server's own working directory so
2450
+ // tools that take a cwd fallback (notably the unified `bridge` tool) can
2451
+ // resolve to a valid plugin path when the caller did not explicitly pass
2452
+ // one. Callers can still override with an explicit `cwd` argument.
2453
+ // Telemetry: dispatchTool's outer wrapper handles start/end/error logs
2454
+ // for both this MCP call path and the in-process toolExecutor path.
2455
+ // MCP live-progress reporter. The SDK auto-allocates extra._meta.progressToken
2456
+ // when the client passes an onprogress callback to callTool and exposes
2457
+ // extra.sendNotification bound to this request (relatedRequestId set). We
2458
+ // build a monotonic-progress reporter ONLY when a token is present, so a
2459
+ // client that did not subscribe sees byte-identical behaviour (no emits).
2460
+ const _progressReporter = _buildProgressReporter(progressCtx)
2461
+ const _postT0 = Date.now()
2462
+ const result = await dispatchTool(name, args, {
2463
+ requestSignal,
2464
+ callerSession: session,
2465
+ callerCwd: session.resolveCwd(),
2466
+ progress: _progressReporter,
2467
+ })
2468
+ const _postT1 = Date.now()
2469
+ // Apply the same chained safe-compression + trim passes the bridge role loop
2470
+ // runs (loop.mjs:1105). Without this, Lead-direct tool calls bypassed every
2471
+ // RTK-class lossless reduction (CR overwrite, NUL/BOM strip, trailing
2472
+ // newline normalize, ANSI/whitespace/dup/separator). compressToolResult
2473
+ // is gated on annotations.compressible per tool definition, so non-
2474
+ // compressible tools (read/etc) pass through untouched. Final expand
2475
+ // guard inside compressToolResult returns the original on no-shrink.
2476
+ // tailTrimLargeOutput adds RTK-style long-line and head/tail caps so one
2477
+ // huge minified line cannot stall the MCP stdout path.
2478
+ // sessionId='lead-direct' (sentinel) lets traceBridgeCompress fire on
2479
+ // this path too — without it the compress trace skipped Lead-direct
2480
+ // entirely and PG aggregates only saw bridge role loop activity.
2481
+ try {
2482
+ const { compressToolResult, tailTrimLargeOutput } = await _getCompressionModule()
2483
+ const rawText = result?.content?.[0]?.text
2484
+ if (typeof rawText === 'string' && Buffer.byteLength(rawText, 'utf8') >= LEAD_DIRECT_POST_COMPRESS_MIN_BYTES) {
2485
+ const _before = rawText.length
2486
+ let nextText = compressToolResult(name, args, rawText, { sessionId: 'lead-direct', toolKind: null })
2487
+ // `read` output is consumed sequentially — head-only trim with a
2488
+ // continue hint instead of head+tail (which destroys the middle of a
2489
+ // deliberately windowed read).
2490
+ const _seq = name === 'read'
2491
+ const trimmedCandidate = tailTrimLargeOutput(nextText, { trimLongLines: true, sequential: _seq })
2492
+ if (trimmedCandidate !== nextText) {
2493
+ const fullOutputPath = _saveLeadDirectFullOutput(name, rawText)
2494
+ if (fullOutputPath) {
2495
+ nextText = tailTrimLargeOutput(nextText, { fullOutputPath, sequential: _seq })
2496
+ }
2497
+ }
2498
+ if (nextText !== rawText) {
2499
+ result.content[0].text = nextText
2500
+ const _saved = _before - nextText.length
2501
+ const _pct = _before > 0 ? Math.round((_saved / _before) * 100) : 0
2502
+ log(`[compress] tool=${name} ${_before}→${nextText.length} bytes saved=${_pct}%`)
2503
+ }
2504
+ }
2505
+ } catch { /* compression best-effort — never block a tool result */ }
2506
+ const _postT3 = Date.now()
2507
+ log(`[mcp-post] tool=${name} dispatch=${_postT1 - _postT0}ms post=${_postT3 - _postT1}ms total=${_postT3 - _postT0}ms`)
2508
+ return result
2509
+ }
2510
+
2511
+ server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
2512
+ return runToolCall(bootSession, req.params.name, req.params.arguments, extra?.signal, extra)
2513
+ })
2514
+
2515
+ // ── Memory worker — start before MCP handshake so it boots in parallel ─────
2516
+ const memoryOn = isModuleEnabled('memory')
2517
+ const channelsOn = isModuleEnabled('channels')
2518
+ if (memoryOn) spawnWorker('memory')
2519
+ else log(`module 'memory' disabled — skipping worker spawn`)
2520
+
2521
+ // ── Daemon mode: serve N Claude Code clients over one shared pipe ───────────
2522
+ // Each connection gets its own MCP SDK Server bound to a FramedServerTransport,
2523
+ // so the SDK does protocol-correct initialize/tools negotiation per client
2524
+ // while a per-connection Session carries identity (sessionId/cwd) into
2525
+ // runToolCall. The memory/channels workers booted in this process are SINGLE
2526
+ // shared services — the point of the daemon. Replaces the per-terminal stdio
2527
+ // server + fork-proxy/election model (deleted at cutover).
2528
+ async function serveDaemon(dataDir) {
2529
+ daemonNotifyRouter = daemonNotifyRouter || createDaemonNotifyRouter()
2530
+ const reg = new SessionRegistry()
2531
+ const srv = await daemonListen({
2532
+ dataDir,
2533
+ onConnection(conn) {
2534
+ const session = reg.open(conn, { sessionId: randomUUID() })
2535
+ const perConn = new Server(
2536
+ { name: 'mixdog', version: PLUGIN_VERSION },
2537
+ {
2538
+ capabilities: { tools: {}, experimental: { 'claude/channel': {}, 'claude/channel/permission': {} } },
2539
+ instructions: SERVER_INSTRUCTIONS,
2540
+ },
2541
+ )
2542
+ perConn.setRequestHandler(ListToolsRequestSchema, async () => LIST_TOOLS_RESPONSE)
2543
+ perConn.setRequestHandler(CallToolRequestSchema, async (req, extra) =>
2544
+ runToolCall(session, req.params.name, req.params.arguments, extra?.signal, extra))
2545
+ // Channel permission requests: forward to the shared channels worker and
2546
+ // record request_id → this connection so the worker's response routes back
2547
+ // here (the daemon notify router). Each connection registers its own.
2548
+ perConn.setNotificationHandler(ChannelPermissionRequestNotificationSchema, async (notification) => {
2549
+ const reqId = notification?.params?.request_id
2550
+ const forwarded = forwardChannelPermissionRequest(notification.params, (n) => perConn.notification(n).catch(() => {}))
2551
+ // Track for response routing ONLY if it reached the worker; a local
2552
+ // deny already replied directly, so registering would leak in byReq.
2553
+ if (forwarded && reqId) daemonNotifyRouter.registerPermissionRequest(reqId, perConn)
2554
+ })
2555
+ // Track the connection + its Session so the router can resolve the
2556
+ // active-instance owner (channel events) and the dispatching session
2557
+ // (worker results). Register the bootstrap session id now so a dispatch
2558
+ // that completes before the control frame still routes correctly; the
2559
+ // control-frame handler below swaps in the real MIXDOG_SESSION_ID.
2560
+ daemonNotifyRouter.add(perConn, session)
2561
+ daemonNotifyRouter.registerSession(session.sessionId, perConn)
2562
+ // Register cleanup immediately after the registry/router entries exist,
2563
+ // before any further (throwable) transport setup — otherwise an
2564
+ // exception mid-setup leaks this session/router registration.
2565
+ conn.onClose(() => { daemonNotifyRouter.remove(perConn); reg.close(conn) })
2566
+ // Session identity arrives out-of-band as a `__mixdog` control frame,
2567
+ // filtered by FramedServerTransport before the SDK (no dependency on the
2568
+ // MCP initialize ordering).
2569
+ const transport = new FramedServerTransport(conn, {
2570
+ onControl(msg) {
2571
+ if (msg.__mixdog !== 'session') return
2572
+ let priorSid = null
2573
+ if (msg.sessionId) {
2574
+ priorSid = session.sessionId
2575
+ session.sessionId = String(msg.sessionId)
2576
+ // Register sessionId → this connection so async dispatch results
2577
+ // (bridge/explore) route back to this terminal instead of broadcasting.
2578
+ daemonNotifyRouter.registerSession(session.sessionId, perConn)
2579
+ }
2580
+ const _advertisedCwd = typeof msg.cwd === 'string' && msg.cwd ? msg.cwd : null
2581
+ const _restoredCwd = !_advertisedCwd && Number.isFinite(msg.leadPid) ? readLastSessionCwd(msg.leadPid) : null
2582
+ if (_advertisedCwd) session.cwd = _advertisedCwd
2583
+ else if (_restoredCwd) session.cwd = _restoredCwd
2584
+ if (typeof msg.transcriptPath === 'string') session.transcriptPath = msg.transcriptPath
2585
+ if (typeof msg.clientHostPid === 'number' && msg.clientHostPid > 0) session.clientHostPid = msg.clientHostPid
2586
+ if (Number.isFinite(msg.leadPid)) session.leadPid = msg.leadPid
2587
+ if (msg.instanceId) session.instanceId = String(msg.instanceId)
2588
+ // Reconnect recovery must use the same stable terminal key as
2589
+ // notification routing. MIXDOG_SESSION_ID is often absent, so
2590
+ // sessionId-only replay misses pending results from a prior bootstrap
2591
+ // UUID; clientHostPid lets the reattached terminal reclaim them.
2592
+ import('./src/agent/orchestrator/dispatch-persist.mjs')
2593
+ .then(({ recoverPending }) => recoverPending(PLUGIN_DATA, pushChannelNotification, {
2594
+ sessionId: session.sessionId,
2595
+ ...(priorSid && priorSid !== session.sessionId ? { priorSessionId: priorSid } : {}),
2596
+ ...(Number(session.clientHostPid) > 0 ? { clientHostPid: session.clientHostPid } : {}),
2597
+ }))
2598
+ .catch(() => {})
2599
+ },
2600
+ })
2601
+ perConn.connect(transport)
2602
+ .catch((e) => log(`[daemon] connection setup failed: ${e?.message || e}`))
2603
+ },
2604
+ })
2605
+ if (!srv) {
2606
+ // Another daemon already owns the pipe (host owner-lock handoff race).
2607
+ log('[daemon] pipe already owned — exiting')
2608
+ process.exit(0)
2609
+ }
2610
+ globalThis.__mixdogServerReady = true
2611
+ log(`[daemon] serving pid=${process.pid} dataDir=${dataDir} tools=${TOOL_DEFS.length}`)
2612
+ }
2613
+
2614
+ // ── Transport — connect so the MCP host can send its first tool call ────────
2615
+ if (process.argv.includes('--daemon') || process.env.MIXDOG_DAEMON_MODE === '1') {
2616
+ await serveDaemon(process.env.MIXDOG_DAEMON_DATA_DIR || PLUGIN_DATA)
2617
+ } else {
2618
+ await server.connect(new StdioServerTransport())
2619
+ }
2620
+ // Signal to server.mjs's prelude uncaught guards that the MCP transport is
2621
+ // live: from this point a soft-net log-only policy is safe because the
2622
+ // post-load classifier (FATAL_CODES + FATAL_NAME_PATTERNS) owns the exit
2623
+ // decision. Anything thrown before this flag flips still routes through the
2624
+ // prelude's pre-ready branch and exit(1) so the supervisor restarts.
2625
+ globalThis.__mixdogServerReady = true
2626
+ log(`connected pid=${process.pid} v${PLUGIN_VERSION} tools=${TOOL_DEFS.length}`)
2627
+
2628
+ // ── Background hydration (fire-and-forget after connect) ────────────────────
2629
+ // Agent registry — background; handleToolCall falls back to
2630
+ // setInternalToolsProvider lazily if a call arrives before this resolves.
2631
+ ;(async () => {
2632
+ if (!isModuleEnabled('agent')) {
2633
+ log(`module 'agent' disabled — skipping eager init, bridge tools will not register`)
2634
+ return
2635
+ }
2636
+ try {
2637
+ await loadModule('agent').then(async () => {
2638
+ // Populate the in-process tool registry at boot so ALL session entry
2639
+ // points (direct createSession / resumeSession, not just handleToolCall)
2640
+ // see the bridge from the first call. handleToolCall still calls
2641
+ // setInternalToolsProvider as an idempotent fallback, but we no longer
2642
+ // rely on a tool call arriving first.
2643
+ try {
2644
+ const internalToolsMod = await import(
2645
+ pathToFileURL(join(PLUGIN_ROOT, 'src', 'agent', 'orchestrator', 'internal-tools.mjs')).href
2646
+ )
2647
+ const { setInternalToolsProvider, markBootReady } = internalToolsMod
2648
+ const ctx = agentContext()
2649
+ setInternalToolsProvider({ executor: ctx.toolExecutor, tools: ctx.internalTools })
2650
+
2651
+ markBootReady()
2652
+ log(`internal-tools registry populated tools=${ctx.internalTools.length}`)
2653
+ // Windows-only: request Defender exclusion for hot IO paths, but keep
2654
+ // the synchronous PowerShell preference probe out of the SessionStart
2655
+ // boot path. The check is advisory and rate-limited; delaying it avoids
2656
+ // holding the MCP parent event loop while cycle1/core/recap are trying
2657
+ // to answer the startup hooks.
2658
+ const defenderTimer = setTimeout(() => {
2659
+ try { maybeRequestDefenderExclusion(PLUGIN_DATA, log) }
2660
+ catch (e) { log(`[defender] check failed: ${e.message}`) }
2661
+ }, 30_000)
2662
+ defenderTimer.unref?.()
2663
+ } catch (e) {
2664
+ log(`internal-tools registry populate failed: ${e.message}`)
2665
+ }
2666
+ })
2667
+ } catch (e) { log(`eager agent init failed: ${e.message}`) }
2668
+ })()
2669
+
2670
+ // ── Spawn workers: channels (gated on memory) ───────────────────────
2671
+ // Hoisted to register at the head of the setImmediate FIFO so the
2672
+ // channels fork lands ahead of the rules-watcher and status-fork
2673
+ // setImmediates registered later in this file. `reconcileClaudeMd`
2674
+ // is a function declaration, so it's hoisted and safe to reference
2675
+ // here even though its source location is below.
2676
+ setImmediate(() => {
2677
+ if (!channelsOn) {
2678
+ log(`module 'channels' disabled — skipping worker spawn`)
2679
+ // CLAUDE.md reconcile is driven by channels/injection config; when
2680
+ // channels is off we still reconcile once so managed blocks stay in
2681
+ // sync with the current mode.
2682
+ reconcileClaudeMd()
2683
+ return
2684
+ }
2685
+ // Channels worker issues callWorker('memory', ...) for inbound routing
2686
+ // and recall. With memory disabled there is no memory worker entry, so
2687
+ // those calls would hang for WORKER_NO_ENTRY_GRACE_MS then reject. Gate
2688
+ // channels start on memory availability to match the lifecycle comment
2689
+ // ("channels (gated on memory)") above.
2690
+ if (!memoryOn) {
2691
+ log(`module 'channels' enabled but 'memory' disabled — skipping channels worker spawn (channels depends on memory)`)
2692
+ reconcileClaudeMd()
2693
+ return
2694
+ }
2695
+
2696
+ reconcileClaudeMd()
2697
+ log('[server] channels spawn reason=parallel-with-memory')
2698
+ spawnWorker('channels')
2699
+ })
2700
+
2701
+ // ── Deferred: search eager-load + dispatch recovery ────────────────
2702
+ // Both are fire-and-forget after channels-spawn is enqueued so they
2703
+ // don't sit on the boot critical path. Search eager-load only avoids
2704
+ // the first-call JIT cost; dispatch recovery emits Aborted
2705
+ // notifications for orphaned handles from a prior process death.
2706
+ if (isModuleEnabled('search')) {
2707
+ const searchWarmupTimer = setTimeout(() => {
2708
+ loadModule('search').catch(e => log(`eager search init failed: ${e.message}`))
2709
+ }, 15_000)
2710
+ searchWarmupTimer.unref?.()
2711
+ } else {
2712
+ log(`module 'search' disabled — skipping eager init`)
2713
+ }
2714
+
2715
+ setImmediate(() => {
2716
+ const IS_DAEMON = process.argv.includes('--daemon') || process.env.MIXDOG_DAEMON_MODE === '1'
2717
+ if (IS_DAEMON) return
2718
+ import('./src/agent/orchestrator/dispatch-persist.mjs')
2719
+ .then(({ recoverPending }) => {
2720
+ const recovered = recoverPending(PLUGIN_DATA, pushChannelNotification)
2721
+ if (recovered > 0) log(`dispatch-recovery: emitted ${recovered} Aborted notifications`)
2722
+ })
2723
+ .catch((err) => {
2724
+ log(`dispatch-recovery failed: ${err instanceof Error ? err.message : String(err)}`)
2725
+ })
2726
+ })
2727
+
2728
+ // ── CLAUDE.md managed block reconciliation ─────────────────────────
2729
+ // Writes static rules into the managed block. Session recap is NOT
2730
+ // written here — the SessionStart hook injects it live from the memory worker.
2731
+ // Fail-soft: any error is logged and swallowed.
2732
+ //
2733
+ // mode === 'claude_md' → upsert the managed block (strong enforcement)
2734
+ // mode === 'hook' (default or missing) → remove any stale managed block
2735
+ // Shared loader for the two CLAUDE.md sites (boot-time reconcile +
2736
+ // live watcher): reads the channels section once, derives the injection
2737
+ // target, and require()s the rules-builder / writer pair. Returning the
2738
+ // same shape from a single helper avoids the duplicated readSection +
2739
+ // createRequire + require() pair at the original two call sites.
2740
+ function _readClaudeMdInjection() {
2741
+ const mainConfig = readSection('channels')
2742
+ const injection = (mainConfig && mainConfig.promptInjection) || {}
2743
+ return { injection }
2744
+ }
2745
+
2746
+ function _loadClaudeMdWriters() {
2747
+ const req = createRequire(import.meta.url)
2748
+ const { buildInjectionContent } = req(join(PLUGIN_ROOT, 'lib', 'rules-builder.cjs'))
2749
+ const { upsertManagedBlock, removeManagedBlock, expandHome } = req(join(PLUGIN_ROOT, 'lib', 'claude-md-writer.cjs'))
2750
+ return { buildInjectionContent, upsertManagedBlock, removeManagedBlock, expandHome }
2751
+ }
2752
+
2753
+ function reconcileClaudeMd() {
2754
+ try {
2755
+ const { injection } = _readClaudeMdInjection()
2756
+ if (injection.mode !== 'claude_md') {
2757
+ // hook/non-claude_md mode: load writers lazily only to remove any
2758
+ // stale managed block, matching original early-return semantics
2759
+ // (no require + no targetPath derivation until after the mode check).
2760
+ const { removeManagedBlock, expandHome } = _loadClaudeMdWriters()
2761
+ const targetPath = injection.targetPath || '~/.claude/CLAUDE.md'
2762
+ const removed = removeManagedBlock(targetPath)
2763
+ if (removed) log(`hook mode: removed stale managed block from ${expandHome(targetPath)}`)
2764
+ return
2765
+ }
2766
+
2767
+ const targetPath = injection.targetPath || '~/.claude/CLAUDE.md'
2768
+ const { buildInjectionContent, upsertManagedBlock, expandHome } = _loadClaudeMdWriters()
2769
+ const content = buildInjectionContent({ PLUGIN_ROOT, DATA_DIR: PLUGIN_DATA })
2770
+ upsertManagedBlock(targetPath, content)
2771
+ log(`claude_md: wrote managed block to ${expandHome(targetPath)} (${content.length} chars)`)
2772
+ } catch (e) {
2773
+ log(`claude_md reconcile failed: ${e && (e.stack || e.message) || e}`)
2774
+ }
2775
+ }
2776
+
2777
+ // ── CLAUDE.md managed block live watcher ───────────────────────────
2778
+ // After boot-time reconcile, watch the rules/config sources and rebuild
2779
+ // the managed block in-place whenever they change. Keeps the disk copy
2780
+ // of CLAUDE.md in sync so the next session start always sees the latest
2781
+ // rules, even if the user edited mid-session.
2782
+ //
2783
+ // Only active when injection.mode === 'claude_md'. In hook mode this is
2784
+ // a no-op (hook mode regenerates on every prompt anyway).
2785
+ //
2786
+ // All errors are contained: per-watcher try/catch plus an outer try/catch
2787
+ // so watcher setup failure never crashes the MCP server.
2788
+ setImmediate(() => {
2789
+ try {
2790
+ const { injection } = _readClaudeMdInjection()
2791
+ if (injection.mode !== 'claude_md') return
2792
+
2793
+ // Initial target snapshot used only for the watcher's own exclude
2794
+ // check (don't recurse on our own writes). The rebuild callback
2795
+ // re-reads mode/targetPath on every fire so a runtime config change
2796
+ // (mode flip to hook, or targetPath move) is honored instead of
2797
+ // upserting into the stale snapshot.
2798
+ const initialTargetPath = injection.targetPath || '~/.claude/CLAUDE.md'
2799
+ const { buildInjectionContent, upsertManagedBlock, expandHome } = _loadClaudeMdWriters()
2800
+ const resolvedTarget = pathResolve(expandHome(initialTargetPath))
2801
+
2802
+ // Track every target path we have ever written a managed block to
2803
+ // during this process's lifetime so an A→B→C reconfiguration removes
2804
+ // the block from BOTH A and B before writing C. Tracking only the
2805
+ // initial target left a stale block on intermediate hops because the
2806
+ // watcher always compared `currentTargetPath` against `initialTargetPath`.
2807
+ // Keyed by expandHome()-resolved absolute path so two spellings of
2808
+ // the same file (`~/.claude/CLAUDE.md` vs the expanded form) collapse.
2809
+ const writtenTargets = new Map() // resolvedAbs -> originalPath (for log + remove)
2810
+ writtenTargets.set(pathResolve(expandHome(initialTargetPath)), initialTargetPath)
2811
+
2812
+ let debounceTimer = null
2813
+ const rebuild = triggerFilename => {
2814
+ if (debounceTimer) clearTimeout(debounceTimer)
2815
+ debounceTimer = setTimeout(() => {
2816
+ debounceTimer = null
2817
+ try {
2818
+ // Re-read injection on every fire so mode/target changes in
2819
+ // mixdog-config.json take effect immediately instead of writing
2820
+ // to the original target captured at setup time.
2821
+ const { injection: currentInjection } = _readClaudeMdInjection()
2822
+ const currentTargetPath = currentInjection.targetPath || '~/.claude/CLAUDE.md'
2823
+ const resolvedCurrent = pathResolve(expandHome(currentTargetPath))
2824
+ if (currentInjection.mode !== 'claude_md') {
2825
+ // Mode flipped (e.g. claude_md → hook): remove any managed
2826
+ // block from EVERY target we have ever written to during this
2827
+ // session so we don't leave stale injection on disk at any
2828
+ // prior hop in an A→B→C chain.
2829
+ const { removeManagedBlock } = _loadClaudeMdWriters()
2830
+ for (const [, prior] of writtenTargets) {
2831
+ try {
2832
+ const removed = removeManagedBlock(prior)
2833
+ if (removed) log(`[rules-watcher] mode changed away from claude_md — removed managed block from ${expandHome(prior)}`)
2834
+ } catch (e) {
2835
+ log(`[rules-watcher] failed to remove managed block from ${expandHome(prior)}: ${e && (e.stack || e.message) || e}`)
2836
+ }
2837
+ }
2838
+ return
2839
+ }
2840
+ // Every rebuild removes stale managed blocks from all prior targets
2841
+ // except the current one. This covers A→B→A, where A was already
2842
+ // seen but B still needs cleanup before A is rebuilt.
2843
+ const { removeManagedBlock } = _loadClaudeMdWriters()
2844
+ for (const [resolvedPrior, prior] of writtenTargets) {
2845
+ if (resolvedPrior === resolvedCurrent) continue
2846
+ try {
2847
+ const removed = removeManagedBlock(prior)
2848
+ if (removed) log(`[rules-watcher] removed stale managed block from ${expandHome(prior)} before rebuilding ${expandHome(currentTargetPath)}`)
2849
+ } catch (e) {
2850
+ log(`[rules-watcher] failed to remove stale managed block from ${expandHome(prior)}: ${e && (e.stack || e.message) || e}`)
2851
+ }
2852
+ }
2853
+ const content = buildInjectionContent({ PLUGIN_ROOT, DATA_DIR: PLUGIN_DATA })
2854
+ upsertManagedBlock(currentTargetPath, content)
2855
+ writtenTargets.set(resolvedCurrent, currentTargetPath)
2856
+ log(`[rules-watcher] rebuilt managed block (${content.length} chars) at ${expandHome(currentTargetPath)} after ${triggerFilename}`)
2857
+ } catch (e) {
2858
+ log(`[rules-watcher] rebuild failed: ${e && (e.stack || e.message) || e}`)
2859
+ }
2860
+ }, 300)
2861
+ }
2862
+
2863
+ // Filenames are normalised to forward-slashes before lookup so the
2864
+ // recursive fs.watch event (which reports e.g. `history\user.md` on
2865
+ // Windows) lines up with this allowlist. Keep these in sync with the
2866
+ // sources buildInjectionContent (lib/rules-builder.cjs) actually reads:
2867
+ // mixdog-config.json + user-workflow.{json,md} at the data root and
2868
+ // history/user.md + history/bot.md (user profile / bot persona).
2869
+ const DATA_ALLOWLIST = new Set([
2870
+ 'mixdog-config.json', 'user-workflow.json', 'user-workflow.md',
2871
+ 'history/user.md', 'history/bot.md',
2872
+ ])
2873
+
2874
+ const makeHandler = root => {
2875
+ const isDataDir = pathResolve(root) === pathResolve(PLUGIN_DATA)
2876
+ return (_eventType, filename) => {
2877
+ if (!filename) return
2878
+ if (!/\.(md|json)$/i.test(filename)) return
2879
+ const norm = filename.replace(/\\/g, '/')
2880
+ if (isDataDir && !DATA_ALLOWLIST.has(norm)) return
2881
+ const abs = pathResolve(root, filename)
2882
+ if (abs === resolvedTarget) return
2883
+ rebuild(filename)
2884
+ }
2885
+ }
2886
+
2887
+ const roots = [
2888
+ join(PLUGIN_ROOT, 'rules'),
2889
+ PLUGIN_DATA,
2890
+ ]
2891
+ for (const root of roots) {
2892
+ try {
2893
+ fsWatch(root, { recursive: true, persistent: true }, makeHandler(root))
2894
+ log(`[rules-watcher] watching ${root}`)
2895
+ } catch (e) {
2896
+ log(`[rules-watcher] failed to watch ${root}: ${e && (e.stack || e.message) || e}`)
2897
+ }
2898
+ }
2899
+ } catch (e) {
2900
+ log(`[rules-watcher] setup failed: ${e && (e.stack || e.message) || e}`)
2901
+ }
2902
+ })
2903
+
2904
+ // Channels worker spawn + reconcileClaudeMd + deferred search/dispatch
2905
+ // recovery stay after MCP connect; status-server now starts during parent
2906
+ // boot, before memory/channels workers.
2907
+
2908
+ // ── Shutdown ────────────────────────────────────────────────────────
2909
+ const isWin = process.platform === 'win32'
2910
+ let shuttingDown = false
2911
+ const WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 8000 // child must be < parent (10s) to avoid race
2912
+
2913
+ async function gracefulKillWorker(name, entry) {
2914
+ const pid = entry.proc.pid
2915
+ workerIntentionalStop.add(name)
2916
+ // Step 1: request clean shutdown via IPC (preferred) or SIGTERM simulation.
2917
+ // On Windows, Node child_process.kill('SIGTERM') sends a real SIGTERM only
2918
+ // on newer Node; for reliability we prefer IPC message on win32.
2919
+ let shutdownRequested = false
2920
+ if (entry.proc.connected) {
2921
+ try {
2922
+ entry.proc.send({ type: 'shutdown' })
2923
+ shutdownRequested = true
2924
+ log(`shutdown: sent IPC {type:"shutdown"} to worker ${name} (pid=${pid})`)
2925
+ } catch {}
2926
+ }
2927
+ if (!shutdownRequested) {
2928
+ try {
2929
+ entry.proc.kill('SIGTERM')
2930
+ log(`shutdown: sent SIGTERM to worker ${name} (pid=${pid})`)
2931
+ } catch {}
2932
+ }
2933
+ // Step 2: wait for clean exit (process.exit fires 'exit' which deletes from workers).
2934
+ const exitP = new Promise(resolve => entry.proc.once('exit', resolve))
2935
+ const timedOut = await Promise.race([
2936
+ exitP.then(() => false),
2937
+ new Promise(resolve => setTimeout(() => resolve(true), WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_MS)),
2938
+ ])
2939
+ if (!timedOut) {
2940
+ log(`shutdown: worker ${name} exited cleanly (pid=${pid}) — path=graceful`)
2941
+ return
2942
+ }
2943
+ // Step 3: timeout expired — force kill as last resort.
2944
+ log(`shutdown: worker ${name} did not exit within ${WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_MS}ms — forcing kill (pid=${pid}) path=force`)
2945
+ try {
2946
+ if (isWin && pid) {
2947
+ const { execSync: _ek } = await import('node:child_process')
2948
+ _ek(`taskkill /F /PID ${pid}`, { stdio: 'ignore', windowsHide: true, timeout: 5000 })
2949
+ } else {
2950
+ entry.proc.kill('SIGKILL')
2951
+ }
2952
+ } catch {}
2953
+ }
2954
+
2955
+ async function shutdown(reason) {
2956
+ if (shuttingDown) return
2957
+ shuttingDown = true
2958
+ log(`shutdown: ${reason}`)
2959
+ // Stop idle session sweep timer
2960
+ try {
2961
+ const { stopIdleCleanup } = await import(pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/session/manager.mjs')).href)
2962
+ stopIdleCleanup()
2963
+ } catch {}
2964
+ // Stop status HTTP server child — parent-disconnect triggers graceful
2965
+ // shutdown (advertisement file cleanup + server.close) in the child.
2966
+ // On Windows, taskkill /F is the reliable fallback.
2967
+ // Set the stop flag first so the exit handler's scheduleStatusServerRestart()
2968
+ // is a no-op; otherwise a long shutdown can respawn the status server.
2969
+ statusServerStopping = true
2970
+ if (statusServerRestartTimer) {
2971
+ clearTimeout(statusServerRestartTimer)
2972
+ statusServerRestartTimer = null
2973
+ }
2974
+ if (statusServerChild) {
2975
+ const pid = statusServerChild.pid
2976
+ try {
2977
+ if (isWin && pid) {
2978
+ const { execSync: _execSync } = await import('node:child_process')
2979
+ _execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', windowsHide: true, timeout: 3000 })
2980
+ } else {
2981
+ // SIGTERM then bounded wait + SIGKILL escalation: the parent exits at
2982
+ // the end of shutdown(), so without waiting a slow-to-handle child is
2983
+ // orphaned. Race the child's 'exit' against a 3s deadline, then force
2984
+ // kill if it hasn't exited.
2985
+ const _child = statusServerChild
2986
+ const _exited = new Promise(resolve => _child.once('exit', () => resolve(false)))
2987
+ _child.kill('SIGTERM')
2988
+ const _timedOut = await Promise.race([
2989
+ _exited,
2990
+ new Promise(resolve => setTimeout(() => resolve(true), 3000)),
2991
+ ])
2992
+ if (_timedOut) {
2993
+ try { _child.kill('SIGKILL') } catch {}
2994
+ }
2995
+ }
2996
+ } catch {}
2997
+ // Belt-and-braces: unlink the advertisement file if child didn't.
2998
+ try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
2999
+ }
3000
+ // Graceful worker shutdown: IPC/SIGTERM → wait → force kill only as last resort.
3001
+ // Avoids taskkill /F /T which may corrupt pgdata.
3002
+ for (const [name, entry] of workers) {
3003
+ await gracefulKillWorker(name, entry)
3004
+ }
3005
+ // Kill tracked bridge CLI processes
3006
+ try {
3007
+ const { cleanupOrphanedPids } = await import(pathToFileURL(join(PLUGIN_ROOT, 'src/shared/llm/pid-cleanup.mjs')).href)
3008
+ const killed = cleanupOrphanedPids()
3009
+ if (killed > 0) log(`shutdown: cleaned ${killed} bridge CLI processes`)
3010
+ } catch {}
3011
+ // Graceful PG shutdown — fires only on clean shutdown path.
3012
+ // On supervisor-kill path (SIGTERM w/ no time for graceful stop), PG is
3013
+ // left running; next ensurePgInstance call recovers via stale-detection.
3014
+ try {
3015
+ const { stopPgForShutdown } = await import(pathToFileURL(join(PLUGIN_ROOT, 'src/memory/lib/pg/supervisor.mjs')).href)
3016
+ await stopPgForShutdown()
3017
+ } catch {}
3018
+ for (const mod of modules.values()) {
3019
+ if (mod.stop) await mod.stop()
3020
+ }
3021
+ // Final log drain — graceful path is the only flush guarantee since the
3022
+ // SIGTERM short-circuit handler was removed.
3023
+ try { _logFlushSync() } catch {}
3024
+ process.exit(0)
3025
+ }
3026
+
3027
+ server.onclose = () => shutdown('transport closed')
3028
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
3029
+ process.on('SIGINT', () => shutdown('SIGINT'))
3030
+
3031
+ // R17 parent-watch: if the supervisor dies abruptly (no IPC, no SIGTERM),
3032
+ // server-main + forked workers would otherwise keep running across restarts.
3033
+ // Poll the supervisor pid with signal 0 every 5 s; on ESRCH (parent gone)
3034
+ // trigger the same graceful shutdown path the SIGTERM handler uses.
3035
+ {
3036
+ const _supervisorPid = Number(process.env.MIXDOG_SUPERVISOR_PID)
3037
+ if (Number.isFinite(_supervisorPid) && _supervisorPid > 0) {
3038
+ const _parentWatch = setInterval(() => {
3039
+ try {
3040
+ process.kill(_supervisorPid, 0)
3041
+ } catch {
3042
+ // parent gone — fall through to graceful shutdown
3043
+ try { clearInterval(_parentWatch) } catch {}
3044
+ shutdown('supervisor exit (parent-watch)')
3045
+ }
3046
+ }, 5000)
3047
+ if (typeof _parentWatch.unref === 'function') _parentWatch.unref()
3048
+ }
3049
+ }
3050
+
3051
+ // Wire prelude's supervisor-control flag (set before server-main loaded).
3052
+ globalThis.__mixdogShutdownFromSupervisor = () => shutdown('supervisor control')
3053
+ if (globalThis.__mixdogSupervisorShutdownRequested) {
3054
+ shutdown('supervisor control (early)')
3055
+ }