mixdog 0.7.18 → 0.8.0

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