mixdog 0.7.1

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