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
@@ -0,0 +1,3309 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import {
4
+ ListToolsRequestSchema,
5
+ CallToolRequestSchema
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import { spawn, execSync, spawnSync } from "child_process";
8
+ import * as crypto from "crypto";
9
+ import * as fs from "fs";
10
+ import * as http from "http";
11
+ import * as os from "os";
12
+ import * as path from "path";
13
+ import { performance } from "perf_hooks";
14
+ import { pathToFileURL } from "url";
15
+ import { createRequire } from "module";
16
+ const _require = createRequire(import.meta.url);
17
+ import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
18
+ import { resolveVoiceRuntime } from "./lib/voice-runtime-fetcher.mjs";
19
+ import { ensureReady, transcribe, stopVoiceWhisperServer } from "./lib/whisper-server.mjs";
20
+ import { loadConfig as loadAgentConfig } from "../agent/orchestrator/config.mjs";
21
+ import { captureOriginalUserCwd, readLastSessionCwd } from "../shared/user-cwd.mjs";
22
+ import { managedLaunchId, enqueueLauncherCommand } from "../shared/launcher-control.mjs";
23
+ import { initProviders } from "../agent/orchestrator/providers/registry.mjs";
24
+ import { Scheduler } from "./lib/scheduler.mjs";
25
+ import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./lib/status-snapshot.mjs";
26
+ import { hasPending as dispatchHasPending } from "../agent/orchestrator/dispatch-persist.mjs";
27
+ import { setListener as setActivityBusListener } from "../agent/orchestrator/activity-bus.mjs";
28
+ import { stripSoftWarns } from "../agent/orchestrator/tool-loop-guard.mjs";
29
+ import { invalidatePrefetchCache } from "../agent/orchestrator/session/cache/prefetch-cache.mjs";
30
+ import { WebhookServer } from "./lib/webhook.mjs";
31
+ import { EventPipeline } from "./lib/event-pipeline.mjs";
32
+ import { startCliWorker } from "./lib/cli-worker-host.mjs";
33
+ import {
34
+ OutputForwarder,
35
+ discoverSessionBoundTranscript,
36
+ findLatestTranscriptByMtime
37
+ } from "./lib/output-forwarder.mjs";
38
+ import { controlClaudeSession } from "./lib/session-control.mjs";
39
+ import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./lib/state-file.mjs";
40
+ import {
41
+ buildModalRequestSpec,
42
+ PendingInteractionStore
43
+ } from "./lib/interaction-workflows.mjs";
44
+ import {
45
+ ensureRuntimeDirs,
46
+ makeInstanceId,
47
+ getTurnEndPath,
48
+ getStatusPath,
49
+ getPermissionResultPath,
50
+ getChannelOwnerPath,
51
+ getActiveOwnerPid,
52
+ getTerminalLeadPid,
53
+ readActiveInstance,
54
+ refreshActiveInstance,
55
+ cleanupStaleRuntimeFiles,
56
+ cleanupInstanceRuntimeFiles,
57
+ releaseOwnedChannelLocks,
58
+ clearActiveInstance,
59
+ notePreviousServerIfAny,
60
+ writeServerPid,
61
+ clearServerPid,
62
+ RUNTIME_ROOT
63
+ } from "./lib/runtime-paths.mjs";
64
+ import { getDiscordToken } from "./lib/config.mjs";
65
+ const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.url).href;
66
+ const {
67
+ appendEntry: memoryAppendEntry,
68
+ ingestTranscript: memoryIngestTranscript,
69
+ } = await import(memoryClientModulePath);
70
+ const DEFAULT_PLUGIN_VERSION = "0.0.1";
71
+ const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ""));
72
+ const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
73
+ function bootProfile(event, fields = {}) {
74
+ if (!BOOT_PROFILE_ENABLED) return;
75
+ const elapsedMs = performance.now() - BOOT_PROFILE_START;
76
+ const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `channels:${event}`];
77
+ for (const [key, value] of Object.entries(fields || {})) {
78
+ if (value === undefined || value === null || value === "") continue;
79
+ parts.push(`${key}=${String(value).replace(/\s+/g, "_")}`);
80
+ }
81
+ try { process.stderr.write(`${parts.join(" ")}\n`); } catch {}
82
+ }
83
+ function localTimestamp() {
84
+ return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
85
+ }
86
+ function readPluginVersion() {
87
+ try {
88
+ const pkg = JSON.parse(fs.readFileSync(new URL("../../../package.json", import.meta.url), "utf8"));
89
+ return pkg.version || DEFAULT_PLUGIN_VERSION;
90
+ } catch {
91
+ return DEFAULT_PLUGIN_VERSION;
92
+ }
93
+ }
94
+ const PLUGIN_VERSION = readPluginVersion();
95
+ let crashLogging = false;
96
+ let _channelsDegraded = false;
97
+ let _stderrBroken = false;
98
+ function isChannelsDegraded() { return _channelsDegraded; }
99
+
100
+ // stderr can break when the parent stdio pipe closes. Node then emits an
101
+ // async 'error' on process.stderr, which sync try/catch around write() does
102
+ // not catch — without a listener, that error becomes uncaughtException and
103
+ // re-enters logCrash, looping until the disk fills. Register a suppressor
104
+ // once at load time and stop writing to stderr after the first EPIPE so the
105
+ // loop cannot start.
106
+ try {
107
+ process.stderr.on('error', (e) => {
108
+ if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
109
+ _stderrBroken = true;
110
+ _channelsDegraded = true;
111
+ }
112
+ });
113
+ } catch {}
114
+
115
+ // Crash log guards: dedup repeated identical errors (a single broken handler
116
+ // can fire thousands of times per minute) and rotate at a 10 MB cap so the
117
+ // file cannot grow unbounded. One .old generation is kept; older rolls drop.
118
+ const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
119
+ let _lastCrashSig = "";
120
+ let _crashRepeatCount = 0;
121
+
122
+ function _writeCrashLine(crashLog, line) {
123
+ try {
124
+ let size = 0;
125
+ try { size = fs.statSync(crashLog).size; } catch {}
126
+ if (size + line.length > CRASH_LOG_MAX_BYTES) {
127
+ try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
128
+ }
129
+ fs.appendFileSync(crashLog, line);
130
+ } catch {}
131
+ }
132
+
133
+ function logCrash(label, err) {
134
+ if (crashLogging) return;
135
+ crashLogging = true;
136
+ const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
137
+ ${err instanceof Error ? err.stack : ""}
138
+ `;
139
+ if (!_stderrBroken) {
140
+ try { process.stderr.write(msg); } catch (e) {
141
+ if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
142
+ _stderrBroken = true;
143
+ }
144
+ }
145
+ }
146
+ const sig = `${label}|${err && err.message ? err.message : String(err)}`;
147
+ const crashLog = path.join(DATA_DIR, "crash.log");
148
+ if (sig === _lastCrashSig) {
149
+ // Same error repeating — count it but skip the disk write. The next
150
+ // distinct error (or EPIPE branch below) flushes the suppressed total.
151
+ _crashRepeatCount += 1;
152
+ } else {
153
+ if (_crashRepeatCount > 0) {
154
+ _writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
155
+ _crashRepeatCount = 0;
156
+ }
157
+ _lastCrashSig = sig;
158
+ _writeCrashLine(crashLog, msg);
159
+ }
160
+ if (err instanceof Error && err.message.includes("EPIPE")) {
161
+ _channelsDegraded = true;
162
+ _stderrBroken = true;
163
+ }
164
+ crashLogging = false;
165
+ }
166
+ process.on("unhandledRejection", (err) => logCrash("unhandled rejection", err));
167
+ process.on("uncaughtException", (err) => logCrash("uncaught exception", err));
168
+ if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
169
+ process.exit(0);
170
+ }
171
+ const _isWorkerMode = process.env.MIXDOG_WORKER_MODE === '1'
172
+ const _isCliOwnedMode = process.env.MIXDOG_CLI_OWNED === '1'
173
+ const _bootLogEarly = path.join(
174
+ DATA_DIR || path.join(os.tmpdir(), "mixdog"),
175
+ "boot.log"
176
+ );
177
+ const {
178
+ isMixdogDebugEnabled: isMixdogDebug,
179
+ pruneStalePluginDataLogSiblings,
180
+ appendSessionStartCriticalLog,
181
+ DEFAULT_STALE_LOG_SIBLING_MAX,
182
+ } = _require("../../lib/mixdog-debug.cjs");
183
+ // One-shot log rotation at worker boot (10 MB threshold, .1 suffix overwrite).
184
+ if (isMixdogDebug()) {
185
+ try { if (fs.statSync(_bootLogEarly).size > 10 * 1024 * 1024) fs.renameSync(_bootLogEarly, _bootLogEarly + '.1') } catch {}
186
+ fs.appendFileSync(_bootLogEarly, `[${localTimestamp()}] bootstrap start pid=${process.pid}
187
+ `);
188
+ }
189
+ const _bootLog = path.join(DATA_DIR, "boot.log");
190
+ let config = loadConfig();
191
+ let backend = createBackend(config);
192
+ const INSTANCE_ID = makeInstanceId();
193
+ const TERMINAL_LEAD_PID = getTerminalLeadPid();
194
+ // ── drop-trace instrumentation ──────────────────────────────────────────────
195
+ const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
196
+ const DROP_TRACE_ENABLED =
197
+ process.env.MIXDOG_DROP_TRACE === "1" ||
198
+ process.env.MIXDOG_DROP_TRACE === "true" ||
199
+ process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
200
+ process.env.MIXDOG_DEBUG_CHANNELS === "true";
201
+ // One-shot rotation for drop-trace.log at worker boot.
202
+ if (DROP_TRACE_ENABLED) {
203
+ try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
204
+ }
205
+ // Rotate additional worker logs (10 MB threshold).
206
+ for (const _rotLog of ["channels-worker.log", "schedule.log", "event.log", "memory-worker.log", "mcp-debug.log", "webhook.log", "pg.log", "session-start.log"]) {
207
+ const _rotPath = path.join(DATA_DIR, _rotLog);
208
+ try { if (fs.statSync(_rotPath).size > 10 * 1024 * 1024) fs.renameSync(_rotPath, _rotPath + ".1") } catch {}
209
+ }
210
+ // GC per-worker scoped sibling logs (`<name>-worker.<leadPid>.<workerPid>.log`).
211
+ // Master logs above rotate live; scoped siblings are opened once per worker
212
+ // process and never reopened, so age-based removal is the only reliable
213
+ // cleanup signal. 7-day TTL keeps recent crash forensics while bounding leak.
214
+ const _STALE_WORKER_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1000;
215
+ try {
216
+ const _now = Date.now();
217
+ for (const _f of fs.readdirSync(DATA_DIR)) {
218
+ if (!/^(channels|memory)-worker\.\d+\.\d+\.log$/.test(_f)
219
+ && !/^mcp-debug\.\d+\.\d+\.log$/.test(_f)
220
+ && !/^supervisor\.\d+\.log$/.test(_f)) continue;
221
+ const _p = path.join(DATA_DIR, _f);
222
+ try { if (_now - fs.statSync(_p).mtimeMs > _STALE_WORKER_LOG_TTL_MS) fs.unlinkSync(_p); } catch {}
223
+ }
224
+ } catch {}
225
+ // GC stale ephemeral session files. closeSession plants a closed=true
226
+ // tombstone, but bench / smoke / probe drivers historically created sessions
227
+ // without ever calling closeSession, leaving 175-byte placeholders behind.
228
+ // 7-day TTL is safe because live bridge sessions touch their JSON file on
229
+ // every ask iteration, so any file older than 7 days is provably abandoned.
230
+ const _SESSIONS_DIR = path.join(DATA_DIR, 'sessions');
231
+ const _STALE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
232
+ try {
233
+ const _now = Date.now();
234
+ for (const _f of fs.readdirSync(_SESSIONS_DIR)) {
235
+ if (!_f.endsWith('.json')) continue;
236
+ const _p = path.join(_SESSIONS_DIR, _f);
237
+ try { if (_now - fs.statSync(_p).mtimeMs > _STALE_SESSION_TTL_MS) fs.unlinkSync(_p); } catch {}
238
+ }
239
+ } catch {}
240
+ // Count-based cap: drop oldest *.log siblings when plugin-data accumulates
241
+ // hundreds of per-process files (doctor warns above 300).
242
+ try {
243
+ pruneStalePluginDataLogSiblings(DATA_DIR, DEFAULT_STALE_LOG_SIBLING_MAX);
244
+ } catch {}
245
+
246
+ // ── Buffered drop-trace writer (channels/index) ──────────────────────────────
247
+ // Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
248
+ // Drains on process exit so no log lines are lost.
249
+ let _dtIdxBuf = "";
250
+ let _dtIdxBytes = 0;
251
+ let _dtIdxFlushTimer = null;
252
+ let _dtIdxStream = null;
253
+ function _dtIdxGetStream() {
254
+ if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
255
+ return _dtIdxStream;
256
+ }
257
+ async function _dtIdxFlush() {
258
+ if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
259
+ if (!_dtIdxBuf) return;
260
+ const stream = _dtIdxGetStream();
261
+ const buf = _dtIdxBuf;
262
+ _dtIdxBuf = "";
263
+ _dtIdxBytes = 0;
264
+ try {
265
+ const ok = stream.write(buf);
266
+ if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
267
+ } catch {}
268
+ }
269
+ function _dtIdxScheduleFlush() {
270
+ if (_dtIdxFlushTimer) return;
271
+ _dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
272
+ if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
273
+ }
274
+ function _dtIdxAppend(line) {
275
+ _dtIdxBuf += line;
276
+ _dtIdxBytes += Buffer.byteLength(line);
277
+ if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
278
+ _dtIdxScheduleFlush();
279
+ }
280
+ process.on("exit", () => { void _dtIdxFlush(); });
281
+ // SIGTERM: flush the drop-trace buffer, but do NOT exit here. In worker
282
+ // mode the graceful `_channelsShutdownHandler` below owns shutdown
283
+ // (stop() → cleanup → process.exit). In non-worker mode no SIGTERM
284
+ // handler was previously installed beyond this one; defer to default
285
+ // termination so process.on('exit') hooks still run.
286
+ process.on("SIGTERM", () => {
287
+ void _dtIdxFlush();
288
+ if (!_isWorkerMode) process.exit(0);
289
+ });
290
+
291
+ function preview(text) {
292
+ if (!text) return "";
293
+ const s = String(text).replace(/\n/g, "\\n");
294
+ return s.length > 120 ? s.slice(0, 120) + "…" : s;
295
+ }
296
+ function dropTrace(event, fields) {
297
+ if (!DROP_TRACE_ENABLED) return;
298
+ try {
299
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
300
+ const loc = `[${ts}][pid=${process.pid}] ${event}`;
301
+ const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
302
+ _dtIdxAppend(loc + kv + "\n");
303
+ } catch {}
304
+ }
305
+ // ────────────────────────────────────────────────────────────────────────────
306
+ ensureRuntimeDirs();
307
+ cleanupStaleRuntimeFiles();
308
+ if (!_isWorkerMode) {
309
+ notePreviousServerIfAny();
310
+ writeServerPid();
311
+ // Publish owner identity immediately so the SessionStart shim's
312
+ // owner_lead_alive() sees a live owner and uses the full connect budget
313
+ // instead of the 5s no-owner grace (fixes missing recap/core on restart).
314
+ // backendReady intentionally omitted — readiness stays gated until connect.
315
+ refreshActiveInstance(INSTANCE_ID);
316
+ startCliWorker();
317
+ }
318
+ const INSTRUCTIONS = "";
319
+
320
+ // ── Parent notification helper ───────────────────────────────────────
321
+ // This worker has no MCP transport of its own. All notifications flow
322
+ // through IPC to the parent (server.mjs), which owns the single connected
323
+ // MCP `Server` instance. The parent's IPC message handler translates
324
+ // `{type:'notify', method, params}` into `server.notification({method, params})`.
325
+ //
326
+ // Before v0.6.7 the worker had its own orphan `Server` instance that was
327
+ // never `connect()`ed to any transport, so `.notification()` silently
328
+ // threw 'Not connected' inside the SDK and every call was dropped by an
329
+ // outer `.catch(() => {})`. That regression is what this path replaces.
330
+ function sendNotifyToParent(method, params) {
331
+ if (!process.send) {
332
+ try { process.stderr.write(`mixdog channels: notify dropped (no IPC): ${method}\n`); } catch {}
333
+ return;
334
+ }
335
+ // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
336
+ // Coerce every meta value to string so a non-string (e.g. a Discord
337
+ // interaction.type number) can't fail zod and silently drop the notify.
338
+ // silent_to_agent stays boolean — an internal routing flag the daemon
339
+ // router / agentNotify consume (=== true) before the CC zod boundary.
340
+ let outParams = params;
341
+ if (method === 'notifications/claude/channel' && params && params.meta) {
342
+ const m = {};
343
+ for (const [k, v] of Object.entries(params.meta)) {
344
+ if (v === undefined || v === null) continue;
345
+ m[k] = k === 'silent_to_agent' ? (v === true || v === 'true') : String(v);
346
+ }
347
+ outParams = { ...params, meta: m };
348
+ }
349
+ try {
350
+ process.send({ type: 'notify', method, params: outParams });
351
+ } catch (err) {
352
+ try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
353
+ }
354
+ }
355
+
356
+ const recapState = { state: 'idle', running: false, startedAt: null, lastCompletedAt: null, updatedAt: null, errorMessage: null };
357
+ function sendRecapStateToParent() {
358
+ if (!process.send) return;
359
+ try {
360
+ process.send({ type: 'recap_status', recap: { ...recapState } });
361
+ } catch (err) {
362
+ try { process.stderr.write(`mixdog channels: recap status IPC send failed: ${err && err.message || err}\n`); } catch {}
363
+ }
364
+ }
365
+
366
+ // ── Memory worker bridge (worker → parent → memory) ─────────────────
367
+ // The channels worker does not own the memory worker handle. To trigger
368
+ // memory tool actions (e.g. cycle1) we send `memory_call_request` to the
369
+ // parent, which routes through callWorker('memory', ...) and ships the
370
+ // result back as `memory_call_response`. The response listener is
371
+ // integrated into the main IPC handler below (not a second listener).
372
+ const _memoryCallPending = new Map();
373
+ let _memoryCallSeq = 0;
374
+
375
+ function callMemoryAction(action, args, timeoutMs) {
376
+ return new Promise((resolve, reject) => {
377
+ if (!process.send) return reject(new Error('not a worker process'));
378
+ const callId = `mc_${INSTANCE_ID}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
379
+ const timer = setTimeout(() => {
380
+ _memoryCallPending.delete(callId);
381
+ reject(new Error(`memory_call ${action} timed out after ${timeoutMs}ms`));
382
+ }, timeoutMs);
383
+ _memoryCallPending.set(callId, {
384
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
385
+ reject: (e) => { clearTimeout(timer); reject(e); },
386
+ });
387
+ try {
388
+ process.send({ type: 'memory_call_request', callId, action, args: args || {} });
389
+ } catch (e) {
390
+ _memoryCallPending.delete(callId);
391
+ clearTimeout(timer);
392
+ reject(e);
393
+ }
394
+ });
395
+ }
396
+ function resolveChannelLabel(channelsConfig, label) {
397
+ if (!label || !channelsConfig) return label;
398
+ const entry = channelsConfig[label];
399
+ if (entry?.channelId) return entry.channelId;
400
+ return label;
401
+ }
402
+ let channelBridgeActive = false;
403
+ function writeBridgeState(active) {
404
+ try {
405
+ const stateFile = path.join(os.tmpdir(), "mixdog", "bridge-state.json");
406
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
407
+ fs.writeFileSync(stateFile, JSON.stringify({ active, ts: Date.now() }));
408
+ } catch {
409
+ }
410
+ }
411
+ function isChannelBridgeActive() {
412
+ return channelBridgeActive;
413
+ }
414
+ let typingChannelId = null;
415
+ const pendingSetup = new PendingInteractionStore();
416
+ function startServerTyping(channelId) {
417
+ if (typingChannelId && typingChannelId !== channelId) {
418
+ backend.stopTyping(typingChannelId);
419
+ }
420
+ typingChannelId = channelId;
421
+ backend.startTyping(channelId);
422
+ }
423
+ function stopServerTyping() {
424
+ if (typingChannelId) {
425
+ backend.stopTyping(typingChannelId);
426
+ typingChannelId = null;
427
+ }
428
+ }
429
+ const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
430
+ const TURN_END_BASENAME = path.basename(TURN_END_FILE);
431
+ const TURN_END_DIR = path.dirname(TURN_END_FILE);
432
+ let turnEndWatcher = null;
433
+ if (!_isWorkerMode) {
434
+ removeFileIfExists(TURN_END_FILE);
435
+ turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
436
+ if (filename !== TURN_END_BASENAME) return;
437
+ try {
438
+ const stat = fs.statSync(TURN_END_FILE);
439
+ if (stat.size > 0) {
440
+ stopServerTyping();
441
+ await forwarder.forwardFinalText();
442
+ removeFileIfExists(TURN_END_FILE);
443
+ }
444
+ } catch {
445
+ }
446
+ });
447
+ }
448
+ const STATUS_FILE = getStatusPath(INSTANCE_ID);
449
+ const statusState = new JsonStateFile(STATUS_FILE, {});
450
+ statusState.ensure();
451
+ function sessionIdFromTranscriptPath(transcriptPath) {
452
+ const base = path.basename(transcriptPath);
453
+ return base.endsWith(".jsonl") ? base.slice(0, -6) : "";
454
+ }
455
+ function getPersistedTranscriptPath() {
456
+ const state = statusState.read();
457
+ if (typeof state.transcriptPath === "string" && state.transcriptPath) return state.transcriptPath;
458
+ return readActiveInstance()?.transcriptPath ?? "";
459
+ }
460
+ function pickUsableTranscriptPath(bound, previousPath) {
461
+ if (bound?.exists) return bound.transcriptPath;
462
+ if (!previousPath) return "";
463
+ if (!bound?.sessionId) return previousPath;
464
+ return sessionIdFromTranscriptPath(previousPath) === bound.sessionId ? previousPath : "";
465
+ }
466
+ const forwarder = new OutputForwarder({
467
+ send: async (ch, text) => {
468
+ if (!channelBridgeActive) {
469
+ throw new Error("send() called while channel bridge is inactive");
470
+ }
471
+ await backend.sendMessage(ch, text);
472
+ },
473
+ recordAssistantTurn: async () => {
474
+ },
475
+ react: (ch, mid, emoji) => {
476
+ if (!channelBridgeActive) return Promise.resolve();
477
+ return backend.react(ch, mid, emoji);
478
+ },
479
+ removeReaction: (ch, mid, emoji) => {
480
+ if (!channelBridgeActive) return Promise.resolve();
481
+ return backend.removeReaction(ch, mid, emoji);
482
+ }
483
+ }, statusState);
484
+ forwarder.setOnIdle(() => {
485
+ stopServerTyping();
486
+ void forwarder.forwardFinalText();
487
+ });
488
+ // Wire the forwarder ownership probe unconditionally. wireEventQueueHandlers()
489
+ // also sets this, but that path only runs when the event pipeline starts
490
+ // (webhook enabled or event rules present). Without an event pipeline the
491
+ // forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
492
+ // non-owner / proxy process forward transcript output (duplicate Discord
493
+ // sends). The closure reads bridgeRuntimeConnected/proxyMode at call time.
494
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
495
+ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
496
+ if (!transcriptPath) return;
497
+ forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
498
+ const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
499
+ forwarder.startWatch();
500
+ void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
501
+ refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath });
502
+ if (options.persistStatus !== false) {
503
+ statusState.update((state) => {
504
+ state.channelId = channelId;
505
+ state.transcriptPath = boundTranscriptPath;
506
+ state.lastFileSize = forwarder.lastFileSize;
507
+ state.sentCount = forwarder.sentCount;
508
+ state.lastSentHash = forwarder.lastHash;
509
+ state.lastSentTime = 0;
510
+ state.sessionIdle = false;
511
+ state.sessionCwd = options.cwd ?? null;
512
+ });
513
+ }
514
+ }
515
+ async function rebindTranscriptContext(channelId, options = {}) {
516
+ const previousPath = options.previousPath ?? "";
517
+ const mode = options.mode ?? "same";
518
+ const explicitTranscriptPath = typeof options.transcriptPath === "string" ? options.transcriptPath.trim() : "";
519
+ if (explicitTranscriptPath) {
520
+ let explicitExists = false;
521
+ try {
522
+ explicitExists = fs.statSync(explicitTranscriptPath).isFile();
523
+ } catch {
524
+ explicitExists = false;
525
+ }
526
+ if (explicitExists) {
527
+ applyTranscriptBinding(channelId, explicitTranscriptPath, {
528
+ replayFromStart: Boolean(options.catchUp),
529
+ catchUpFromPersisted: options.catchUpFromPersisted,
530
+ persistStatus: options.persistStatus
531
+ });
532
+ if (options.catchUp || options.catchUpFromPersisted) {
533
+ await forwarder.forwardNewText();
534
+ }
535
+ return explicitTranscriptPath;
536
+ }
537
+ }
538
+ let sawPendingTranscript = false;
539
+ let pendingSessionId = "";
540
+ for (let attempt = 0; attempt < 30; attempt += 1) {
541
+ const bound = discoverSessionBoundTranscript();
542
+ if (bound?.exists) {
543
+ const acceptable = mode === "same" || !previousPath || bound.transcriptPath !== previousPath;
544
+ if (acceptable) {
545
+ const replayFromStart = Boolean(
546
+ options.catchUp && !previousPath && sawPendingTranscript && pendingSessionId === bound.sessionId
547
+ );
548
+ applyTranscriptBinding(channelId, bound.transcriptPath, {
549
+ replayFromStart,
550
+ catchUpFromPersisted: options.catchUpFromPersisted,
551
+ persistStatus: options.persistStatus,
552
+ cwd: bound.sessionCwd,
553
+ });
554
+ if (replayFromStart || options.catchUpFromPersisted) {
555
+ await forwarder.forwardNewText();
556
+ }
557
+ return bound.transcriptPath;
558
+ }
559
+ } else if (bound?.sessionId) {
560
+ sawPendingTranscript = true;
561
+ pendingSessionId = bound.sessionId;
562
+ }
563
+ await new Promise((resolve) => setTimeout(resolve, 150));
564
+ }
565
+ if (previousPath) {
566
+ applyTranscriptBinding(channelId, previousPath, { catchUpFromPersisted: true, cwd: statusState.read().sessionCwd });
567
+ await forwarder.forwardNewText();
568
+ process.stderr.write(`mixdog: rebind fallback: bound previous transcript ${previousPath}\n`);
569
+ return previousPath;
570
+ }
571
+ process.stderr.write(`mixdog: rebind failed: no transcript found and no previous path to fall back to\n`);
572
+ return "";
573
+ }
574
+ const scheduler = new Scheduler(
575
+ config.nonInteractive ?? [],
576
+ config.interactive ?? [],
577
+ // channelsConfig kept for channel-label resolution (resolveChannel)
578
+ // only — quiet/schedules now come from the top-level config.
579
+ config.channelsConfig,
580
+ // 0.1.62: top-level normalized config carries quiet/schedules.
581
+ config
582
+ );
583
+ // Register the pending-dispatch probe so the scheduler treats an in-flight
584
+ // bridge dispatch as "active" regardless of user-inbound silence.
585
+ scheduler.setPendingCheck(() => {
586
+ try {
587
+ return dispatchHasPending(DATA_DIR);
588
+ } catch {
589
+ return false;
590
+ }
591
+ });
592
+ // Bridge the orchestrator-side activity notifier into the scheduler so
593
+ // events like `addPending` can bump lastActivity without importing the
594
+ // scheduler instance directly (avoids module cycles).
595
+ setActivityBusListener(() => scheduler.noteActivity());
596
+ let webhookServer = null;
597
+ let eventPipeline = null;
598
+ let bridgeRuntimeConnected = false;
599
+ let bridgeRuntimeStarting = false;
600
+ // Stop-requested signal: set by stopOwnedRuntime() when it runs during the
601
+ // startOwnedRuntime() in-flight window (bridgeRuntimeStarting=true). Checked
602
+ // by startOwnedRuntime() right after backend.connect() resolves so the
603
+ // in-flight start does not revive owner state after the stop already tore
604
+ // the partial-start state down.
605
+ let _ownedRuntimeStopRequested = false;
606
+ let bridgeOwnershipRefreshInFlight = null;
607
+ let bridgeOwnershipTimer = null;
608
+ let lastOwnershipNote = "";
609
+ const ACTIVE_OWNER_STALE_MS = 1e4;
610
+ // Owner heartbeat: keep active-instance.json fresh so other sessions cannot
611
+ // steal the seat after 10 s of channel-action silence. unref'd interval —
612
+ // never blocks process exit. Single JSON atomic write, no measurable load.
613
+ const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
614
+ let ownerHeartbeatTimer = null;
615
+ // Owner gating here is multi-process runtime coordination: only the active
616
+ // bindingReady gates all send paths until the boot-time refreshBridgeOwnership
617
+ // ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
618
+ // emissions fired within the first ~few hundred ms after restart drop because
619
+ // the Discord backend binding has not yet been established.
620
+ let bindingReadyStatus = "pending";
621
+ // Channel-flag detection result, stored at module scope so the worker-mode
622
+ // ready IPC can forward it to the daemon for caching across respawns.
623
+ let _channelFlagDetected = false;
624
+ let _bindingReadyResolve;
625
+ const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
626
+ dropTrace("bindingReady.create", { status: bindingReadyStatus });
627
+ // owner runs webhook/event ticks. It is not webhook HTTP authentication.
628
+ let proxyMode = false;
629
+ let ownerHttpPort = 0;
630
+ let ownerHttpServer = null;
631
+ const PROXY_PORT_MIN = 3460;
632
+ const PROXY_PORT_MAX = 3467;
633
+ // Per-owner-process auth secret. Generated once at HTTP server start and
634
+ // published into runtime/owner-secret-<instanceId>.json with 0o600 perms so
635
+ // only the owner UID can read it back. requireOwnerToken below checks THIS
636
+ // secret (not the public-by-/ping instanceId) so any local caller that
637
+ // scrapes /ping cannot forge owner-side calls. The file is keyed on the
638
+ // owner's INSTANCE_ID — the SAME identifier published into active-instance
639
+ // as `instanceId` and validated by requireOwnerToken's x-owner-instance
640
+ // header check — so proxy readers can resolve the path off readActiveInstance()
641
+ // without depending on getActiveOwnerPid(), which prefers ownerLeadPid/
642
+ // terminalLeadPid/supervisor_pid and would diverge from process.pid in
643
+ // supervisor-backed sessions.
644
+ let OWNER_SECRET = "";
645
+ function getOwnerSecretPath(instanceId) {
646
+ return path.join(RUNTIME_ROOT, `owner-secret-${String(instanceId)}.json`);
647
+ }
648
+ function publishOwnerSecret(secret) {
649
+ const file = getOwnerSecretPath(INSTANCE_ID);
650
+ try { ensureDir(RUNTIME_ROOT); } catch {}
651
+ // Best-effort restrictive write: O_CREAT|O_TRUNC|O_WRONLY with mode 0o600.
652
+ // On Windows mode bits are largely ignored, but the file still lives in
653
+ // the per-user tmp dir; an attacker without the same UID cannot read it.
654
+ try { fs.unlinkSync(file); } catch {}
655
+ const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY, 0o600);
656
+ try {
657
+ fs.writeSync(fd, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, secret, updatedAt: Date.now() }));
658
+ } finally {
659
+ try { fs.closeSync(fd); } catch {}
660
+ }
661
+ try { fs.chmodSync(file, 0o600); } catch {}
662
+ }
663
+ function clearOwnerSecret() {
664
+ try { fs.unlinkSync(getOwnerSecretPath(INSTANCE_ID)); } catch {}
665
+ }
666
+ function readOwnerSecretFor(ownerInstanceId) {
667
+ if (!ownerInstanceId) return "";
668
+ try {
669
+ const raw = fs.readFileSync(getOwnerSecretPath(ownerInstanceId), "utf8");
670
+ const parsed = JSON.parse(raw);
671
+ return typeof parsed?.secret === "string" ? parsed.secret : "";
672
+ } catch {
673
+ return "";
674
+ }
675
+ }
676
+ async function proxyRequest(endpoint, method, body) {
677
+ return new Promise((resolve) => {
678
+ const url = new URL(`http://127.0.0.1:${ownerHttpPort}${endpoint}`);
679
+ // Auth: read the owner's per-process secret from the restricted
680
+ // owner-secret file (0o600). The instanceId header is kept only as a
681
+ // secondary diagnostic — requireOwnerToken on the owner side checks
682
+ // the secret, not the instanceId.
683
+ const active = readActiveInstance();
684
+ const ownerInstanceId = active?.instanceId || INSTANCE_ID;
685
+ // Key the secret-file lookup on the owner's published instanceId — the
686
+ // SAME identifier the owner used when writing owner-secret-<instanceId>.json
687
+ // (publishOwnerSecret above) and what requireOwnerToken's x-owner-instance
688
+ // header check compares against. Do NOT route this through
689
+ // getActiveOwnerPid(active): that helper prefers ownerLeadPid /
690
+ // terminalLeadPid / supervisor_pid, which in a supervisor-backed session
691
+ // diverge from the owner-HTTP process.pid (== owner's INSTANCE_ID),
692
+ // causing the proxy to read owner-secret-<supervisorPid>.json while the
693
+ // owner wrote owner-secret-<process.pid>.json → empty secret → 401.
694
+ const ownerSecret = readOwnerSecretFor(ownerInstanceId);
695
+ if (!ownerSecret) {
696
+ resolve({ ok: false, error: "owner secret unavailable (file missing or unreadable)" });
697
+ return;
698
+ }
699
+ const reqOpts = {
700
+ hostname: "127.0.0.1",
701
+ port: ownerHttpPort,
702
+ path: url.pathname + url.search,
703
+ method,
704
+ headers: {
705
+ "Content-Type": "application/json",
706
+ "x-owner-token": ownerSecret,
707
+ "x-owner-instance": ownerInstanceId,
708
+ },
709
+ timeout: 3e4
710
+ };
711
+ const req = http.request(reqOpts, (res) => {
712
+ let data = "";
713
+ res.on("data", (chunk) => {
714
+ data += chunk;
715
+ });
716
+ res.on("end", () => {
717
+ try {
718
+ const parsed = JSON.parse(data);
719
+ resolve({ ok: res.statusCode === 200, data: parsed, error: parsed.error });
720
+ } catch {
721
+ resolve({ ok: false, error: `invalid response from owner: ${data.slice(0, 200)}` });
722
+ }
723
+ });
724
+ });
725
+ req.on("error", (err) => {
726
+ resolve({ ok: false, error: `proxy request failed: ${err.message}` });
727
+ });
728
+ req.on("timeout", () => {
729
+ req.destroy();
730
+ resolve({ ok: false, error: "proxy request timed out" });
731
+ });
732
+ if (body) req.write(JSON.stringify(body));
733
+ req.end();
734
+ });
735
+ }
736
+ async function pingOwner(port) {
737
+ return new Promise((resolve) => {
738
+ const req = http.request({
739
+ hostname: "127.0.0.1",
740
+ port,
741
+ path: "/ping",
742
+ method: "GET",
743
+ timeout: 3e3
744
+ }, (res) => {
745
+ res.resume();
746
+ resolve(res.statusCode === 200);
747
+ });
748
+ req.on("error", () => resolve(false));
749
+ req.on("timeout", () => {
750
+ req.destroy();
751
+ resolve(false);
752
+ });
753
+ req.end();
754
+ });
755
+ }
756
+ function tryListenPort(server, port) {
757
+ return new Promise((resolve) => {
758
+ server.once("error", () => resolve(false));
759
+ server.listen(port, "127.0.0.1", () => resolve(true));
760
+ });
761
+ }
762
+ // Owner-token auth gate. Compares x-owner-token against the per-process
763
+ // OWNER_SECRET generated at startOwnerHttpServer time. The secret is NOT
764
+ // returned by /ping (only the public instanceId is) so a local caller that
765
+ // scrapes /ping still cannot forge owner-side calls. Constant-time compare
766
+ // to avoid trivial timing leakage on the local socket. Optional secondary
767
+ // instanceId check via x-owner-instance: when present it must match this
768
+ // process's INSTANCE_ID, catching stale clients targeting an old owner.
769
+ function requireOwnerToken(req, res) {
770
+ const token = req.headers["x-owner-token"];
771
+ if (!OWNER_SECRET || typeof token !== "string" || token.length !== OWNER_SECRET.length) {
772
+ res.writeHead(401, { "Content-Type": "application/json" });
773
+ res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
774
+ return false;
775
+ }
776
+ let ok = false;
777
+ try {
778
+ ok = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(OWNER_SECRET));
779
+ } catch {
780
+ ok = false;
781
+ }
782
+ if (!ok) {
783
+ res.writeHead(401, { "Content-Type": "application/json" });
784
+ res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
785
+ return false;
786
+ }
787
+ const instanceHeader = req.headers["x-owner-instance"];
788
+ if (instanceHeader && instanceHeader !== INSTANCE_ID) {
789
+ res.writeHead(401, { "Content-Type": "application/json" });
790
+ res.end(JSON.stringify({ error: "unauthorized: instance mismatch" }));
791
+ return false;
792
+ }
793
+ return true;
794
+ }
795
+ // Per-route handler table. Each handler matches the original switch-case
796
+ // behavior byte-for-byte (auth checks, status codes, response shapes); the
797
+ // outer dispatch loop just looks up the entry instead of running a long
798
+ // switch. `methods` mirrors any pre-existing 405 guard.
799
+ const OWNER_ROUTES = {
800
+ "/ping": async (req, res /*, body, url*/) => {
801
+ res.writeHead(200);
802
+ res.end(JSON.stringify({ ok: true, instanceId: INSTANCE_ID, pid: process.pid }));
803
+ },
804
+ "/send": async (req, res, body) => {
805
+ if (!requireOwnerToken(req, res)) return;
806
+ // Pre/post-send activity bumps keep idle gating consistent across
807
+ // slow network / attachment / rate-limited sends; double bump is
808
+ // harmless.
809
+ scheduler.noteActivity();
810
+ const sendResult = await backend.sendMessage(body.chatId, body.text, body.opts);
811
+ scheduler.noteActivity();
812
+ res.writeHead(200);
813
+ res.end(JSON.stringify({ sentIds: sendResult.sentIds }));
814
+ },
815
+ "/react": async (req, res, body) => {
816
+ if (!requireOwnerToken(req, res)) return;
817
+ await backend.react(body.chatId, body.messageId, body.emoji);
818
+ res.writeHead(200);
819
+ res.end(JSON.stringify({ ok: true }));
820
+ },
821
+ "/edit": async (req, res, body) => {
822
+ if (!requireOwnerToken(req, res)) return;
823
+ const editId = await backend.editMessage(body.chatId, body.messageId, body.text, body.opts);
824
+ res.writeHead(200);
825
+ res.end(JSON.stringify({ id: editId }));
826
+ },
827
+ "/fetch": async (req, res, body, url) => {
828
+ if (!requireOwnerToken(req, res)) return;
829
+ const channelId = url.searchParams.get("channel") ?? "";
830
+ const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
831
+ const msgs = await backend.fetchMessages(channelId, limit);
832
+ recordFetchedMessages(channelId, labelForChannelId(channelId), msgs);
833
+ res.writeHead(200);
834
+ res.end(JSON.stringify({ messages: msgs }));
835
+ },
836
+ "/download": async (req, res, body) => {
837
+ if (!requireOwnerToken(req, res)) return;
838
+ const files = await backend.downloadAttachment(body.chatId, body.messageId);
839
+ res.writeHead(200);
840
+ res.end(JSON.stringify({ files }));
841
+ },
842
+ "/typing/start": async (req, res, body) => {
843
+ if (!requireOwnerToken(req, res)) return;
844
+ backend.startTyping(body.channelId);
845
+ res.writeHead(200);
846
+ res.end(JSON.stringify({ ok: true }));
847
+ },
848
+ "/typing/stop": async (req, res, body) => {
849
+ if (!requireOwnerToken(req, res)) return;
850
+ backend.stopTyping(body.channelId);
851
+ res.writeHead(200);
852
+ res.end(JSON.stringify({ ok: true }));
853
+ },
854
+ "/inject": async (req, res, body) => {
855
+ // Require owner-token header to prevent unauthenticated local injection.
856
+ if (!requireOwnerToken(req, res)) return;
857
+ const content = body.content;
858
+ if (!content) {
859
+ res.writeHead(400);
860
+ res.end(JSON.stringify({ error: "content required" }));
861
+ return;
862
+ }
863
+ const source = body.source || "mixdog-agent";
864
+ const injMeta = { user: source, user_id: "system", ts: (/* @__PURE__ */ new Date()).toISOString() };
865
+ if (body.instruction) injMeta.instruction = body.instruction;
866
+ if (body.type) injMeta.type = body.type;
867
+ sendNotifyToParent("notifications/claude/channel", { content, meta: injMeta });
868
+ res.writeHead(200);
869
+ res.end(JSON.stringify({ ok: true }));
870
+ },
871
+ "/trigger-schedule": async (req, res, body) => {
872
+ // Native fallback for `mcp__trigger_schedule` so out-of-band
873
+ // verification works when the MCP stdio bridge is down (Claude Code
874
+ // disconnected, supervisor restart pending, etc.). Same authz as
875
+ // /inject — x-owner-token must equal INSTANCE_ID.
876
+ if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
877
+ if (!requireOwnerToken(req, res)) return;
878
+ const triggerName = body.name;
879
+ if (!triggerName) {
880
+ res.writeHead(400);
881
+ res.end(JSON.stringify({ error: "name required" }));
882
+ return;
883
+ }
884
+ try {
885
+ const r = await scheduler.triggerManual(triggerName);
886
+ res.writeHead(200);
887
+ res.end(JSON.stringify({ ok: true, result: r ?? null }));
888
+ } catch (e) {
889
+ res.writeHead(500);
890
+ res.end(JSON.stringify({ error: e?.message || String(e) }));
891
+ }
892
+ },
893
+ "/schedule-status": async (req, res) => {
894
+ // Owner-side schedule_status so standby/proxy sessions read the LIVE
895
+ // scheduler instead of their own stale local state. Mirrors the MCP
896
+ // schedule_status handler's formatting (kept byte-identical via the
897
+ // shared scheduleStatusResult() helper).
898
+ if (!requireOwnerToken(req, res)) return;
899
+ try {
900
+ const r = scheduleStatusResult();
901
+ res.writeHead(200);
902
+ res.end(JSON.stringify({ ok: true, result: r }));
903
+ } catch (e) {
904
+ res.writeHead(500);
905
+ res.end(JSON.stringify({ error: e?.message || String(e) }));
906
+ }
907
+ },
908
+ "/schedule-control": async (req, res, body) => {
909
+ // Owner-side schedule_control so standby/proxy sessions mutate the LIVE
910
+ // scheduler (defer/skip_today) instead of their own stale local state.
911
+ // Validation lives here because the proxy side's scheduler.nonInteractive/
912
+ // interactive lists are not authoritative.
913
+ if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
914
+ if (!requireOwnerToken(req, res)) return;
915
+ try {
916
+ const r = scheduleControlResult(body || {});
917
+ res.writeHead(200);
918
+ res.end(JSON.stringify({ ok: true, result: r }));
919
+ } catch (e) {
920
+ res.writeHead(500);
921
+ res.end(JSON.stringify({ error: e?.message || String(e) }));
922
+ }
923
+ },
924
+ "/bridge": async (req, res, body) => {
925
+ if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
926
+ if (!requireOwnerToken(req, res)) return;
927
+ const bridgeFile = body.file;
928
+ const bridgePrompt = body.prompt;
929
+ const bridgeRef = body.ref;
930
+ const bridgeRole = body.role;
931
+ const bridgeContext = body.context;
932
+ let bridgePromptFinal = bridgePrompt;
933
+ if (!bridgePromptFinal && bridgeFile) {
934
+ try { bridgePromptFinal = fs.readFileSync(bridgeFile, "utf-8").trim(); } catch (e) {
935
+ res.writeHead(400); res.end(JSON.stringify({ error: `Cannot read file: ${e.message}` })); return;
936
+ }
937
+ }
938
+ if (!bridgePromptFinal && !bridgeRef) { res.writeHead(400); res.end(JSON.stringify({ error: "prompt, file, or ref required" })); return; }
939
+ try {
940
+ const agentMod = await import(pathToFileURL(path.join(path.dirname(import.meta.url.replace("file:///", "").replace(/\//g, path.sep)), "..", "agent", "index.mjs")).href);
941
+ if (agentMod.init) await agentMod.init();
942
+ const toolArgs = {};
943
+ if (bridgePromptFinal) toolArgs.prompt = bridgePromptFinal;
944
+ if (bridgeRef) toolArgs.ref = bridgeRef;
945
+ if (bridgeRole) toolArgs.role = bridgeRole;
946
+ if (bridgeContext) toolArgs.context = bridgeContext;
947
+ const notifyFn = (text, extraMeta) => {
948
+ sendNotifyToParent("notifications/claude/channel", {
949
+ content: text,
950
+ meta: {
951
+ user: "mixdog-agent",
952
+ user_id: "system",
953
+ ts: new Date().toISOString(),
954
+ ...(extraMeta || {})
955
+ }
956
+ });
957
+ };
958
+ const BRIDGE_HTTP_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
959
+ const bridgeAbort = new AbortController();
960
+ const bridgeTimer = setTimeout(() => bridgeAbort.abort(new Error("bridge HTTP timeout")), BRIDGE_HTTP_TIMEOUT_MS);
961
+ const onReqClose = () => bridgeAbort.abort(new Error("client disconnected"));
962
+ req.on("close", onReqClose);
963
+ let result;
964
+ try {
965
+ result = await Promise.race([
966
+ agentMod.handleToolCall("bridge", toolArgs, { notifyFn, requestSignal: bridgeAbort.signal }),
967
+ new Promise((_, reject) => bridgeAbort.signal.addEventListener("abort", () => reject(bridgeAbort.signal.reason), { once: true })),
968
+ ]);
969
+ } finally {
970
+ clearTimeout(bridgeTimer);
971
+ req.removeListener("close", onReqClose);
972
+ }
973
+ res.writeHead(200);
974
+ res.end(JSON.stringify(result));
975
+ } catch (e) {
976
+ res.writeHead(500); res.end(JSON.stringify({ error: e.message })); return;
977
+ }
978
+ },
979
+ "/bridge/activate": async (req, res, body) => {
980
+ if (!requireOwnerToken(req, res)) return;
981
+ const active = Boolean(body.active);
982
+ const wasActive = channelBridgeActive;
983
+ channelBridgeActive = active;
984
+ writeBridgeState(active);
985
+ if (!active && wasActive) {
986
+ // Mirror the MCP activate_channel_bridge deactivate path: tear down
987
+ // owner-side runtime (Discord/scheduler/webhook/event/owner-HTTP/
988
+ // heartbeat) so a deactivated bridge doesn't keep running and this
989
+ // owner can't later proxyMode against its own port.
990
+ stopServerTyping();
991
+ try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
992
+ process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
993
+ }
994
+ }
995
+ res.writeHead(200);
996
+ res.end(JSON.stringify({ ok: true, active: channelBridgeActive }));
997
+ },
998
+ "/mcp": async (req, res, body) => {
999
+ if (req.method === "POST") {
1000
+ // Require owner-token header to prevent unauthenticated local MCP dispatch.
1001
+ if (!requireOwnerToken(req, res)) return;
1002
+ const httpMcp = createHttpMcpServer();
1003
+ const httpTransport = new StreamableHTTPServerTransport({
1004
+ sessionIdGenerator: void 0,
1005
+ enableJsonResponse: true
1006
+ });
1007
+ res.on("close", () => {
1008
+ httpTransport.close();
1009
+ void httpMcp.close();
1010
+ });
1011
+ await httpMcp.connect(httpTransport);
1012
+ await httpTransport.handleRequest(req, res, body);
1013
+ } else {
1014
+ res.writeHead(405);
1015
+ res.end(JSON.stringify({ error: "Method not allowed" }));
1016
+ }
1017
+ },
1018
+ "/recap/reset": async (req, res /*, body*/) => {
1019
+ if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
1020
+ if (!requireOwnerToken(req, res)) return;
1021
+ // Called by hooks/session-start.cjs on `/clear` (matcher startup|clear).
1022
+ // The session-start hook runs in a separate cjs process with no IPC
1023
+ // handle to this forked channels child, so it can't drop recap
1024
+ // status directly. Reset to an `empty` baseline so the statusline
1025
+ // doesn't carry the prior session's `injected`/`error` recap badge
1026
+ // into the cleared session.
1027
+ const now = Date.now();
1028
+ recapState.state = 'empty';
1029
+ recapState.running = false;
1030
+ recapState.startedAt = null;
1031
+ recapState.lastCompletedAt = now;
1032
+ recapState.updatedAt = now;
1033
+ recapState.errorMessage = null;
1034
+ sendRecapStateToParent();
1035
+ res.writeHead(200);
1036
+ res.end(JSON.stringify({ ok: true }));
1037
+ },
1038
+ "/cycle1": async (req, res, body) => {
1039
+ if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ ok: false, reason: "method-not-allowed", error: "POST required" })); return; }
1040
+ if (!requireOwnerToken(req, res)) return;
1041
+ const tCycleEntry = Date.now();
1042
+ const timeoutMs = Number(body?.timeout_ms) > 0 ? Math.min(60000, Number(body.timeout_ms)) : 15000;
1043
+ // IPC timer must outlive the worker-side deadline so a graceful
1044
+ // {timedOutWaiting:true} resolve has time to traverse IPC before
1045
+ // the channel timer rejects with memory-timeout. Without the
1046
+ // buffer, the worker resolves at deadline-0ms and the local
1047
+ // setTimeout fires at deadline+0ms in the same tick — race won by
1048
+ // whichever scheduler ordering wins, turning intended 200 flags
1049
+ // into 503 responses.
1050
+ const ipcTimeoutMs = timeoutMs + 2000;
1051
+ try {
1052
+ // Carry the caller deadline through to the memory worker so a
1053
+ // pending cycle1 in-flight is awaited under the same budget.
1054
+ // Without this, when the previous cycle1's LLM call lives past
1055
+ // 60s, every later SessionStart slot stacks another full 60s
1056
+ // wait behind the same zombie promise.
1057
+ const result = await callMemoryAction(
1058
+ 'cycle1',
1059
+ { ...(body?.args || {}), _callerDeadlineMs: timeoutMs },
1060
+ ipcTimeoutMs,
1061
+ );
1062
+ // A successful IPC round-trip can still carry a nested MCP error
1063
+ // envelope ({ isError: true }) when the memory worker served the
1064
+ // call but the action failed — e.g. a promoted fork-proxy whose
1065
+ // local `db` is still null. Surfacing that as outer { ok: true }
1066
+ // masks the failure and makes session-start log a phantom success.
1067
+ // Return a transient 503 so the hook's 503-retry path (which gates
1068
+ // only on statusCode===503) re-polls instead of trusting it.
1069
+ if (result && typeof result === 'object' && result.isError === true) {
1070
+ const nestedText = Array.isArray(result.content)
1071
+ ? result.content.map(c => (c && c.text) || '').join(' ').trim()
1072
+ : '';
1073
+ try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry} nestedError=1\n`); } catch {}
1074
+ res.writeHead(503);
1075
+ res.end(JSON.stringify({ ok: false, reason: 'memory-not-ready', error: nestedText || 'memory cycle1 returned isError' }));
1076
+ } else {
1077
+ try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry}\n`); } catch {}
1078
+ res.writeHead(200);
1079
+ res.end(JSON.stringify({ ok: true, result }));
1080
+ }
1081
+ } catch (e) {
1082
+ // Classify transient/unavailable failures so the session-start hook
1083
+ // (and other 503-retry callers) can distinguish boot-time races from
1084
+ // IPC-layer faults and timeouts. All four reasons stay on 503 to
1085
+ // preserve the hook retry contract (hooks/session-start.cjs:516
1086
+ // gates only on statusCode===503); only the `reason` label changes.
1087
+ //
1088
+ // Source → reason mapping (upstream messages from server.mjs
1089
+ // callWorker at 457-490 and local callMemoryAction at 169-187):
1090
+ // server.mjs:470 "not ready (still booting)" → memory-not-ready
1091
+ // server.mjs:464/467 "not available (...)" → worker-unavailable
1092
+ // server.mjs:435 "exited unexpectedly" → worker-unavailable
1093
+ // local "not a worker process" guard → worker-unavailable
1094
+ // server.mjs:483 "IPC channel full or closed" → ipc-error
1095
+ // server.mjs:488 "send failed: ..." → ipc-error
1096
+ // server.mjs:475 "worker ... call ... timed out" → memory-timeout
1097
+ // local "memory_call <action> timed out after Nms" → memory-timeout
1098
+ const msg = e?.message || String(e);
1099
+ let reason;
1100
+ if (/worker memory not ready/i.test(msg)) {
1101
+ reason = 'memory-not-ready';
1102
+ } else if (/worker memory (IPC channel|send failed)/i.test(msg)) {
1103
+ reason = 'ipc-error';
1104
+ } else if (/timed out/i.test(msg)) {
1105
+ reason = 'memory-timeout';
1106
+ } else if (msg.includes('restart cap exceeded') || msg.includes('degraded')) {
1107
+ // Permanent degraded state: restart cap hit or boot-time init failure.
1108
+ // Use a distinct reason so callers can fail-fast without retrying.
1109
+ // NOTE: checked before 'not available' — the error message
1110
+ // "worker memory not available (restart cap exceeded)" contains both
1111
+ // substrings and must land in 'memory-degraded', not 'worker-unavailable'.
1112
+ reason = 'memory-degraded';
1113
+ } else if (msg.includes('worker memory not available') || msg.includes('worker memory exited unexpectedly') || msg.includes('not a worker process')) {
1114
+ reason = 'worker-unavailable';
1115
+ }
1116
+ const transient = Boolean(reason);
1117
+ res.writeHead(transient ? 503 : 500);
1118
+ res.end(JSON.stringify({ ok: false, reason, error: msg }));
1119
+ }
1120
+ },
1121
+ "/rebind": async (req, res, body) => {
1122
+ if (!requireOwnerToken(req, res)) return;
1123
+ const channelId = statusState.read().channelId;
1124
+ if (!channelId) {
1125
+ res.writeHead(200);
1126
+ res.end(JSON.stringify({ rebound: false, reason: "no channelId" }));
1127
+ return;
1128
+ }
1129
+ const previousPath = getPersistedTranscriptPath();
1130
+ const explicitTranscriptPath = typeof body?.transcriptPath === "string" ? body.transcriptPath.trim() : "";
1131
+ const bound = await rebindTranscriptContext(channelId, {
1132
+ previousPath,
1133
+ persistStatus: true,
1134
+ catchUp: true,
1135
+ ...(explicitTranscriptPath ? { transcriptPath: explicitTranscriptPath } : {})
1136
+ });
1137
+ const reboundChanged = Boolean(bound) && bound !== previousPath;
1138
+ res.writeHead(200);
1139
+ res.end(JSON.stringify({ rebound: reboundChanged, path: bound || null }));
1140
+ },
1141
+ };
1142
+ const BACKEND_DEPENDENT_PATHS = new Set([
1143
+ "/send",
1144
+ "/react",
1145
+ "/edit",
1146
+ "/fetch",
1147
+ "/download",
1148
+ "/typing/start",
1149
+ "/typing/stop",
1150
+ "/mcp"
1151
+ ]);
1152
+ async function ownerRequestHandler(req, res) {
1153
+ res.setHeader("Content-Type", "application/json");
1154
+ let body = {};
1155
+ if (req.method === "POST") {
1156
+ const chunks = [];
1157
+ for await (const chunk of req) chunks.push(chunk);
1158
+ try {
1159
+ const rawBody = Buffer.concat(chunks).toString();
1160
+ body = rawBody.trim() ? JSON.parse(rawBody) : {};
1161
+ } catch {
1162
+ res.writeHead(400);
1163
+ res.end(JSON.stringify({ error: "invalid JSON body" }));
1164
+ return;
1165
+ }
1166
+ }
1167
+ try {
1168
+ const url = new URL(req.url ?? "/", `http://127.0.0.1`);
1169
+ if (BACKEND_DEPENDENT_PATHS.has(url.pathname) && !bridgeRuntimeConnected) {
1170
+ res.writeHead(503);
1171
+ res.end(JSON.stringify({ ok: false, reason: "backend-not-ready" }));
1172
+ return;
1173
+ }
1174
+ const handler = OWNER_ROUTES[url.pathname];
1175
+ if (handler) {
1176
+ await handler(req, res, body, url);
1177
+ return;
1178
+ }
1179
+ res.writeHead(404);
1180
+ res.end(JSON.stringify({ error: "not found" }));
1181
+ } catch (err) {
1182
+ const msg = err instanceof Error ? err.message : String(err);
1183
+ res.writeHead(500);
1184
+ res.end(JSON.stringify({ error: msg }));
1185
+ }
1186
+ }
1187
+ async function startOwnerHttpServer() {
1188
+ if (ownerHttpServer) return ownerHttpServer.address().port;
1189
+ // Generate a fresh cryptographic owner-secret BEFORE the listener accepts
1190
+ // traffic so requireOwnerToken always has a real secret to compare. Stored
1191
+ // in a 0o600 sidecar file (owner-secret-<pid>.json) under RUNTIME_ROOT so
1192
+ // only the same UID + same active owner pid can read it back. /ping does
1193
+ // NOT return this value — only the public instanceId.
1194
+ if (!OWNER_SECRET) {
1195
+ OWNER_SECRET = crypto.randomBytes(32).toString("hex");
1196
+ try { publishOwnerSecret(OWNER_SECRET); }
1197
+ catch (e) {
1198
+ process.stderr.write(`mixdog: failed to publish owner secret: ${e?.message || e}\n`);
1199
+ }
1200
+ }
1201
+ const server = http.createServer(ownerRequestHandler);
1202
+ for (let port = PROXY_PORT_MIN; port <= PROXY_PORT_MAX; port++) {
1203
+ if (await tryListenPort(server, port)) {
1204
+ ownerHttpServer = server;
1205
+ process.stderr.write(`mixdog: owner HTTP server listening on 127.0.0.1:${port}
1206
+ `);
1207
+ return port;
1208
+ }
1209
+ server.removeAllListeners("error");
1210
+ }
1211
+ throw new Error(`no available port in range ${PROXY_PORT_MIN}-${PROXY_PORT_MAX}`);
1212
+ }
1213
+ function stopOwnerHttpServer() {
1214
+ if (!ownerHttpServer) return;
1215
+ ownerHttpServer.close();
1216
+ ownerHttpServer = null;
1217
+ // Drop the per-process secret + sidecar file. A future startOwnerHttpServer()
1218
+ // call regenerates a fresh one, so a stale standby that read the old secret
1219
+ // before the restart cannot authenticate against the new owner.
1220
+ OWNER_SECRET = "";
1221
+ try { clearOwnerSecret(); } catch {}
1222
+ globalThis.__mixdogBeaconRealHandler = null;
1223
+ globalThis.__mixdogBeacon = null;
1224
+ }
1225
+ function logOwnership(note) {
1226
+ if (lastOwnershipNote === note) return;
1227
+ lastOwnershipNote = note;
1228
+ process.stderr.write(`[ownership] ${note}
1229
+ `);
1230
+ }
1231
+ function currentOwnerState() {
1232
+ const active = readActiveInstance();
1233
+ return {
1234
+ active,
1235
+ owned: active?.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID
1236
+ };
1237
+ }
1238
+ function getBridgeOwnershipSnapshot() {
1239
+ return currentOwnerState();
1240
+ }
1241
+ // MIXDOG_PIN_OWNER=1 in the owning process writes `pinned:true` into
1242
+ // active-instance.json. Pinned owners ignore the 10 s stale window — they
1243
+ // only relinquish ownership when their OS process actually dies. Set per
1244
+ // session (env var on the Claude Code shell) to lock that Lead as the
1245
+ // schedule/webhook receiver across multi-session use.
1246
+ function canStealOwnership(active) {
1247
+ if (!active) return true;
1248
+ if (active.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID) return true;
1249
+ if (active.pinned) {
1250
+ const pinnedPid = getActiveOwnerPid(active);
1251
+ if (!pinnedPid) return true;
1252
+ try { process.kill(pinnedPid, 0); return false; }
1253
+ catch { return true; }
1254
+ }
1255
+ if (Date.now() - active.updatedAt > ACTIVE_OWNER_STALE_MS) return true;
1256
+ const ownerPid = getActiveOwnerPid(active);
1257
+ try {
1258
+ if (!ownerPid) throw new Error("missing owner pid");
1259
+ process.kill(ownerPid, 0);
1260
+ return false;
1261
+ } catch {
1262
+ return true;
1263
+ }
1264
+ }
1265
+ function claimBridgeOwnership(reason) {
1266
+ refreshActiveInstance(INSTANCE_ID);
1267
+ logOwnership(`claimed owner (${reason})`);
1268
+ }
1269
+ function noteStartupHandoff(previous) {
1270
+ if (!previous) return;
1271
+ if (previous.instanceId === INSTANCE_ID) return;
1272
+ if (getActiveOwnerPid(previous) === TERMINAL_LEAD_PID) return;
1273
+ logOwnership(`startup handoff from ${previous.instanceId}`);
1274
+ }
1275
+ async function bindPersistedTranscriptIfAny() {
1276
+ // Resolve channelId first from persisted status; fall back to the most
1277
+ // recent status-*.json snapshot, then to the configured main channel when
1278
+ // the bridge is active. No exists-gate here — once we have a channelId,
1279
+ // hand off to rebindTranscriptContext(), which owns the 30-attempt retry
1280
+ // for transcripts that are not yet on disk at boot/activate time.
1281
+ let currentStatus = statusState.read();
1282
+ if (!currentStatus.channelId) {
1283
+ try {
1284
+ const files = fs.readdirSync(RUNTIME_ROOT).filter((f) => f.startsWith("status-") && f.endsWith(".json")).map((f) => {
1285
+ const full = path.join(RUNTIME_ROOT, f);
1286
+ return { path: full, mtime: fs.statSync(full).mtimeMs };
1287
+ }).sort((a, b) => b.mtime - a.mtime);
1288
+ for (const { path: fp } of files) {
1289
+ try {
1290
+ const data = JSON.parse(fs.readFileSync(fp, "utf8"));
1291
+ if (data.channelId) {
1292
+ statusState.update((state) => {
1293
+ Object.assign(state, data);
1294
+ });
1295
+ currentStatus = statusState.read();
1296
+ process.stderr.write(`mixdog: restored status from ${fp}
1297
+ `);
1298
+ break;
1299
+ }
1300
+ } catch {
1301
+ }
1302
+ }
1303
+ } catch {
1304
+ }
1305
+ }
1306
+ if (!currentStatus.channelId && channelBridgeActive) {
1307
+ const chCfg = config.channelsConfig;
1308
+ const mainLabel = config.mainChannel ?? "main";
1309
+ const mainEntry = chCfg?.[mainLabel];
1310
+ const mainId = mainEntry?.channelId;
1311
+ if (mainId) {
1312
+ statusState.update((state) => {
1313
+ state.channelId = mainId;
1314
+ });
1315
+ currentStatus = statusState.read();
1316
+ process.stderr.write(`mixdog: auto-bound to main channel ${mainId}
1317
+ `);
1318
+ }
1319
+ }
1320
+ if (!currentStatus.channelId) return;
1321
+ const bound = await rebindTranscriptContext(currentStatus.channelId, {
1322
+ previousPath: getPersistedTranscriptPath(),
1323
+ persistStatus: true,
1324
+ catchUpFromPersisted: true
1325
+ });
1326
+ if (bound) {
1327
+ process.stderr.write(`mixdog: initial transcript bind: ${bound}
1328
+ `);
1329
+ }
1330
+ }
1331
+ function shouldStartEventPipelineRuntime() {
1332
+ return config.webhook?.enabled === true || (Array.isArray(config.events?.rules) && config.events.rules.length > 0);
1333
+ }
1334
+ function ensureEventPipelineRuntime() {
1335
+ if (!eventPipeline) {
1336
+ eventPipeline = new EventPipeline(config.events, config.channelsConfig);
1337
+ wireEventQueueHandlers(eventPipeline.getQueue());
1338
+ }
1339
+ return eventPipeline;
1340
+ }
1341
+ function ensureWebhookServerRuntime() {
1342
+ if (!webhookServer) {
1343
+ // Pass top-level normalized config so the webhook gate reads the new
1344
+ // top-level `quiet` subtree (and `webhook.respectQuiet`) introduced in
1345
+ // 0.1.62. See applyDefaults() in lib/config.mjs.
1346
+ webhookServer = new WebhookServer(config.webhook, { quiet: config.quiet ?? null });
1347
+ }
1348
+ wireWebhookHandlers();
1349
+ return webhookServer;
1350
+ }
1351
+ async function stopWebhookAndEventRuntime() {
1352
+ if (webhookServer) {
1353
+ await webhookServer.stop();
1354
+ webhookServer = null;
1355
+ }
1356
+ if (eventPipeline) {
1357
+ eventPipeline.stop();
1358
+ eventPipeline = null;
1359
+ }
1360
+ }
1361
+ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
1362
+ if (shouldStartEventPipelineRuntime()) {
1363
+ const pipeline = ensureEventPipelineRuntime();
1364
+ if (reload) {
1365
+ pipeline.reloadConfig(config.events, config.channelsConfig);
1366
+ wireEventQueueHandlers(pipeline.getQueue());
1367
+ }
1368
+ pipeline.start();
1369
+ } else if (eventPipeline) {
1370
+ eventPipeline.stop();
1371
+ eventPipeline = null;
1372
+ }
1373
+
1374
+ if (config.webhook?.enabled === true) {
1375
+ const server = ensureWebhookServerRuntime();
1376
+ if (reload) {
1377
+ // server.reloadConfig is async (it awaits the current server's
1378
+ // close() before re-listening). Chain start() onto its resolution
1379
+ // so we don't race the bound port — calling start() synchronously
1380
+ // here would re-listen before close() finishes and surface
1381
+ // EADDRINUSE on the same port.
1382
+ server.reloadConfig(config.webhook, { quiet: config.quiet ?? null }, {
1383
+ autoStart: false
1384
+ }).then(() => {
1385
+ // A stopWebhookAndEventRuntime() / deactivate landing during the async
1386
+ // close()+reload window nulls out webhookServer (and webhook.enabled may
1387
+ // have flipped off). Without this guard the resolved continuation would
1388
+ // re-listen and resurrect an orphan listener that no teardown tracks.
1389
+ if (webhookServer !== server || config.webhook?.enabled !== true) {
1390
+ try { server.stop(); } catch {}
1391
+ return;
1392
+ }
1393
+ wireWebhookHandlers();
1394
+ server.start();
1395
+ }).catch((err) => {
1396
+ process.stderr.write(`mixdog channels: webhook reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
1397
+ });
1398
+ } else {
1399
+ server.start();
1400
+ }
1401
+ } else if (webhookServer) {
1402
+ webhookServer.stop();
1403
+ webhookServer = null;
1404
+ }
1405
+ }
1406
+ async function startOwnedRuntime(options = {}) {
1407
+ if (bridgeRuntimeConnected) return;
1408
+ if (bridgeRuntimeStarting) return;
1409
+ if (!channelBridgeActive) return;
1410
+ bridgeRuntimeStarting = true;
1411
+ _ownedRuntimeStopRequested = false;
1412
+ // Advertise active-instance.json BEFORE backend connect so peers can
1413
+ // discover this owner (httpPort) immediately. backendReady=false marks
1414
+ // the partial state until backend.connect() succeeds.
1415
+ let httpPort;
1416
+ try {
1417
+ httpPort = await startOwnerHttpServer();
1418
+ } catch (e) {
1419
+ process.stderr.write(`mixdog: HTTP server start failed (non-fatal): ${e instanceof Error ? e.message : String(e)}
1420
+ `);
1421
+ }
1422
+ refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: false });
1423
+ startOwnerHeartbeat();
1424
+ // Re-check after each post-connect await so a stopOwnedRuntime() landing
1425
+ // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
1426
+ // webhook/binding launches below would revive owner state after stop).
1427
+ // Idempotent: stop's sync teardown already ran; re-running disconnect +
1428
+ // teardown is safe and covers both the pre-connected window (stop could
1429
+ // not disconnect an in-flight backend) and the post-connected window
1430
+ // (stop did disconnect; redo to be defensive).
1431
+ const bailIfStopRequested = async () => {
1432
+ if (!_ownedRuntimeStopRequested) return false;
1433
+ try { await backend.disconnect(); } catch {}
1434
+ try { stopOwnerHttpServer(); } catch {}
1435
+ try { stopOwnerHeartbeat(); } catch {}
1436
+ try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
1437
+ try { clearActiveInstance(INSTANCE_ID); } catch {}
1438
+ bridgeRuntimeConnected = false;
1439
+ _ownedRuntimeStopRequested = false;
1440
+ return true;
1441
+ };
1442
+ // Await backend.connect() so callers (and bindingReady) only resolve after
1443
+ // the Discord binding is real. Previously this was fire-and-forget and
1444
+ // refreshBridgeOwnership returned immediately, letting bindingReady fire
1445
+ // before backend listeners were attached.
1446
+ try {
1447
+ await backend.connect();
1448
+ if (await bailIfStopRequested()) return;
1449
+ bridgeRuntimeConnected = true;
1450
+ refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: true });
1451
+ proxyMode = false;
1452
+ // initProviders must complete before scheduler.start() — otherwise the
1453
+ // scheduler's first fire can land before the registry is populated and
1454
+ // return `Provider "<name>" not found or not enabled`. The previous
1455
+ // fire-and-forget call let scheduler.start() race ahead of init.
1456
+ try {
1457
+ const agentCfg = loadAgentConfig();
1458
+ await initProviders(agentCfg.providers || {});
1459
+ } catch (e) {
1460
+ process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1461
+ }
1462
+ if (await bailIfStopRequested()) return;
1463
+ scheduler.start();
1464
+ startSnapshotWriter(scheduler);
1465
+ syncOwnedWebhookAndEventRuntime();
1466
+ if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
1467
+ process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1468
+ });
1469
+ process.stderr.write(`mixdog: running with ${backend.name} backend\n`);
1470
+ logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
1471
+ } catch (e) {
1472
+ process.stderr.write(`mixdog: backend connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
1473
+ // Roll back partial owner-side state advertised before connect() ran:
1474
+ // HTTP server, heartbeat, and active-instance entry. Without this cleanup
1475
+ // stopOwnedRuntime() at shutdown will short-circuit on !bridgeRuntimeConnected
1476
+ // and leave the port bound + active-instance.json stale.
1477
+ try { stopOwnerHttpServer(); } catch {}
1478
+ try { stopOwnerHeartbeat(); } catch {}
1479
+ try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
1480
+ try { clearActiveInstance(INSTANCE_ID); } catch {}
1481
+ } finally {
1482
+ bridgeRuntimeStarting = false;
1483
+ }
1484
+ }
1485
+ async function startCliOwnedRuntime(options = {}) {
1486
+ if (bridgeRuntimeConnected) return;
1487
+ if (bridgeRuntimeStarting) return;
1488
+ if (!channelBridgeActive) return;
1489
+ const startedAt = performance.now();
1490
+ bootProfile("cli-owned:start");
1491
+ bridgeRuntimeStarting = true;
1492
+ _ownedRuntimeStopRequested = false;
1493
+ try {
1494
+ const backendStartedAt = performance.now();
1495
+ await backend.connect();
1496
+ bootProfile("backend:connected", { ms: (performance.now() - backendStartedAt).toFixed(1), backend: backend.name });
1497
+ if (_ownedRuntimeStopRequested) {
1498
+ try { await backend.disconnect(); } catch {}
1499
+ bridgeRuntimeConnected = false;
1500
+ _ownedRuntimeStopRequested = false;
1501
+ return;
1502
+ }
1503
+ bridgeRuntimeConnected = true;
1504
+ proxyMode = false;
1505
+ ownerHttpPort = 0;
1506
+ try {
1507
+ const providersStartedAt = performance.now();
1508
+ const agentCfg = loadAgentConfig();
1509
+ await initProviders(agentCfg.providers || {});
1510
+ bootProfile("providers:ready", { ms: (performance.now() - providersStartedAt).toFixed(1) });
1511
+ } catch (e) {
1512
+ bootProfile("providers:failed", { error: e instanceof Error ? e.message : String(e) });
1513
+ process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1514
+ }
1515
+ if (_ownedRuntimeStopRequested) {
1516
+ await stopOwnedRuntime("cli-owned start cancelled");
1517
+ return;
1518
+ }
1519
+ scheduler.start();
1520
+ startSnapshotWriter(scheduler);
1521
+ bootProfile("scheduler:started");
1522
+ syncOwnedWebhookAndEventRuntime();
1523
+ bootProfile("webhook-event:ready");
1524
+ if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
1525
+ process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1526
+ });
1527
+ bootProfile("cli-owned:ready", { ms: (performance.now() - startedAt).toFixed(1) });
1528
+ process.stderr.write(`mixdog: running with ${backend.name} backend (cli-owned)\n`);
1529
+ } catch (e) {
1530
+ bootProfile("cli-owned:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e instanceof Error ? e.message : String(e) });
1531
+ process.stderr.write(`mixdog: backend connect failed (non-fatal, cli-owned): ${e instanceof Error ? e.message : String(e)}\n`);
1532
+ try { await stopOwnedRuntime("cli-owned start failed"); } catch {}
1533
+ } finally {
1534
+ bridgeRuntimeStarting = false;
1535
+ }
1536
+ }
1537
+ async function stopOwnedRuntime(reason) {
1538
+ // startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
1539
+ // claims channel locks BEFORE awaiting backend.connect(). If shutdown lands
1540
+ // during that window (bridgeRuntimeStarting=true, bridgeRuntimeConnected
1541
+ // still false) we still need to tear that partial state down — otherwise
1542
+ // the port stays bound and active-instance.json stays stale.
1543
+ if (!bridgeRuntimeConnected && !bridgeRuntimeStarting) return;
1544
+ // If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
1545
+ // startOwnedRuntime() to abort right after its backend.connect() resolves.
1546
+ // Without this the in-flight start re-marks connected and re-launches
1547
+ // scheduler/webhook/heartbeat after we tear them down here.
1548
+ if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
1549
+ const wasConnected = bridgeRuntimeConnected;
1550
+ stopServerTyping();
1551
+ // Release the transcript fs.watch handle plus the forwarder's debounce/retry
1552
+ // timers on standby. Without this the watcher keeps firing scheduleWatchFlush
1553
+ // and the drain/retry timers stay live after ownership is dropped, leaking a
1554
+ // file handle + timers for the rest of the process lifetime.
1555
+ try { forwarder.stopWatch(); } catch {}
1556
+ stopOwnerHttpServer();
1557
+ stopOwnerHeartbeat();
1558
+ scheduler.stop();
1559
+ stopSnapshotWriter();
1560
+ await stopWebhookAndEventRuntime();
1561
+ releaseOwnedChannelLocks(INSTANCE_ID);
1562
+ clearActiveInstance(INSTANCE_ID);
1563
+ try {
1564
+ // Only disconnect the backend when connect() actually completed; calling
1565
+ // disconnect() mid-connect races the connect promise.
1566
+ if (wasConnected) await backend.disconnect();
1567
+ } finally {
1568
+ bridgeRuntimeConnected = false;
1569
+ logOwnership(`standby: ${reason}`);
1570
+ }
1571
+ }
1572
+ function refreshBridgeOwnershipSafe(options = {}) {
1573
+ refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
1574
+ }
1575
+ function startOwnerHeartbeat() {
1576
+ if (ownerHeartbeatTimer) return;
1577
+ ownerHeartbeatTimer = setInterval(() => {
1578
+ try { refreshActiveInstance(INSTANCE_ID); }
1579
+ catch (e) {
1580
+ process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
1581
+ }
1582
+ }, OWNER_HEARTBEAT_INTERVAL_MS);
1583
+ ownerHeartbeatTimer.unref?.();
1584
+ }
1585
+ function stopOwnerHeartbeat() {
1586
+ if (!ownerHeartbeatTimer) return;
1587
+ clearInterval(ownerHeartbeatTimer);
1588
+ ownerHeartbeatTimer = null;
1589
+ }
1590
+ async function refreshBridgeOwnership(options = {}) {
1591
+ // Coalesce concurrent callers onto the in-flight refresh so backend tool
1592
+ // calls landing during normal login wait for the same connect attempt
1593
+ // instead of returning early and observing spurious auto-connect failure.
1594
+ if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
1595
+ bridgeOwnershipRefreshInFlight = (async () => {
1596
+ if (!channelBridgeActive) {
1597
+ const { active: active2 } = currentOwnerState();
1598
+ if (active2?.httpPort && !proxyMode) {
1599
+ const alive = await pingOwner(active2.httpPort);
1600
+ if (alive) {
1601
+ proxyMode = true;
1602
+ ownerHttpPort = active2.httpPort;
1603
+ logOwnership(`non-channel session \u2014 proxy mode via ${active2.instanceId}`);
1604
+ }
1605
+ }
1606
+ return;
1607
+ }
1608
+ const { active, owned } = currentOwnerState();
1609
+ const activeHttpPort = Number(active?.httpPort) || 0;
1610
+ let activeHttpChecked = false;
1611
+ let activeHttpAlive = false;
1612
+ const checkActiveHttp = async () => {
1613
+ if (!activeHttpPort) return false;
1614
+ if (!activeHttpChecked) {
1615
+ activeHttpAlive = await pingOwner(activeHttpPort);
1616
+ activeHttpChecked = true;
1617
+ }
1618
+ return activeHttpAlive;
1619
+ };
1620
+ const enterProxyMode = (note) => {
1621
+ proxyMode = true;
1622
+ ownerHttpPort = activeHttpPort;
1623
+ if (note) logOwnership(note);
1624
+ };
1625
+ if (proxyMode && !owned && activeHttpPort) {
1626
+ const alive = await checkActiveHttp();
1627
+ if (!alive) {
1628
+ process.stderr.write(`[ownership] owner ping failed, attempting takeover
1629
+ `);
1630
+ proxyMode = false;
1631
+ ownerHttpPort = 0;
1632
+ claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
1633
+ const next2 = currentOwnerState();
1634
+ if (next2.owned) {
1635
+ refreshActiveInstance(INSTANCE_ID);
1636
+ await startOwnedRuntime(options);
1637
+ }
1638
+ return;
1639
+ }
1640
+ // Active owner is alive but may have rebound to a new port since the
1641
+ // previous refresh (owner restart on a different PROXY_PORT). Sync
1642
+ // ownerHttpPort so subsequent proxyRequest() hits the new port instead
1643
+ // of the stale value cached at proxy-mode entry.
1644
+ if (ownerHttpPort !== activeHttpPort) {
1645
+ ownerHttpPort = activeHttpPort;
1646
+ logOwnership(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
1647
+ }
1648
+ return;
1649
+ }
1650
+ if (!owned && activeHttpPort) {
1651
+ const alive = await checkActiveHttp();
1652
+ if (alive) {
1653
+ enterProxyMode(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
1654
+ return;
1655
+ }
1656
+ const updatedAt = Number(active?.updatedAt);
1657
+ const activeAgeMs = Number.isFinite(updatedAt) ? Date.now() - updatedAt : Number.POSITIVE_INFINITY;
1658
+ if (active?.backendReady === true || activeAgeMs > ACTIVE_OWNER_STALE_MS) {
1659
+ logOwnership(`owner ${active.instanceId} port ${activeHttpPort} unreachable`);
1660
+ claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
1661
+ }
1662
+ }
1663
+ if (!owned && canStealOwnership(active)) {
1664
+ claimBridgeOwnership(active ? `takeover from ${active.instanceId}` : "startup");
1665
+ }
1666
+ const next = currentOwnerState();
1667
+ if (next.owned) {
1668
+ refreshActiveInstance(INSTANCE_ID);
1669
+ await startOwnedRuntime(options);
1670
+ return;
1671
+ }
1672
+ if (bridgeRuntimeConnected) {
1673
+ const reason = next.active?.instanceId ? `newer server ${next.active.instanceId}` : "no active owner";
1674
+ await stopOwnedRuntime(reason);
1675
+ return;
1676
+ }
1677
+ if (next.active?.httpPort && !proxyMode) {
1678
+ const alive = await pingOwner(next.active.httpPort);
1679
+ if (alive) {
1680
+ proxyMode = true;
1681
+ ownerHttpPort = next.active.httpPort;
1682
+ logOwnership(`proxy mode via owner ${next.active.instanceId} port ${next.active.httpPort}`);
1683
+ return;
1684
+ }
1685
+ }
1686
+ if (next.active?.instanceId) {
1687
+ logOwnership(`standby under owner ${next.active.instanceId}`);
1688
+ }
1689
+ })();
1690
+ try {
1691
+ return await bridgeOwnershipRefreshInFlight;
1692
+ } finally {
1693
+ bridgeOwnershipRefreshInFlight = null;
1694
+ }
1695
+ }
1696
+
1697
+ async function reloadRuntimeConfig() {
1698
+ const previousBackend = backend;
1699
+ const previousBackendName = previousBackend?.name || "";
1700
+ config = loadConfig();
1701
+ scheduler.reloadConfig(
1702
+ config.nonInteractive ?? [],
1703
+ config.interactive ?? [],
1704
+ // channelsConfig: channel-label resolution only.
1705
+ config.channelsConfig,
1706
+ // 0.1.62: top-level normalized config (quiet/schedules).
1707
+ config,
1708
+ { restart: bridgeRuntimeConnected }
1709
+ );
1710
+ const nextBackend = createBackend(config);
1711
+ const backendChanged = (nextBackend?.name || "") !== previousBackendName;
1712
+ if (backendChanged) {
1713
+ const shouldRestart = bridgeRuntimeConnected || bridgeRuntimeStarting;
1714
+ if (shouldRestart) await stopOwnedRuntime("backend config changed");
1715
+ backend = nextBackend;
1716
+ if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
1717
+ } else if (nextBackend !== previousBackend) {
1718
+ try { await nextBackend.disconnect?.(); } catch {}
1719
+ }
1720
+ if (bridgeRuntimeConnected) {
1721
+ syncOwnedWebhookAndEventRuntime({ reload: true });
1722
+ } else {
1723
+ await stopWebhookAndEventRuntime();
1724
+ }
1725
+ }
1726
+ function injectAndRecord(channelId, name, content, options) {
1727
+ // Strip soft-warn marker blocks (Tool-loop / Repeated-input / legacy
1728
+ // Repeated-tool / Mixed-tool / Tool-budget / Same-file multi-chunk /
1729
+ // Bash file-lookup / Iteration / 0-match advisory) from anywhere in the
1730
+ // outbound body. Markers are
1731
+ // intentionally prepended onto tool RESULTS upstream (tool-loop-guard.mjs
1732
+ // build*Warn) so the model
1733
+ // self-corrects, but bridge roles commonly echo them and we don't want them
1734
+ // surfacing in Discord / Lead channel push.
1735
+ if (typeof content === 'string') content = stripSoftWarns(content);
1736
+ // Skip-protocol guard: bridge agents (webhook-handler / scheduler-task)
1737
+ // prefix `[meta:silent]` on the first line to opt out
1738
+ // of Lead inject for genuine no-op results (label-only events, dedup,
1739
+ // "nothing to report"). The body still goes to Discord for audit; only
1740
+ // the Lead-context inject is suppressed. See rules/bridge/20-skip-protocol.md.
1741
+ if (typeof content === 'string') {
1742
+ const m = content.match(/^\s*\[meta:silent\][^\n]*\n?([\s\S]*)$/);
1743
+ if (m) {
1744
+ content = m[1];
1745
+ options = { ...(options || {}), silent_to_agent: true };
1746
+ }
1747
+ }
1748
+ const ts = new Date().toISOString();
1749
+ const now = new Date();
1750
+ const timeLabel = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")} `;
1751
+ const sourceLabel = options?.type ? `${timeLabel}: ${options.type}` : timeLabel;
1752
+ const meta = { chat_id: channelId, user: sourceLabel, user_id: "system", ts };
1753
+ if (options?.instruction) meta.instruction = options.instruction;
1754
+ if (options?.type) meta.type = options.type;
1755
+ // `silent_to_agent` — lifecycle status pings (worker/iter/started echoes)
1756
+ // surface on Discord but should NOT land in Lead's context window. When
1757
+ // set, skip the parent-notify hop but keep the Discord-forward + event-log
1758
+ // record. The meta flag is also propagated downstream so consumers that
1759
+ // still see the notification (e.g. Lead itself if emission changes later)
1760
+ // can recognise and drop it. Default is false → legacy behaviour preserved.
1761
+ if (options?.silent_to_agent) meta.silent_to_agent = true;
1762
+ const silent = options?.silent_to_agent === true;
1763
+ if (!silent) {
1764
+ sendNotifyToParent("notifications/claude/channel", { content, meta });
1765
+ } else {
1766
+ forwardLifecycleToDiscord(channelId, content);
1767
+ }
1768
+ }
1769
+
1770
+ // Best-effort direct Discord emission for silent-to-agent lifecycle pings.
1771
+ // Only used when the parent-notify hop is skipped, so the user still sees
1772
+ // the status on Discord even though Lead will never echo it through the
1773
+ // transcript-tail forwarder. Falls back to a no-op when no channel is
1774
+ // resolvable — lifecycle pings are non-critical.
1775
+ function forwardLifecycleToDiscord(channelId, content) {
1776
+ try {
1777
+ // Skip rather than guess: lifecycle callers pass the channelId they own;
1778
+ // falling back to statusState.channelId can route to a stale/unrelated
1779
+ // channel when the caller did not supply one intentionally.
1780
+ const target = channelId || null;
1781
+ dropTrace("send.lifecycle.entry", { channelId: target || "(none)", bindingReadyStatus, backendPresent: !!backend?.sendMessage, preview: preview(content) });
1782
+ if (!target || !backend?.sendMessage) return;
1783
+ void bindingReady.then(() =>
1784
+ backend.sendMessage(target, content)
1785
+ .then(() => dropTrace("send.lifecycle.ok", { channelId: target }))
1786
+ .catch((err) => dropTrace("send.lifecycle.err", { channelId: target, err: String(err) }))
1787
+ ).catch(() => {});
1788
+ } catch { /* best-effort */ }
1789
+ }
1790
+ scheduler.setInjectHandler((channelId, name, content, options) => {
1791
+ injectAndRecord(channelId, name, content, options);
1792
+ });
1793
+ scheduler.setSendHandler(async (channelId, text) => {
1794
+ // Skip protocol: a scheduler-task emitting `[meta:silent]` has nothing to
1795
+ // report — suppress the channel send entirely (no noise). Mirrors the
1796
+ // webhook delegate drop and injectAndRecord's silent handling.
1797
+ if (typeof text === "string" && /^\s*\[meta:silent\]/.test(text)) {
1798
+ dropTrace("send.scheduler.silent", { channelId });
1799
+ return;
1800
+ }
1801
+ dropTrace("send.scheduler.entry", { channelId, preview: preview(text) });
1802
+ await bindingReady;
1803
+ dropTrace("send.scheduler.ready", { channelId });
1804
+ try {
1805
+ await backend.sendMessage(channelId, text);
1806
+ dropTrace("send.scheduler.ok", { channelId });
1807
+ } catch (err) {
1808
+ dropTrace("send.scheduler.err", { channelId, err: String(err) });
1809
+ throw err;
1810
+ }
1811
+ });
1812
+ function wireWebhookHandlers() {
1813
+ if (!webhookServer) return;
1814
+ webhookServer.setEventPipeline(eventPipeline);
1815
+ webhookServer.setBridgeDispatch(async ({ role, preset, prompt, cwd, context }) => {
1816
+ // Delegate-mode webhook → bridge orchestrator. Each bridge progress /
1817
+ // final event is forwarded to the Lead via the same channel-notify
1818
+ // path used by schedule & event-queue (injectAndRecord). Silent
1819
+ // lifecycle pings keep routing only to Discord.
1820
+ const agentMod = await import("../agent/index.mjs");
1821
+ const channelId = resolveWebhookChannelId(context?.channel);
1822
+ const endpoint = context?.endpoint || "unknown";
1823
+ const event = context?.event || null;
1824
+ const deliveryId = context?.deliveryId || null;
1825
+ const label = `webhook:${endpoint}`;
1826
+ const instruction = `Webhook review from role=${role} on endpoint "${endpoint}"`
1827
+ + (event ? ` (event=${event})` : "")
1828
+ + (deliveryId ? ` (delivery=${deliveryId})` : "")
1829
+ + ". Relay the finding to the user naturally — summarize clearly, call out any issues, and note what needs a decision.";
1830
+ const notifyFn = (text, meta = {}) => {
1831
+ if (!text) return;
1832
+ // Webhook skip protocol: when the bridge worker emits a `[meta:silent]`
1833
+ // marker (optionally behind model/role tag prefixes), the event is a
1834
+ // no-op (label-only, dedup, "nothing to report"). Drop the message
1835
+ // entirely — neither Lead inject nor Discord forward — instead of the
1836
+ // partial `silent_to_agent` semantics that still audit to Discord.
1837
+ const raw = String(text);
1838
+ if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
1839
+ // Deterministic findings-count drop. Code-review handlers emit a
1840
+ // structured `[[findings:N]]` token (N = number of issues). The RELAY —
1841
+ // not the worker's prose — decides: N==0 => clean review, drop entirely
1842
+ // (no Lead inject, no Discord forward). Token absent => fail-safe forward
1843
+ // so a real finding is never silently dropped if the worker omits it.
1844
+ const fc = raw.match(/\[\[findings:(\d+)\]\]/i);
1845
+ if (fc && Number(fc[1]) === 0) return;
1846
+ // Lifecycle pings (started / iter echoes, marked silent_to_agent) are
1847
+ // channel noise for an automated webhook review — drop them entirely so
1848
+ // a skip stays fully silent and only the final answer reaches the
1849
+ // channel. The final [meta:silent] skip result is already dropped above.
1850
+ if (meta?.silent_to_agent === true) return;
1851
+ // Strip the verdict token before surfacing (findings present, N>0).
1852
+ const surfaced = raw.replace(/\[\[findings:\d+\]\]/gi, "").replace(/[^\S\n]{2,}/g, " ").trim();
1853
+ injectAndRecord(channelId, label, surfaced || raw, {
1854
+ type: "webhook",
1855
+ instruction,
1856
+ });
1857
+ };
1858
+ // Per-terminal cwd under the daemon's single channels worker. A webhook
1859
+ // result is injected to ownerConn() — the connection whose session.leadPid
1860
+ // equals active-instance ownerLeadPid — so the worker must run in THAT
1861
+ // owner terminal's cwd. Read the sentinel keyed by ownerLeadPid; cwd-tool
1862
+ // writes session-cwd-<leadPid>.txt per connection, so write and read meet
1863
+ // on the same leadPid key no matter which terminal holds the owner seat.
1864
+ // Falls back to the session entry position; never the plugin CACHE root.
1865
+ const ownerPid = getActiveOwnerPid(readActiveInstance());
1866
+ const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
1867
+ return agentMod.handleToolCall(
1868
+ "bridge",
1869
+ { role, preset, prompt, cwd: cwd || ownerCwd },
1870
+ { notifyFn },
1871
+ );
1872
+ });
1873
+ }
1874
+ function resolveWebhookChannelId(channelLabel) {
1875
+ // Fail closed: route only to channels explicitly present in config —
1876
+ // the endpoint's owner-configured `channel`, else the `main` channel.
1877
+ // Runtime / persisted-status fallbacks are never used (they could route
1878
+ // a delivery to an arbitrary or stale channel). The endpoint channel is
1879
+ // owner-authored config, not attacker payload, so honoring it is safe.
1880
+ const channels = config?.channelsConfig || {};
1881
+ if (channelLabel && channels[channelLabel]?.channelId) return channels[channelLabel].channelId;
1882
+ return channels.main?.channelId || "";
1883
+ }
1884
+ function wireEventQueueHandlers(eventQueue) {
1885
+ if (!eventQueue) return;
1886
+ eventQueue.setInjectHandler((channelId, name, content, options) => {
1887
+ injectAndRecord(channelId, name, content, options);
1888
+ });
1889
+ // Defensive ownership probe: the queue tick should only run in the active
1890
+ // owner process. Standby / proxy instances see bridgeRuntimeConnected=false
1891
+ // or proxyMode=true and will skip the tick even if an errant start() slipped
1892
+ // through.
1893
+ eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
1894
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
1895
+ }
1896
+ function editDiscordMessage(channelId, messageId, label) {
1897
+ // Behavior-preserving: route through the backend abstraction (which uses
1898
+ // discord.js under the hood) instead of issuing a raw REST PATCH. Errors
1899
+ // are swallowed to stderr to match the prior fire-and-forget shape — the
1900
+ // call site never awaited the HTTPS request either.
1901
+ if (!getDiscordToken()) return;
1902
+ const text = `\u{1F510} **Permission Request** \u2014 ${label}`;
1903
+ void backend.editMessage(channelId, messageId, text, { components: [] }).catch((err) => {
1904
+ process.stderr.write(`mixdog: editDiscordMessage failed: ${err}
1905
+ `);
1906
+ });
1907
+ }
1908
+ backend.onModalRequest = async (rawInteraction) => {
1909
+ if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
1910
+ refreshBridgeOwnershipSafe();
1911
+ return;
1912
+ }
1913
+ const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = await import("discord.js");
1914
+ const customId = rawInteraction.customId;
1915
+ const channelId = rawInteraction.channelId ?? "";
1916
+ pendingSetup.rememberMessage(rawInteraction.user.id, channelId, rawInteraction.message?.id);
1917
+ const modalSpec = buildModalRequestSpec(
1918
+ customId,
1919
+ pendingSetup.get(rawInteraction.user.id, channelId),
1920
+ loadProfileConfig()
1921
+ );
1922
+ if (!modalSpec) return;
1923
+ const modal = new ModalBuilder().setCustomId(modalSpec.customId).setTitle(modalSpec.title);
1924
+ const rows = modalSpec.fields.map(
1925
+ (field) => new ActionRowBuilder().addComponents((() => {
1926
+ const input = new TextInputBuilder().setCustomId(field.id).setLabel(field.label).setStyle(TextInputStyle.Short).setRequired(field.required);
1927
+ if (field.value) input.setValue(field.value);
1928
+ return input;
1929
+ })())
1930
+ );
1931
+ modal.addComponents(...rows);
1932
+ await rawInteraction.showModal(modal);
1933
+ };
1934
+ const pendingPermRequests = new Map();
1935
+ const TOOL_EXEC_CONSUMER_MARKER = path.join(RUNTIME_ROOT, '.tool-exec-consumer');
1936
+ function refreshToolExecConsumerMarker() {
1937
+ try {
1938
+ if (pendingPermRequests.size > 0) {
1939
+ fs.writeFileSync(TOOL_EXEC_CONSUMER_MARKER, String(Date.now()));
1940
+ } else {
1941
+ try { fs.unlinkSync(TOOL_EXEC_CONSUMER_MARKER); } catch {}
1942
+ }
1943
+ } catch {}
1944
+ }
1945
+ // Watch for terminal-approved tool executions. The PostToolUse hook writes a
1946
+ // signal file per tool call; when we see one, find the oldest pending perm
1947
+ // request with a matching tool name and mark its Discord message as
1948
+ // "Allowed (terminal)" so users don't see stale active buttons.
1949
+ try {
1950
+ try { if (!fs.existsSync(RUNTIME_ROOT)) fs.mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
1951
+ const SIGNAL_RE = /^tool-exec-\d+-[0-9a-f]+\.signal$/;
1952
+ fs.watch(RUNTIME_ROOT, { persistent: false }, (eventType, filename) => {
1953
+ if (!filename || !SIGNAL_RE.test(filename)) return;
1954
+ setTimeout(() => {
1955
+ try {
1956
+ const signalPath = path.join(RUNTIME_ROOT, filename);
1957
+ let raw;
1958
+ try { raw = fs.readFileSync(signalPath, 'utf8'); } catch { return; }
1959
+ let payload;
1960
+ try { payload = JSON.parse(raw); } catch { return; }
1961
+ const toolName = payload?.toolName;
1962
+ if (!toolName) return;
1963
+ const sigFilePath = payload?.filePath || '';
1964
+ let oldestKey = null;
1965
+ let oldestEntry = null;
1966
+ for (const [k, v] of pendingPermRequests) {
1967
+ if (v.toolName !== toolName) continue;
1968
+ // Bind on filePath too. If both sides are empty (non-file tools
1969
+ // like Bash), toolName alone is the match. Otherwise both must
1970
+ // equal — prevents two concurrent Edit/Write requests from
1971
+ // cross-approving each other.
1972
+ const vFilePath = v.filePath || '';
1973
+ if (vFilePath || sigFilePath) {
1974
+ if (vFilePath !== sigFilePath) continue;
1975
+ }
1976
+ if (!oldestEntry || v.createdAt < oldestEntry.createdAt) {
1977
+ oldestKey = k;
1978
+ oldestEntry = v;
1979
+ }
1980
+ }
1981
+ // No matching pending request — leave the signal on disk so a
1982
+ // bridge role hook (or other consumer) gets a chance to claim it.
1983
+ if (!oldestKey || !oldestEntry) return;
1984
+ if (oldestEntry.channelId && oldestEntry.messageId) {
1985
+ try {
1986
+ editDiscordMessage(oldestEntry.channelId, oldestEntry.messageId, 'Allowed (terminal)');
1987
+ } catch (err) {
1988
+ try { process.stderr.write(`mixdog channels: tool-exec signal edit failed: ${err && err.message || err}\n`); } catch {}
1989
+ }
1990
+ }
1991
+ pendingPermRequests.delete(oldestKey);
1992
+ refreshToolExecConsumerMarker();
1993
+ // Only unlink once we've confirmed the match and handled it.
1994
+ try { fs.unlinkSync(signalPath); } catch {}
1995
+ } catch (err) {
1996
+ try { process.stderr.write(`mixdog channels: tool-exec signal handler error: ${err && err.message || err}\n`); } catch {}
1997
+ }
1998
+ }, 50);
1999
+ });
2000
+ // Stale-signal sweeper: any signal file older than 60s is removed so
2001
+ // unclaimed files don't accumulate on disk. Runs every 30s.
2002
+ setInterval(() => {
2003
+ try {
2004
+ const now = Date.now();
2005
+ const entries = fs.readdirSync(RUNTIME_ROOT);
2006
+ for (const name of entries) {
2007
+ if (!SIGNAL_RE.test(name)) continue;
2008
+ const p = path.join(RUNTIME_ROOT, name);
2009
+ try {
2010
+ const st = fs.statSync(p);
2011
+ if (now - st.mtimeMs > 60_000) {
2012
+ try { fs.unlinkSync(p); } catch {}
2013
+ }
2014
+ } catch {}
2015
+ }
2016
+ } catch {}
2017
+ }, 30_000)?.unref?.();
2018
+ } catch (err) {
2019
+ try { process.stderr.write(`mixdog channels: tool-exec signal watcher setup failed: ${err && err.message || err}\n`); } catch {}
2020
+ }
2021
+
2022
+ backend.onInteraction = (interaction) => {
2023
+ // Channel-route permission reply. Custom_id format: perm-ch-{request_id}-{allow|session|deny}.
2024
+ // request_id is the 5-letter short ID CC generates via shortRequestId().
2025
+ // Emit notifications/claude/channel/permission back to Claude Code; the race
2026
+ // logic in interactiveHandler.ts resolves the pending request and dismisses
2027
+ // every other racer (local dialog, bridge, hook, classifier).
2028
+ if (interaction.customId?.startsWith("perm-ch-")) {
2029
+ const match = interaction.customId.match(/^perm-ch-([a-km-z]{5})-(allow|session|deny)$/);
2030
+ if (!match) return;
2031
+ const [, requestId, action] = match;
2032
+ const access = config.access;
2033
+ if (access?.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
2034
+ process.stderr.write(`mixdog: perm-ch button rejected — user ${interaction.userId} not in allowFrom\n`);
2035
+ return;
2036
+ }
2037
+ const pending = pendingPermRequests.get(requestId);
2038
+ pendingPermRequests.delete(requestId);
2039
+ refreshToolExecConsumerMarker();
2040
+ const params = { request_id: requestId };
2041
+ if (action === 'deny') {
2042
+ params.behavior = 'deny';
2043
+ } else if (action === 'session') {
2044
+ params.behavior = 'allow';
2045
+ const toolName = pending?.toolName;
2046
+ if (toolName) {
2047
+ params.updatedPermissions = [{ type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'session' }];
2048
+ }
2049
+ } else {
2050
+ params.behavior = 'allow';
2051
+ }
2052
+ sendNotifyToParent('notifications/claude/channel/permission', params);
2053
+ const labels = { allow: 'Approved', session: 'Session Approved', deny: 'Denied' };
2054
+ if (interaction.message?.id && interaction.channelId) {
2055
+ editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
2056
+ }
2057
+ return;
2058
+ }
2059
+ if (interaction.customId?.startsWith("perm-")) {
2060
+ const match = interaction.customId.match(/^perm-([0-9a-f]{32})-(allow|session|deny)$/);
2061
+ if (!match) return;
2062
+ const [, uuid, action] = match;
2063
+ const access = config.access;
2064
+ if (!access) {
2065
+ const _permDropLine = `[${localTimestamp()}] perm interaction dropped: no access config\n`;
2066
+ if (isMixdogDebug()) {
2067
+ fs.appendFileSync(_bootLog, _permDropLine);
2068
+ } else {
2069
+ appendSessionStartCriticalLog(DATA_DIR, `[channels] ${_permDropLine}`);
2070
+ }
2071
+ return;
2072
+ }
2073
+ if (access.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
2074
+ process.stderr.write(`mixdog: perm button rejected \u2014 user ${interaction.userId} not in allowFrom
2075
+ `);
2076
+ return;
2077
+ }
2078
+ const resultPaths = [getPermissionResultPath(INSTANCE_ID, uuid)];
2079
+ const leadInstanceId = String(TERMINAL_LEAD_PID);
2080
+ if (leadInstanceId && leadInstanceId !== INSTANCE_ID) {
2081
+ resultPaths.push(getPermissionResultPath(leadInstanceId, uuid));
2082
+ }
2083
+ for (const resultPath of resultPaths) {
2084
+ try {
2085
+ fs.writeFileSync(resultPath, action, { flag: "wx" });
2086
+ } catch (e) {
2087
+ if (e.code !== "EEXIST") {
2088
+ process.stderr.write(`mixdog: writePermissionResult failed: ${e.message}\n`);
2089
+ }
2090
+ }
2091
+ }
2092
+ const labels = { allow: "Approved", session: "Session Approved", deny: "Denied" };
2093
+ if (interaction.message?.id && interaction.channelId) {
2094
+ editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
2095
+ }
2096
+ return;
2097
+ }
2098
+ if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
2099
+ refreshBridgeOwnershipSafe();
2100
+ return;
2101
+ }
2102
+ scheduler.noteActivity();
2103
+ if (interaction.customId === "stop_task") {
2104
+ controlClaudeSession(INSTANCE_ID, { type: "interrupt" })
2105
+ .catch(err => process.stderr.write(`[channels] controlClaudeSession rejected: ${err?.message || err}\n`));
2106
+ writeTextFile(TURN_END_FILE, String(Date.now()));
2107
+ return;
2108
+ }
2109
+ sendNotifyToParent("notifications/claude/channel", {
2110
+ content: `[interaction] ${interaction.type}: ${interaction.customId}${interaction.values ? " values=" + interaction.values.join(",") : ""}`,
2111
+ meta: {
2112
+ chat_id: interaction.channelId,
2113
+ user: `interaction:${interaction.type}`,
2114
+ user_id: interaction.userId,
2115
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2116
+ interaction_type: interaction.type,
2117
+ custom_id: interaction.customId,
2118
+ ...interaction.values ? { values: interaction.values.join(",") } : {},
2119
+ ...interaction.message ? { message_id: interaction.message.id } : {}
2120
+ }
2121
+ });
2122
+ };
2123
+ function isVoiceAttachment(contentType) {
2124
+ if (typeof contentType !== 'string') return false;
2125
+ const ct = contentType.toLowerCase();
2126
+ return ct.startsWith("audio/") || ct.startsWith("application/ogg");
2127
+ }
2128
+ function runCmd(cmd, args, capture = false) {
2129
+ return new Promise((resolve, reject) => {
2130
+ const proc = spawn(cmd, args, {
2131
+ stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore",
2132
+ windowsHide: true
2133
+ });
2134
+ let out = "";
2135
+ if (capture && proc.stdout) proc.stdout.on("data", (d) => {
2136
+ out += d;
2137
+ });
2138
+ proc.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} exit ${code}`)));
2139
+ proc.on("error", reject);
2140
+ });
2141
+ }
2142
+ let resolvedWhisperLanguage = null;
2143
+ function normalizeWhisperLanguage(value) {
2144
+ const raw = String(value ?? "").trim().toLowerCase();
2145
+ if (!raw || raw === "auto") return null;
2146
+ if (raw.startsWith("ko")) return "ko";
2147
+ if (raw.startsWith("ja")) return "ja";
2148
+ if (raw.startsWith("en")) return "en";
2149
+ if (raw.startsWith("zh")) return "zh";
2150
+ if (raw.startsWith("de")) return "de";
2151
+ if (raw.startsWith("fr")) return "fr";
2152
+ if (raw.startsWith("es")) return "es";
2153
+ if (raw.startsWith("it")) return "it";
2154
+ if (raw.startsWith("pt")) return "pt";
2155
+ if (raw.startsWith("ru")) return "ru";
2156
+ return raw;
2157
+ }
2158
+ function detectDeviceLanguage() {
2159
+ if (resolvedWhisperLanguage) return resolvedWhisperLanguage;
2160
+ const candidates = [
2161
+ process.env.MIXDOG_CHANNELS_WHISPER_LANGUAGE,
2162
+ process.env.LC_ALL,
2163
+ process.env.LC_MESSAGES,
2164
+ process.env.LANG,
2165
+ Intl.DateTimeFormat().resolvedOptions().locale
2166
+ ];
2167
+ for (const candidate of candidates) {
2168
+ const normalized = normalizeWhisperLanguage(candidate);
2169
+ if (normalized) {
2170
+ resolvedWhisperLanguage = normalized;
2171
+ return normalized;
2172
+ }
2173
+ }
2174
+ resolvedWhisperLanguage = "auto";
2175
+ return resolvedWhisperLanguage;
2176
+ }
2177
+ // ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
2178
+ const _voiceTranscriptionQueue = (() => {
2179
+ let running = 0;
2180
+ const pending = [];
2181
+ function drain() {
2182
+ const limit = config.voice?.transcription?.maxConcurrency ?? 1;
2183
+ while (running < limit && pending.length > 0) {
2184
+ const { fn, resolve, reject } = pending.shift();
2185
+ running++;
2186
+ fn().then(resolve, reject).finally(() => { running--; drain(); });
2187
+ }
2188
+ }
2189
+ return function enqueue(fn) {
2190
+ return new Promise((resolve, reject) => { pending.push({ fn, resolve, reject }); drain(); });
2191
+ };
2192
+ })();
2193
+
2194
+ // ── wav + transcript cache keyed by attachment id ──
2195
+ const _voiceWavCache = new Map(); // attachmentId → wavPath
2196
+ const _voiceTranscriptCache = new Map(); // attachmentId → transcript string
2197
+ const _voiceInflight = new Map(); // attachmentId → Promise<string|null>
2198
+ const _voiceFfmpegInflight = new Map(); // attachmentId|wavPath → Promise<void> single-flight ffmpeg
2199
+
2200
+ async function _probeAudioDurationSec(filePath) {
2201
+ try {
2202
+ const ffprobePath = (() => { try { return _require('ffprobe-static').path; } catch { return 'ffprobe'; } })();
2203
+ return await new Promise((resolve, reject) => {
2204
+ const args = ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath];
2205
+ let out = '';
2206
+ const proc = spawn(ffprobePath, args, { windowsHide: true });
2207
+ proc.stdout.on('data', (d) => { out += d; });
2208
+ proc.on('close', (code) => { code === 0 ? resolve(parseFloat(out.trim()) || null) : reject(new Error(`ffprobe exit ${code}`)); });
2209
+ proc.on('error', reject);
2210
+ });
2211
+ } catch {
2212
+ return null;
2213
+ }
2214
+ }
2215
+
2216
+ async function transcribeVoice(audioPath, { attachmentId } = {}) {
2217
+ // ── size gate (config: voice.transcription.maxFileSizeMB) ──
2218
+ const maxSizeBytes = (config.voice?.transcription?.maxFileSizeMB ?? 0) * 1024 * 1024;
2219
+ if (maxSizeBytes > 0) {
2220
+ try {
2221
+ const stat = await fs.promises.stat(audioPath);
2222
+ if (stat.size > maxSizeBytes) {
2223
+ process.stderr.write(`mixdog: voice.transcription skipped — file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB > ${config.voice.transcription.maxFileSizeMB} MB): ${audioPath}\n`);
2224
+ return null;
2225
+ }
2226
+ } catch { /* stat failure: proceed */ }
2227
+ }
2228
+ // ── duration gate (config: voice.transcription.maxDurationSec) ──
2229
+ const maxDurationSec = config.voice?.transcription?.maxDurationSec ?? 0;
2230
+ if (maxDurationSec > 0) {
2231
+ const dur = await _probeAudioDurationSec(audioPath);
2232
+ if (dur !== null && dur > maxDurationSec) {
2233
+ process.stderr.write(`mixdog: voice.transcription skipped — audio too long (${Math.floor(dur)}s > ${maxDurationSec}s): ${audioPath}\n`);
2234
+ return null;
2235
+ }
2236
+ }
2237
+ // ── transcript cache hit ──
2238
+ if (attachmentId && _voiceTranscriptCache.has(attachmentId)) {
2239
+ process.stderr.write(`mixdog: voice.transcription cache hit (${attachmentId})\n`);
2240
+ return _voiceTranscriptCache.get(attachmentId);
2241
+ }
2242
+ if (attachmentId && _voiceInflight.has(attachmentId)) {
2243
+ return _voiceInflight.get(attachmentId);
2244
+ }
2245
+ const p = _voiceTranscriptionQueue(() => _doTranscribeVoice(audioPath, attachmentId));
2246
+ if (attachmentId) {
2247
+ _voiceInflight.set(attachmentId, p);
2248
+ p.catch((err) => {
2249
+ try { process.stderr.write(`mixdog: voice.transcription inflight rejection: ${err?.stack || err}\n`); } catch {}
2250
+ }).finally(() => _voiceInflight.delete(attachmentId));
2251
+ }
2252
+ return p;
2253
+ }
2254
+
2255
+ async function _doTranscribeVoice(audioPath, attachmentId) {
2256
+ try {
2257
+ const runtime = resolveVoiceRuntime(DATA_DIR);
2258
+ if (!runtime?.installed) {
2259
+ const missing = [runtime?.binary ? null : 'binary', runtime?.model ? null : 'model', runtime?.ffmpeg ? null : 'ffmpeg'].filter(Boolean).join(' + ');
2260
+ throw new Error(`voice runtime not installed (missing: ${missing}) — open the setup wizard and click "Install voice"`);
2261
+ }
2262
+ const whisperCmd = runtime.whisperCmd;
2263
+ const modelPath = runtime.modelPath;
2264
+ const ffmpegPath = runtime.ffmpegPath;
2265
+ const lang = normalizeWhisperLanguage(config.voice?.language) ?? detectDeviceLanguage();
2266
+ const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
2267
+ const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
2268
+ // ── wav cache keyed by attachment id ──
2269
+ let wavPath;
2270
+ if (attachmentId && _voiceWavCache.has(attachmentId)) {
2271
+ wavPath = _voiceWavCache.get(attachmentId);
2272
+ if (!fs.existsSync(wavPath)) {
2273
+ _voiceWavCache.delete(attachmentId);
2274
+ wavPath = undefined;
2275
+ } else {
2276
+ process.stderr.write(`mixdog: voice.transcription wav cache hit (${attachmentId})\n`);
2277
+ }
2278
+ }
2279
+ if (!wavPath) {
2280
+ wavPath = audioPath.replace(/\.[^.]+$/, ".wav");
2281
+ const sampleRate = config.voice?.transcription?.sampleRate ?? 16000;
2282
+ const channels = config.voice?.transcription?.channels ?? 1;
2283
+ // Single-flight: parallel callers for the same key share one ffmpeg spawn.
2284
+ const _ffmpegKey = attachmentId || wavPath;
2285
+ if (_voiceFfmpegInflight.has(_ffmpegKey)) {
2286
+ await _voiceFfmpegInflight.get(_ffmpegKey);
2287
+ } else {
2288
+ const _ffmpegPromise = runCmd(ffmpegPath, ["-i", audioPath, "-ar", String(sampleRate), "-ac", String(channels), "-threads", String(threadCount), "-y", wavPath]);
2289
+ _voiceFfmpegInflight.set(_ffmpegKey, _ffmpegPromise);
2290
+ try {
2291
+ await _ffmpegPromise;
2292
+ if (attachmentId) _voiceWavCache.set(attachmentId, wavPath);
2293
+ } finally {
2294
+ _voiceFfmpegInflight.delete(_ffmpegKey);
2295
+ }
2296
+ }
2297
+ }
2298
+ process.stderr.write(`mixdog: voice.transcription start runtime=${runtime.kind} cmd=${path.basename(whisperCmd)}\n`);
2299
+ await ensureReady({ serverCmd: runtime.serverCmd, modelPath, threadCount, host: '127.0.0.1' });
2300
+ const text = await transcribe(wavPath, { language: lang });
2301
+ const result = text.trim() || null;
2302
+ if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
2303
+ return result;
2304
+ } catch (err) {
2305
+ if (err?.message?.startsWith('voice runtime not installed')) throw err; // propagate setup errors; caller posts user-visible failure
2306
+ process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
2307
+ return null;
2308
+ }
2309
+ }
2310
+ import { TOOL_DEFS } from './tool-defs.mjs';
2311
+ function createHttpMcpServer() {
2312
+ const s = new Server(
2313
+ { name: "mixdog", version: PLUGIN_VERSION },
2314
+ { capabilities: { tools: {} }, instructions: INSTRUCTIONS }
2315
+ );
2316
+ s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
2317
+ s.setRequestHandler(CallToolRequestSchema, async (req) => {
2318
+ const toolName = req.params.name;
2319
+ const args = req.params.arguments ?? {};
2320
+ return handleToolCallWithBridgeRetry(toolName, args);
2321
+ });
2322
+ return s;
2323
+ }
2324
+ // Tool dispatch in worker mode goes through the IPC `call` handler at the
2325
+ // bottom of this file (parent's `callWorker` → `handleToolCall`). The HTTP
2326
+ // MCP path uses its own short-lived `Server` instance built by
2327
+ // `createHttpMcpServer()` above. There is no orphan worker-level Server.
2328
+ const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
2329
+ // ── Backend-tool dispatch helpers ───────────────────────────────────────────
2330
+ // Each helper transparently routes through proxyRequest() when this instance
2331
+ // is in proxyMode (non-owner), or through the local backend otherwise. The
2332
+ // MCP-result formatting (text shape, cache invalidation, isError flag) is
2333
+ // shared so both branches produce byte-identical output.
2334
+ // schedule_status / schedule_control share their result-formatting between
2335
+ // the local (owner) MCP case handlers and the owner-side HTTP routes that
2336
+ // serve proxied standby sessions. Keeping the body here makes both paths
2337
+ // byte-identical and reads the LIVE scheduler.
2338
+ function scheduleStatusResult() {
2339
+ const statuses = scheduler.getStatus();
2340
+ if (statuses.length === 0) {
2341
+ return { content: [{ type: "text", text: "no schedules configured" }] };
2342
+ }
2343
+ const lines = statuses.map((s) => {
2344
+ const state = s.running ? " [RUNNING]" : "";
2345
+ const last = s.lastFired ? ` (last: ${s.lastFired})` : "";
2346
+ return ` ${s.name} ${s.time} ${s.days} (${s.type})${state}${last}`;
2347
+ });
2348
+ return { content: [{ type: "text", text: lines.join("\n") }] };
2349
+ }
2350
+ function scheduleControlResult(args) {
2351
+ const scName = args.name;
2352
+ const action = args.action;
2353
+ // Validate that the named schedule actually exists.
2354
+ const _scAll = [...(scheduler.nonInteractive || []), ...(scheduler.interactive || [])];
2355
+ const _scKnown = _scAll.some(s => s.name === scName);
2356
+ if (!_scKnown) {
2357
+ return { content: [{ type: "text", text: `schedule_control: unknown schedule "${scName}" — use schedule_status to list valid names` }], isError: true };
2358
+ }
2359
+ if (action === "defer") {
2360
+ const minutes = args.minutes ?? 30;
2361
+ if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) {
2362
+ return { content: [{ type: "text", text: `schedule_control: minutes must be a positive number, got ${JSON.stringify(minutes)}` }], isError: true };
2363
+ }
2364
+ scheduler.defer(scName, minutes);
2365
+ return { content: [{ type: "text", text: `deferred "${scName}" for ${minutes} minutes` }] };
2366
+ } else if (action === "skip_today") {
2367
+ scheduler.skipToday(scName);
2368
+ return { content: [{ type: "text", text: `skipped "${scName}" for today` }] };
2369
+ }
2370
+ return { content: [{ type: "text", text: `unknown action: ${action}` }], isError: true };
2371
+ }
2372
+ async function dispatchReply(args) {
2373
+ const sendOpts = {
2374
+ replyTo: args.reply_to,
2375
+ files: args.files ?? [],
2376
+ embeds: args.embeds ?? [],
2377
+ components: args.components ?? []
2378
+ };
2379
+ let ids;
2380
+ if (proxyMode) {
2381
+ const proxyResult = await proxyRequest("/send", "POST", {
2382
+ chatId: args.chat_id,
2383
+ text: args.text,
2384
+ opts: sendOpts
2385
+ });
2386
+ if (!proxyResult.ok) {
2387
+ return { content: [{ type: "text", text: `proxy reply failed: ${proxyResult.error}` }], isError: true };
2388
+ }
2389
+ ids = proxyResult.data?.sentIds ?? [];
2390
+ } else {
2391
+ // Pre-send activity bump keeps idle gating consistent during the await.
2392
+ scheduler.noteActivity();
2393
+ const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
2394
+ // Lead-originated reply via proxy-mode MCP — bump activity.
2395
+ scheduler.noteActivity();
2396
+ ids = sendResult.sentIds;
2397
+ }
2398
+ const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
2399
+ return { content: [{ type: "text", text }] };
2400
+ }
2401
+ async function dispatchFetch(args) {
2402
+ const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
2403
+ const limit = args.limit ?? 20;
2404
+ let msgs;
2405
+ if (proxyMode) {
2406
+ const proxyResult = await proxyRequest(`/fetch?channel=${encodeURIComponent(channelId)}&limit=${limit}`, "GET");
2407
+ if (!proxyResult.ok) {
2408
+ return { content: [{ type: "text", text: `proxy fetch failed: ${proxyResult.error}` }], isError: true };
2409
+ }
2410
+ msgs = proxyResult.data?.messages ?? [];
2411
+ // recordFetchedMessages already ran on the owner side (/fetch route).
2412
+ } else {
2413
+ msgs = await backend.fetchMessages(channelId, limit);
2414
+ recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
2415
+ }
2416
+ const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
2417
+ const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
2418
+ return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
2419
+ }).join("\n");
2420
+ return { content: [{ type: "text", text }] };
2421
+ }
2422
+ async function dispatchReact(args) {
2423
+ if (proxyMode) {
2424
+ const proxyResult = await proxyRequest("/react", "POST", {
2425
+ chatId: args.chat_id,
2426
+ messageId: args.message_id,
2427
+ emoji: args.emoji
2428
+ });
2429
+ if (!proxyResult.ok) {
2430
+ return { content: [{ type: "text", text: `proxy react failed: ${proxyResult.error}` }], isError: true };
2431
+ }
2432
+ } else {
2433
+ await backend.react(args.chat_id, args.message_id, args.emoji);
2434
+ }
2435
+ return { content: [{ type: "text", text: "reacted" }] };
2436
+ }
2437
+ async function dispatchEditMessage(args) {
2438
+ const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
2439
+ let id;
2440
+ if (proxyMode) {
2441
+ const proxyResult = await proxyRequest("/edit", "POST", {
2442
+ chatId: args.chat_id,
2443
+ messageId: args.message_id,
2444
+ text: args.text,
2445
+ opts
2446
+ });
2447
+ if (!proxyResult.ok) {
2448
+ return { content: [{ type: "text", text: `proxy edit failed: ${proxyResult.error}` }], isError: true };
2449
+ }
2450
+ id = proxyResult.data?.id;
2451
+ } else {
2452
+ id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
2453
+ }
2454
+ return { content: [{ type: "text", text: `edited (id: ${id})` }] };
2455
+ }
2456
+ async function dispatchDownloadAttachment(args) {
2457
+ let files;
2458
+ if (proxyMode) {
2459
+ const proxyResult = await proxyRequest("/download", "POST", {
2460
+ chatId: args.chat_id,
2461
+ messageId: args.message_id
2462
+ });
2463
+ if (!proxyResult.ok) {
2464
+ return { content: [{ type: "text", text: `proxy download failed: ${proxyResult.error}` }], isError: true };
2465
+ }
2466
+ files = proxyResult.data?.files ?? [];
2467
+ } else {
2468
+ files = await backend.downloadAttachment(args.chat_id, args.message_id);
2469
+ }
2470
+ if (files.length === 0) {
2471
+ return { content: [{ type: "text", text: "message has no attachments" }] };
2472
+ }
2473
+ const lines = files.map(
2474
+ (f) => ` ${f.path} (${f.name}, ${f.contentType}, ${(f.size / 1024).toFixed(0)}KB)`
2475
+ );
2476
+ // Each downloaded file lands on the local FS; if any of them
2477
+ // had a stale prefetch entry from a prior session, drop it so
2478
+ // the next prefetch sees the fresh contents.
2479
+ for (const f of files) {
2480
+ if (f && typeof f.path === "string" && f.path) {
2481
+ invalidatePrefetchCache(f.path);
2482
+ }
2483
+ }
2484
+ return { content: [{ type: "text", text: `downloaded ${files.length} attachment(s):
2485
+ ${lines.join("\n")}` }] };
2486
+ }
2487
+ async function handleToolCall(name, args, _signal) {
2488
+ if (_channelsDegraded) {
2489
+ return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
2490
+ }
2491
+ let result;
2492
+ try {
2493
+ switch (name) {
2494
+ case "reply":
2495
+ result = await dispatchReply(args);
2496
+ break;
2497
+ case "fetch":
2498
+ result = await dispatchFetch(args);
2499
+ break;
2500
+ case "react":
2501
+ result = await dispatchReact(args);
2502
+ break;
2503
+ case "edit_message":
2504
+ result = await dispatchEditMessage(args);
2505
+ break;
2506
+ case "download_attachment":
2507
+ result = await dispatchDownloadAttachment(args);
2508
+ break;
2509
+ case "schedule_status": {
2510
+ if (proxyMode) {
2511
+ const proxyResult = await proxyRequest("/schedule-status", "GET");
2512
+ if (!proxyResult.ok) {
2513
+ result = { content: [{ type: "text", text: `proxy schedule_status failed: ${proxyResult.error}` }], isError: true };
2514
+ break;
2515
+ }
2516
+ result = proxyResult.data?.result ?? { content: [{ type: "text", text: "no schedules configured" }] };
2517
+ } else {
2518
+ result = scheduleStatusResult();
2519
+ }
2520
+ break;
2521
+ }
2522
+ case "trigger_schedule": {
2523
+ if (proxyMode) {
2524
+ const proxyResult = await proxyRequest("/trigger-schedule", "POST", { name: args.name });
2525
+ if (!proxyResult.ok) {
2526
+ result = { content: [{ type: "text", text: `proxy trigger_schedule failed: ${proxyResult.error}` }], isError: true };
2527
+ break;
2528
+ }
2529
+ const triggerResult = proxyResult.data?.result;
2530
+ result = { content: [{ type: "text", text: triggerResult == null ? "" : String(triggerResult) }] };
2531
+ } else {
2532
+ const triggerResult = await scheduler.triggerManual(args.name);
2533
+ result = { content: [{ type: "text", text: triggerResult }] };
2534
+ }
2535
+ break;
2536
+ }
2537
+ case "schedule_control": {
2538
+ if (proxyMode) {
2539
+ const proxyResult = await proxyRequest("/schedule-control", "POST", {
2540
+ name: args.name,
2541
+ action: args.action,
2542
+ minutes: args.minutes
2543
+ });
2544
+ if (!proxyResult.ok) {
2545
+ result = { content: [{ type: "text", text: `proxy schedule_control failed: ${proxyResult.error}` }], isError: true };
2546
+ break;
2547
+ }
2548
+ result = proxyResult.data?.result ?? { content: [{ type: "text", text: `unknown action: ${args.action}` }], isError: true };
2549
+ } else {
2550
+ result = scheduleControlResult(args);
2551
+ }
2552
+ break;
2553
+ }
2554
+ case "activate_channel_bridge": {
2555
+ if (proxyMode) {
2556
+ const proxyRes = await proxyRequest("/bridge/activate", "POST", { active: args.active === true });
2557
+ if (!proxyRes.ok) {
2558
+ result = { content: [{ type: "text", text: `proxy bridge activate failed: ${proxyRes.error}` }], isError: true };
2559
+ } else {
2560
+ channelBridgeActive = Boolean(args.active);
2561
+ writeBridgeState(channelBridgeActive);
2562
+ // Remote owner just deactivated and is tearing its owner-HTTP
2563
+ // server down. Drop our proxy pointer so subsequent direct
2564
+ // tool calls don't route through proxyRequest() to a port
2565
+ // about to close (ECONNREFUSED) or stripped of auth (401).
2566
+ if (!args.active) {
2567
+ proxyMode = false;
2568
+ ownerHttpPort = 0;
2569
+ }
2570
+ result = { content: [{ type: "text", text: `channel bridge ${args.active ? "activated" : "deactivated"}` }] };
2571
+ }
2572
+ } else {
2573
+ const active = args.active === true;
2574
+ const wasActive = channelBridgeActive;
2575
+ channelBridgeActive = active;
2576
+ writeBridgeState(active);
2577
+ if (active && !wasActive) {
2578
+ refreshBridgeOwnershipSafe({ restoreBinding: true });
2579
+ }
2580
+ if (!active && wasActive) {
2581
+ stopServerTyping();
2582
+ // Tear down the owner-side runtime so Discord/scheduler/webhook/
2583
+ // event-pipeline/owner-HTTP/heartbeat don't keep running on a
2584
+ // deactivated bridge (and to prevent this owner from later
2585
+ // entering proxyMode against its own port).
2586
+ try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
2587
+ process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
2588
+ }
2589
+ // Also clear proxyMode/ownerHttpPort. Without this, a session
2590
+ // that was acting as proxy when deactivate landed keeps the
2591
+ // stale flag + port set; later direct tool calls then route
2592
+ // through proxyRequest() to a port whose owner has just been
2593
+ // stopped or stripped of auth, returning ECONNREFUSED/401.
2594
+ if (proxyMode) {
2595
+ proxyMode = false;
2596
+ ownerHttpPort = 0;
2597
+ }
2598
+ }
2599
+ result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
2600
+ }
2601
+ break;
2602
+ }
2603
+ case "reload_config": {
2604
+ await reloadRuntimeConfig();
2605
+ // Extend reload to the agent module so providers/presets/maintenance
2606
+ // hot-reload on the same call (dynamic import: agent/index.mjs does not
2607
+ // import channels, so this stays acyclic and tolerant of load order).
2608
+ let agentReloadMsg = "";
2609
+ if (process.env.MIXDOG_STANDALONE !== '1') {
2610
+ try {
2611
+ const { reloadAgentConfig } = await import("../agent/index.mjs");
2612
+ await reloadAgentConfig("reload_config tool");
2613
+ agentReloadMsg = ", agent providers/presets/maintenance";
2614
+ } catch (err) {
2615
+ process.stderr.write(`[reload_config] agent reload failed: ${err?.message || String(err)}\n`);
2616
+ }
2617
+ }
2618
+ result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
2619
+ break;
2620
+ }
2621
+ case "inject_command": {
2622
+ const cmd = String(args?.command || "").trim();
2623
+ const ALLOW = new Set(["reload-plugins", "clear"]);
2624
+ if (!ALLOW.has(cmd)) {
2625
+ result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
2626
+ break;
2627
+ }
2628
+ // Unified managed-launcher control path (cross-platform). The
2629
+ // command is delivered to the `mixdog`-launched child's stdin by the
2630
+ // launcher that owns it — no OS/terminal keystroke injection, no new
2631
+ // window. Only sessions with an engaged native managed-launch bridge
2632
+ // are addressable; anything else gets a clear not-managed error
2633
+ // rather than a silent no-op.
2634
+ try {
2635
+ const launchId = managedLaunchId();
2636
+ if (!launchId) {
2637
+ result = { content: [{ type: "text", text: "inject_command: this session is not a managed `mixdog` launch (MIXDOG_LAUNCH_ID unset). Managed input delivery requires the native mixdog-launch PTY/ConPTY bridge." }], isError: true };
2638
+ break;
2639
+ }
2640
+ enqueueLauncherCommand(launchId, `/${cmd}`);
2641
+ result = { content: [{ type: "text", text: `queued /${cmd} for managed launcher (launchId=${launchId})` }] };
2642
+ } catch (err) {
2643
+ result = { content: [{ type: "text", text: `inject_command error: ${err?.message || err}` }], isError: true };
2644
+ }
2645
+ break;
2646
+ }
2647
+ // memory — handled by memory-service.mjs MCP
2648
+ default:
2649
+ result = {
2650
+ content: [{ type: "text", text: `unknown tool: ${name}` }],
2651
+ isError: true
2652
+ };
2653
+ }
2654
+ } catch (err) {
2655
+ const msg = err instanceof Error ? err.message : String(err);
2656
+ result = {
2657
+ content: [{ type: "text", text: `${name} failed: ${msg}` }],
2658
+ isError: true
2659
+ };
2660
+ }
2661
+ return result;
2662
+ }
2663
+ // Bridge auto-connect retry + forwarder-aware tool dispatch wrapper. Used by
2664
+ // both the HTTP MCP path (createHttpMcpServer's CallTool handler can call this)
2665
+ // and the worker IPC handler at the bottom of this file. The pre-v0.6.7 code
2666
+ // registered this on the orphan worker-level `Server`, which never had a
2667
+ // transport, so the wrapper never actually fired. Centralised here for reuse.
2668
+ // Last timestamp a forwardNewText() call was dispatched (debounce for item 4).
2669
+ let _lastForwardMs = 0;
2670
+
2671
+ async function handleToolCallWithBridgeRetry(toolName, args, signal) {
2672
+ // Debounce: only forward when ≥250 ms have elapsed since the last forward,
2673
+ // to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
2674
+ const now = Date.now();
2675
+ if (now - _lastForwardMs >= 250) {
2676
+ _lastForwardMs = now;
2677
+ await forwarder.forwardNewText();
2678
+ }
2679
+ if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected && !proxyMode) {
2680
+ if (_isCliOwnedMode) {
2681
+ await startCliOwnedRuntime({ restoreBinding: true });
2682
+ if (!bridgeRuntimeConnected) {
2683
+ return {
2684
+ content: [{ type: "text", text: `Channel runtime is not connected. Check token and network.` }],
2685
+ isError: true
2686
+ };
2687
+ }
2688
+ } else {
2689
+ // Do NOT pre-claim ownership here. claimBridgeOwnership() overwrites the
2690
+ // active-instance advert immediately, which kicks a live owner offline if
2691
+ // refreshBridgeOwnership() would have otherwise discovered them via
2692
+ // pingOwner() and entered proxyMode. Let refreshBridgeOwnership() below
2693
+ // ping/proxy the existing owner first and only fall through to a takeover
2694
+ // when the live owner is unreachable.
2695
+ for (let i = 0; i < 2 && !bridgeRuntimeConnected && !proxyMode; i++) {
2696
+ try {
2697
+ await refreshBridgeOwnership();
2698
+ } catch {
2699
+ }
2700
+ if (!bridgeRuntimeConnected && !proxyMode) await new Promise((r) => setTimeout(r, 300));
2701
+ }
2702
+ if (!bridgeRuntimeConnected && !proxyMode) {
2703
+ return {
2704
+ content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
2705
+ isError: true
2706
+ };
2707
+ }
2708
+ }
2709
+ }
2710
+ const result = await handleToolCall(toolName, args, signal);
2711
+ const toolLine = OutputForwarder.buildToolLine(toolName, args);
2712
+ if (toolLine) {
2713
+ // Distinct from the dispatch-log ok line (server-main.mjs): this forwards
2714
+ // a human-readable tool summary to Discord for the user, not operator stdout.
2715
+ void forwarder.forwardToolLog(toolLine, toolName, args);
2716
+ }
2717
+ return result;
2718
+ }
2719
+ const INBOUND_DEDUP_TTL = 5 * 6e4;
2720
+ const inboundSeen = /* @__PURE__ */ new Map();
2721
+ const INBOUND_DEDUP_DIR = path.join(os.tmpdir(), "mixdog-inbound");
2722
+ ensureDir(INBOUND_DEDUP_DIR);
2723
+ function writeChannelOwner(channelId) {
2724
+ const ownerPath = getChannelOwnerPath(channelId);
2725
+ try {
2726
+ fs.writeFileSync(ownerPath, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, updatedAt: Date.now() }));
2727
+ return true;
2728
+ } catch {
2729
+ return false;
2730
+ }
2731
+ }
2732
+ function shouldDropDuplicateInbound(msg) {
2733
+ const key = `${msg.chatId}:${msg.messageId}`;
2734
+ const now = Date.now();
2735
+ if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
2736
+ inboundSeen.set(key, now);
2737
+ const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
2738
+ try {
2739
+ fs.writeFileSync(marker, String(now), { flag: "wx" });
2740
+ } catch (e) {
2741
+ if (e.code === "EEXIST") {
2742
+ try {
2743
+ const stat = fs.statSync(marker);
2744
+ if (now - stat.mtimeMs < INBOUND_DEDUP_TTL) return true;
2745
+ } catch {}
2746
+ }
2747
+ }
2748
+ if (Math.random() < 0.1) {
2749
+ try {
2750
+ for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
2751
+ const fp = path.join(INBOUND_DEDUP_DIR, f);
2752
+ try {
2753
+ if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
2754
+ } catch {
2755
+ }
2756
+ }
2757
+ } catch {
2758
+ }
2759
+ }
2760
+ for (const [k, t] of inboundSeen) {
2761
+ if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
2762
+ }
2763
+ return false;
2764
+ }
2765
+ function resolveInboundRoute(chatId, parentChatId) {
2766
+ const main = config.channelsConfig?.main;
2767
+ const findEntry = (id) => {
2768
+ if (!id || !config.channelsConfig) return null;
2769
+ if (typeof main === "object" && main !== null && main.channelId === id) {
2770
+ return { label: "main", entry: main };
2771
+ }
2772
+ for (const [label, entry] of Object.entries(config.channelsConfig)) {
2773
+ if (typeof entry === "object" && entry !== null && entry.channelId === id) {
2774
+ return { label, entry };
2775
+ }
2776
+ }
2777
+ return null;
2778
+ };
2779
+ // Prefer a direct channelsConfig match on the thread/channel id; fall back
2780
+ // to the parent channel id so thread messages inherit the parent's label
2781
+ // and mode (e.g. monitor) instead of being routed as untagged interactive.
2782
+ const direct = findEntry(chatId);
2783
+ if (direct) {
2784
+ const mode = direct.entry.mode === "monitor" ? "monitor" : (direct.entry.mode || "interactive");
2785
+ return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: direct.label, sourceMode: mode };
2786
+ }
2787
+ if (parentChatId) {
2788
+ const viaParent = findEntry(parentChatId);
2789
+ if (viaParent) {
2790
+ const mode = viaParent.entry.mode === "monitor" ? "monitor" : (viaParent.entry.mode || "interactive");
2791
+ return { targetChatId: chatId, sourceChatId: parentChatId, sourceLabel: viaParent.label, sourceMode: mode };
2792
+ }
2793
+ }
2794
+ return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: undefined, sourceMode: "interactive" };
2795
+ }
2796
+ const inboundQueue = (() => {
2797
+ let tail = Promise.resolve();
2798
+ let _iqDepth = 0;
2799
+ const _IQ_MAX_DEPTH = 1000;
2800
+ return (fn) => {
2801
+ if (_iqDepth >= _IQ_MAX_DEPTH) {
2802
+ try { process.stderr.write(`mixdog: inboundQueue overflow (depth=${_iqDepth}), dropping message\n`); } catch {}
2803
+ return;
2804
+ }
2805
+ _iqDepth++;
2806
+ tail = Promise.resolve(tail).then(fn).catch((err) => {
2807
+ try { process.stderr.write(`mixdog: inboundQueue error: ${err && err.message || err}\n`); } catch {}
2808
+ }).finally(() => { _iqDepth--; });
2809
+ };
2810
+ })();
2811
+ // ── Reverse-lookup channelId → human label from channelsConfig ──────────────
2812
+ function labelForChannelId(channelId) {
2813
+ if (!channelId || !config.channelsConfig) return channelId;
2814
+ for (const [label, entry] of Object.entries(config.channelsConfig)) {
2815
+ if (entry?.channelId === channelId) return label;
2816
+ }
2817
+ return channelId;
2818
+ }
2819
+
2820
+ backend.onMessage = (msg) => {
2821
+ const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
2822
+ const onMessageAtMs = Date.now();
2823
+ if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
2824
+ refreshBridgeOwnershipSafe();
2825
+ return;
2826
+ }
2827
+ if (!channelBridgeActive) return;
2828
+ if (shouldDropDuplicateInbound(msg)) return;
2829
+ recordFetchedMessages(msg.chatId, labelForChannelId(msg.chatId), [{ id: msg.messageId }], { markRead: true });
2830
+ if (!writeChannelOwner(msg.chatId)) return;
2831
+ const route = resolveInboundRoute(msg.chatId, msg.parentChatId);
2832
+ scheduler.noteActivity();
2833
+ startServerTyping(route.targetChatId);
2834
+ backend.resetSendCount();
2835
+ // Pin the prior turn's bound channel before this fire-and-forget flush so the
2836
+ // imminent rebind below (which mutates forwarder.channelId synchronously)
2837
+ // cannot redirect the previous turn's final output to the new channel.
2838
+ const priorForwardChannelId = forwarder.channelId || null;
2839
+ void forwarder.forwardFinalText(0, priorForwardChannelId).catch((err) => {
2840
+ try { process.stderr.write(`mixdog: forwardFinalText rejection: ${err?.stack || err}\n`); } catch {}
2841
+ }).finally(() => forwarder.reset());
2842
+ const previousPath = getPersistedTranscriptPath();
2843
+ let boundTranscript = null;
2844
+ let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
2845
+ if (transcriptPath) {
2846
+ boundTranscript = {
2847
+ sessionId: sessionIdFromTranscriptPath(transcriptPath),
2848
+ sessionCwd: statusState.read().sessionCwd ?? null,
2849
+ transcriptPath,
2850
+ exists: true
2851
+ };
2852
+ } else {
2853
+ boundTranscript = discoverSessionBoundTranscript();
2854
+ transcriptPath = pickUsableTranscriptPath(boundTranscript, previousPath);
2855
+ // Only fall back to latest-by-mtime when discovery did NOT produce a
2856
+ // confident, existing current-session transcript. detectCurrentSessionTranscript()
2857
+ // already weighs mtime (with a 30s decisive threshold) against active-pid /
2858
+ // cwd affinity, so overriding a real detected binding with the raw newest
2859
+ // file would clobber the current session with an unrelated, more-recently
2860
+ // touched transcript (wrong-session forward).
2861
+ if (!boundTranscript?.exists) {
2862
+ const latestByMtime = findLatestTranscriptByMtime(boundTranscript?.sessionCwd);
2863
+ if (latestByMtime && latestByMtime !== transcriptPath) {
2864
+ transcriptPath = latestByMtime;
2865
+ }
2866
+ }
2867
+ }
2868
+ if (transcriptPath) {
2869
+ applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
2870
+ } else {
2871
+ refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId });
2872
+ }
2873
+ void (async () => {
2874
+ try {
2875
+ await backend.react(msg.chatId, msg.messageId, "\u{1F914}");
2876
+ } catch {
2877
+ }
2878
+ statusState.update((state) => {
2879
+ state.channelId = route.targetChatId;
2880
+ state.userMessageId = msg.messageId;
2881
+ state.emoji = "\u{1F914}";
2882
+ state.sentCount = 0;
2883
+ state.sessionIdle = false;
2884
+ if (transcriptPath) state.transcriptPath = transcriptPath;
2885
+ else delete state.transcriptPath;
2886
+ state.sessionCwd = boundTranscript?.sessionCwd ?? null;
2887
+ });
2888
+ if (!boundTranscript?.exists) {
2889
+ await rebindTranscriptContext(route.targetChatId, {
2890
+ previousPath: transcriptPath,
2891
+ catchUp: true,
2892
+ persistStatus: true
2893
+ });
2894
+ }
2895
+ })();
2896
+ const queuedAtMs = Date.now();
2897
+ const preQueueMs = queuedAtMs - onMessageAtMs;
2898
+ const gatewayToQueueMs = queuedAtMs - receivedAtMs;
2899
+ if (preQueueMs > 250 || gatewayToQueueMs > 500) {
2900
+ process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
2901
+ }
2902
+ inboundQueue(() => handleInbound(msg, route, {
2903
+ sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
2904
+ receivedAtMs,
2905
+ queuedAtMs
2906
+ }).catch((err) => {
2907
+ process.stderr.write(`mixdog: handleInbound error: ${err}
2908
+ `);
2909
+ }).finally(() => {
2910
+ stopServerTyping();
2911
+ }));
2912
+ };
2913
+ async function handleInbound(msg, route, options = {}) {
2914
+ const handleStartMs = Date.now();
2915
+ let text = msg.text;
2916
+ const voiceAtts = msg.attachments.filter((a) => isVoiceAttachment(a.contentType));
2917
+ if (voiceAtts.length > 0) {
2918
+ try {
2919
+ const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
2920
+ // concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
2921
+ for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
2922
+ const _t0 = Date.now();
2923
+ const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
2924
+ const _elapsed = Date.now() - _t0;
2925
+ if (transcript) {
2926
+ text = transcript;
2927
+ process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
2928
+ } else {
2929
+ process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
2930
+ text = text || "[voice message \u2014 transcription failed]";
2931
+ }
2932
+ }
2933
+ } catch (err) {
2934
+ process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
2935
+ text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
2936
+ }
2937
+ }
2938
+ const hasVoiceAtt = voiceAtts.length > 0;
2939
+ const attMeta = msg.attachments.length > 0 && !hasVoiceAtt ? {
2940
+ attachment_count: String(msg.attachments.length),
2941
+ attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
2942
+ } : {};
2943
+ const messageBody = route.sourceMode === "monitor" && route.sourceLabel ? `[monitor:${route.sourceLabel}] ${text}` : text;
2944
+ const now = (/* @__PURE__ */ new Date()).toLocaleString();
2945
+ const notificationMeta = {
2946
+ chat_id: route.targetChatId,
2947
+ message_id: msg.messageId,
2948
+ user: msg.user,
2949
+ user_id: msg.userId,
2950
+ ts: msg.ts,
2951
+ ...route.sourceMode === "monitor" ? {
2952
+ source_chat_id: route.sourceChatId,
2953
+ source_mode: route.sourceMode,
2954
+ ...route.sourceLabel ? { source_label: route.sourceLabel } : {}
2955
+ } : {},
2956
+ ...attMeta,
2957
+ ...msg.imagePath ? { image_path: msg.imagePath } : {}
2958
+ };
2959
+ const notificationContent = `[${now}]
2960
+ ${messageBody}`;
2961
+ sendNotifyToParent("notifications/claude/channel", {
2962
+ content: notificationContent,
2963
+ meta: notificationMeta
2964
+ });
2965
+ const notifiedAtMs = Date.now();
2966
+ const receivedAtMs = Number.isFinite(options.receivedAtMs) ? options.receivedAtMs : handleStartMs;
2967
+ const queuedAtMs = Number.isFinite(options.queuedAtMs) ? options.queuedAtMs : handleStartMs;
2968
+ const queueMs = handleStartMs - queuedAtMs;
2969
+ const handleMs = notifiedAtMs - handleStartMs;
2970
+ const totalMs = notifiedAtMs - receivedAtMs;
2971
+ if (queueMs > 250 || handleMs > 250 || totalMs > 500) {
2972
+ process.stderr.write(`mixdog: inbound latency delivered total=${totalMs}ms queue=${queueMs}ms handle=${handleMs}ms channel=${route.targetChatId} attachments=${msg.attachments.length}\n`);
2973
+ }
2974
+ void memoryAppendEntry({
2975
+ ts: msg.ts,
2976
+ role: "user",
2977
+ content: messageBody,
2978
+ sourceRef: `discord:${route.targetChatId}#${msg.messageId}`,
2979
+ sessionId: `discord:${route.targetChatId}`,
2980
+ cwd: statusState.read().sessionCwd,
2981
+ });
2982
+ }
2983
+ async function init(_sharedMcp) {
2984
+ // _sharedMcp is no longer used. Notifications now flow via IPC to the parent
2985
+ // (sendNotifyToParent above). The parameter is retained for backward
2986
+ // compatibility with any caller that still passes a Server reference.
2987
+ scheduler.setInjectHandler((channelId, name, content, options) => {
2988
+ injectAndRecord(channelId, name, content, options);
2989
+ });
2990
+ }
2991
+ async function start() {
2992
+ channelBridgeActive = true;
2993
+ writeBridgeState(true);
2994
+ if (_isCliOwnedMode) {
2995
+ await startCliOwnedRuntime({ restoreBinding: true });
2996
+ } else {
2997
+ await refreshBridgeOwnership({ restoreBinding: true });
2998
+ }
2999
+ // Pre-warm the whisper-server manager once at owner startup so the first
3000
+ // voice transcription does not pay cold-start cost. Non-blocking: failures
3001
+ // (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
3002
+ void (async () => {
3003
+ try {
3004
+ const runtime = resolveVoiceRuntime(DATA_DIR);
3005
+ if (!runtime?.installed) return;
3006
+ const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
3007
+ const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
3008
+ await ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
3009
+ } catch (err) {
3010
+ try { process.stderr.write(`mixdog: voice.transcription pre-warm skipped: ${err}\n`); } catch {}
3011
+ }
3012
+ })();
3013
+ }
3014
+ async function stop() {
3015
+ try { await stopVoiceWhisperServer(); } catch {}
3016
+ await stopOwnedRuntime("unified server stop");
3017
+ cleanupInstanceRuntimeFiles(INSTANCE_ID);
3018
+ if (bridgeOwnershipTimer) {
3019
+ clearInterval(bridgeOwnershipTimer);
3020
+ bridgeOwnershipTimer = null;
3021
+ }
3022
+ if (turnEndWatcher) {
3023
+ try { turnEndWatcher.close(); } catch {}
3024
+ turnEndWatcher = null;
3025
+ }
3026
+ }
3027
+ if (process.env.MIXDOG_CHANNELS_AUTO_BOOT !== '0') {
3028
+ let detectChannelFlag = function() {
3029
+ const isWin = process.platform === "win32";
3030
+ const flagRe = /--channels\b|--dangerously-load-development-channels\b/;
3031
+ if (process.env.MIXDOG_CHANNEL_FLAG === "1") return true;
3032
+ if (process.env.MIXDOG_CHANNEL_FLAG === "0") return false;
3033
+ if (isWin) {
3034
+ // Single CIM snapshot + in-process chain walk: one powershell.exe spawn
3035
+ // instead of up to 12 synchronous wmic/powershell spawns. Snapshots all
3036
+ // processes into a map, walks from process.ppid up to 6 ancestors
3037
+ // (closest first), and emits each ancestor CommandLine on its own line
3038
+ // for the same flagRe test below. Any failure returns false.
3039
+ try {
3040
+ const ps = [
3041
+ '$procs = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine;',
3042
+ '$map = @{};',
3043
+ 'foreach ($p in $procs) { $map[[int]$p.ProcessId] = $p }',
3044
+ `$cur = ${Number(process.ppid)};`,
3045
+ 'for ($i = 0; $i -lt 6; $i++) {',
3046
+ ' if (-not $cur -or $cur -le 1) { break }',
3047
+ ' $p = $map[[int]$cur]; if ($null -eq $p) { break }',
3048
+ ' [Console]::WriteLine($p.CommandLine);',
3049
+ ' $next = [int]$p.ParentProcessId;',
3050
+ ' if ($next -eq [int]$cur -or $next -le 1) { break }',
3051
+ ' $cur = $next',
3052
+ '}',
3053
+ ].join(" ");
3054
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-Command", ps], {
3055
+ encoding: "utf8",
3056
+ timeout: 5e3,
3057
+ windowsHide: true,
3058
+ });
3059
+ const out = String(r.stdout || "");
3060
+ for (const line of out.split(/\r?\n/)) {
3061
+ if (flagRe.test(line)) return true;
3062
+ }
3063
+ } catch {}
3064
+ return false;
3065
+ }
3066
+ let pid = process.ppid;
3067
+ for (let depth = 0; pid && pid > 1 && depth < 6; depth++) {
3068
+ try {
3069
+ const cmdLine = execSync(`ps -p ${pid} -o args=`, { encoding: "utf8", timeout: 3e3, windowsHide: true });
3070
+ if (flagRe.test(cmdLine)) return true;
3071
+ pid = parseInt(execSync(`ps -p ${pid} -o ppid=`, { encoding: "utf8", timeout: 3e3, windowsHide: true }).trim(), 10);
3072
+ } catch {
3073
+ break;
3074
+ }
3075
+ }
3076
+ return false;
3077
+ };
3078
+ _channelFlagDetected = detectChannelFlag();
3079
+ if (isMixdogDebug()) {
3080
+ fs.appendFileSync(_bootLog, `[${localTimestamp()}] channelFlag: ${_channelFlagDetected}\n`);
3081
+ if (_channelFlagDetected) {
3082
+ fs.appendFileSync(_bootLog, `[${localTimestamp()}] channel mode detected — bridge auto-activated\n`);
3083
+ }
3084
+ }
3085
+ if (_channelFlagDetected) {
3086
+ channelBridgeActive = true;
3087
+ }
3088
+ writeBridgeState(channelBridgeActive);
3089
+ const previousOwner = readActiveInstance();
3090
+ noteStartupHandoff(previousOwner);
3091
+ // Do not claim ownership just because this terminal is channel-capable.
3092
+ // refreshBridgeOwnership() below pings/proxies a live owner first and only
3093
+ // claims when there is no reachable active owner or the record is stale.
3094
+ const _bindingReadyStart = Date.now();
3095
+ void refreshBridgeOwnership({ restoreBinding: true }).then(
3096
+ (v) => {
3097
+ bindingReadyStatus = "resolved";
3098
+ dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
3099
+ _bindingReadyResolve(v);
3100
+ },
3101
+ (e) => {
3102
+ bindingReadyStatus = "rejected";
3103
+ dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
3104
+ _bindingReadyResolve(e);
3105
+ }
3106
+ );
3107
+ bridgeOwnershipTimer = setInterval(() => {
3108
+ refreshBridgeOwnershipSafe();
3109
+ }, 3e3);
3110
+ // Hook/statusline IPC is owned by the MCP parent process so it is available
3111
+ // before channels finishes bridge ownership and backend startup.
3112
+ const configPath = path.join(DATA_DIR, "mixdog-config.json");
3113
+ let reloadDebounce = null;
3114
+ let configWatcher = null;
3115
+ try {
3116
+ configWatcher = fs.watch(configPath, () => {
3117
+ if (reloadDebounce) clearTimeout(reloadDebounce);
3118
+ reloadDebounce = setTimeout(() => {
3119
+ reloadRuntimeConfig().catch(() => {});
3120
+ }, 500);
3121
+ });
3122
+ } catch {
3123
+ }
3124
+ process.on("exit", () => {
3125
+ if (configWatcher) { try { configWatcher.close(); } catch {} }
3126
+ if (bridgeOwnershipTimer) { clearInterval(bridgeOwnershipTimer); }
3127
+ });
3128
+ }
3129
+ // ── IPC worker mode ──────────────────────────────────────────────
3130
+ if (_isWorkerMode && process.send) {
3131
+ // SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
3132
+ // Cleans up in-progress webhook/scheduler state, removes runtime files, then exits.
3133
+ let _channelsStopInFlight = false
3134
+ let _channelsForceExitTimer = null
3135
+ const _channelsShutdownHandler = async (sig) => {
3136
+ if (_channelsStopInFlight) {
3137
+ process.stderr.write(`[channels-worker] ${sig} — shutdown already in flight, ignoring\n`)
3138
+ return
3139
+ }
3140
+ _channelsStopInFlight = true
3141
+ process.stderr.write(`[channels-worker] received ${sig} — shutting down cleanly\n`)
3142
+ _channelsForceExitTimer = setTimeout(() => {
3143
+ process.stderr.write(`[channels-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
3144
+ process.exit(2)
3145
+ }, 6000)
3146
+ try { await stopVoiceWhisperServer() } catch (e) {
3147
+ process.stderr.write(`[channels-worker] stopVoiceWhisperServer() error on ${sig}: ${e && (e.message || e)}\n`)
3148
+ }
3149
+ try { await stop() } catch (e) {
3150
+ process.stderr.write(`[channels-worker] stop() error on ${sig}: ${e && (e.message || e)}\n`)
3151
+ }
3152
+ if (_channelsForceExitTimer) clearTimeout(_channelsForceExitTimer)
3153
+ try { cleanupInstanceRuntimeFiles(INSTANCE_ID) } catch {}
3154
+ try { clearServerPid() } catch {}
3155
+ process.exit(0)
3156
+ }
3157
+ process.on('SIGTERM', () => _channelsShutdownHandler('SIGTERM'))
3158
+ process.on('SIGINT', () => _channelsShutdownHandler('SIGINT'))
3159
+
3160
+ // Map of callId → AbortController for in-flight IPC calls.
3161
+ const _inFlightChannelCalls = new Map()
3162
+
3163
+ process.on('message', async (msg) => {
3164
+ // Parent-initiated graceful shutdown — mirrors memory worker IPC pattern.
3165
+ if (msg && msg.type === 'shutdown') {
3166
+ process.stderr.write('[channels-worker] received IPC shutdown — calling stop()\n')
3167
+ _channelsShutdownHandler('IPC:shutdown')
3168
+ return
3169
+ }
3170
+ // Silent-to-agent lifecycle forward — parent (server.mjs) asks the
3171
+ // channels worker to post status pings to the active bridge Discord
3172
+ // channel without the Lead-notify hop. Best-effort: unknown channel or
3173
+ // backend failure is swallowed; lifecycle pings are non-critical.
3174
+ if (msg && msg.type === 'forward_to_discord') {
3175
+ try {
3176
+ const target = msg.channelId
3177
+ || (statusState?.read?.().channelId)
3178
+ || null;
3179
+ if (target && backend?.sendMessage && typeof msg.content === 'string' && msg.content) {
3180
+ await backend.sendMessage(target, msg.content).catch(() => {});
3181
+ }
3182
+ } catch { /* best-effort */ }
3183
+ return;
3184
+ }
3185
+ // Claude Code permission request → Discord Allow/Deny prompt.
3186
+ // Parent (server.mjs) receives notifications/claude/channel/permission_request
3187
+ // from Claude Code and forwards the params here. We post a buttoned message;
3188
+ // button clicks are handled in backend.onInteraction and sent back to CC as
3189
+ // notifications/claude/channel/permission via sendNotifyToParent.
3190
+ if (msg && msg.type === 'permission_request_inbound') {
3191
+ try {
3192
+ const { request_id, tool_name, description, input_preview } = msg.params || {};
3193
+ // tool_input arrives via the passthrough() schema in server.mjs when
3194
+ // Claude Code includes it in the permission_request notification.
3195
+ // Used to bind the pendingPermRequest to a specific file so two
3196
+ // concurrent Edit/Write requests cannot cross-approve via the
3197
+ // terminal signal.
3198
+ const toolInputParam = (msg.params && (msg.params.tool_input || msg.params.toolInput)) || {};
3199
+ const filePathParam = toolInputParam.file_path || '';
3200
+ if (!request_id || !tool_name) return;
3201
+ if (pendingPermRequests.size > 100) {
3202
+ const cutoff = Date.now() - 30 * 60 * 1000;
3203
+ for (const [k, v] of pendingPermRequests) {
3204
+ if (v.createdAt < cutoff) pendingPermRequests.delete(k);
3205
+ }
3206
+ refreshToolExecConsumerMarker();
3207
+ }
3208
+ const mainLabel = config?.mainChannel || 'main';
3209
+ const target = (statusState?.read?.().channelId)
3210
+ || resolveChannelLabel(config?.channelsConfig, mainLabel)
3211
+ || null;
3212
+ if (!target || !backend?.sendMessage) {
3213
+ process.stderr.write(`mixdog channels: permission_request dropped, no target channel (request_id=${request_id})\n`);
3214
+ return;
3215
+ }
3216
+ const lines = [`🔐 **Permission Request**`, `Tool: \`${tool_name}\``];
3217
+ if (description) lines.push(description);
3218
+ if (input_preview) lines.push('```\n' + String(input_preview).slice(0, 800) + '\n```');
3219
+ const content = lines.join('\n');
3220
+ const components = [{
3221
+ type: 1,
3222
+ components: [
3223
+ { type: 2, style: 3, label: 'Allow', custom_id: `perm-ch-${request_id}-allow` },
3224
+ { type: 2, style: 1, label: 'Session Allow', custom_id: `perm-ch-${request_id}-session` },
3225
+ { type: 2, style: 4, label: 'Deny', custom_id: `perm-ch-${request_id}-deny` },
3226
+ ],
3227
+ }];
3228
+ let sentIds = null;
3229
+ try {
3230
+ const sendResult = await backend.sendMessage(target, content, { components });
3231
+ sentIds = sendResult?.sentIds;
3232
+ } catch (err) {
3233
+ process.stderr.write(`mixdog channels: permission_request Discord send failed: ${err && err.message || err}\n`);
3234
+ return;
3235
+ }
3236
+ const messageId = Array.isArray(sentIds) && sentIds.length > 0 ? sentIds[0] : null;
3237
+ pendingPermRequests.set(request_id, {
3238
+ toolName: tool_name,
3239
+ filePath: filePathParam,
3240
+ createdAt: Date.now(),
3241
+ channelId: target,
3242
+ messageId,
3243
+ });
3244
+ refreshToolExecConsumerMarker();
3245
+ } catch (err) {
3246
+ try { process.stderr.write(`mixdog channels: permission_request handler error: ${err && err.message || err}\n`); } catch {}
3247
+ }
3248
+ return;
3249
+ }
3250
+ if (msg && msg.type === 'memory_call_response' && msg.callId) {
3251
+ // Response side of the worker → parent → memory bridge. Routed into
3252
+ // this existing listener (instead of a second process.on('message'))
3253
+ // to keep IPC dispatch in one place.
3254
+ const pending = _memoryCallPending.get(msg.callId);
3255
+ if (!pending) return;
3256
+ _memoryCallPending.delete(msg.callId);
3257
+ if (msg.ok) pending.resolve(msg.result);
3258
+ else pending.reject(new Error(msg.error || 'memory_call failed'));
3259
+ return;
3260
+ }
3261
+ if (msg.type === 'cancel' && msg.callId) {
3262
+ const entry = _inFlightChannelCalls.get(msg.callId)
3263
+ if (entry) {
3264
+ entry.abort()
3265
+ _inFlightChannelCalls.delete(msg.callId)
3266
+ }
3267
+ process.send({ type: 'result', callId: msg.callId, error: 'cancelled' })
3268
+ return
3269
+ }
3270
+ if (msg.type !== 'call' || !msg.callId) return
3271
+ try {
3272
+ const ac = new AbortController()
3273
+ _inFlightChannelCalls.set(msg.callId, ac)
3274
+ let result
3275
+ try {
3276
+ result = await handleToolCallWithBridgeRetry(msg.name, msg.args || {}, ac.signal)
3277
+ } finally {
3278
+ _inFlightChannelCalls.delete(msg.callId)
3279
+ }
3280
+ process.send({ type: 'result', callId: msg.callId, result })
3281
+ } catch (e) {
3282
+ process.send({ type: 'result', callId: msg.callId, error: e.message })
3283
+ }
3284
+ })
3285
+ void (async () => {
3286
+ const startedAt = performance.now()
3287
+ try {
3288
+ await start()
3289
+ bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1) })
3290
+ process.send({ type: 'ready', channelFlag: _channelFlagDetected })
3291
+ } catch (e) {
3292
+ bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e?.message || String(e) })
3293
+ process.stderr.write(`[channels-worker] start() failed: ${e && (e.message || e)}\n`)
3294
+ process.send({ type: 'ready', channelFlag: _channelFlagDetected, degraded: true, error: e?.message || String(e) })
3295
+ }
3296
+ })()
3297
+ }
3298
+
3299
+ export {
3300
+ TOOL_DEFS,
3301
+ handleToolCall,
3302
+ handleToolCallWithBridgeRetry,
3303
+ init,
3304
+ INSTRUCTIONS as instructions,
3305
+ isChannelBridgeActive,
3306
+ isChannelsDegraded,
3307
+ start,
3308
+ stop
3309
+ };