mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -1,17 +1,8 @@
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";
1
+ import { spawn } from "child_process";
9
2
  import * as fs from "fs";
10
- import * as http from "http";
11
3
  import * as os from "os";
12
4
  import * as path from "path";
13
5
  import { performance } from "perf_hooks";
14
- import { pathToFileURL } from "url";
15
6
  import { createRequire } from "module";
16
7
  const _require = createRequire(import.meta.url);
17
8
  import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
@@ -33,7 +24,8 @@ import { startCliWorker } from "./lib/cli-worker-host.mjs";
33
24
  import {
34
25
  OutputForwarder,
35
26
  discoverSessionBoundTranscript,
36
- findLatestTranscriptByMtime
27
+ findLatestTranscriptByMtime,
28
+ sameResolvedPath
37
29
  } from "./lib/output-forwarder.mjs";
38
30
  import { controlClaudeSession } from "./lib/session-control.mjs";
39
31
  import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./lib/state-file.mjs";
@@ -62,116 +54,58 @@ import {
62
54
  RUNTIME_ROOT
63
55
  } from "./lib/runtime-paths.mjs";
64
56
  import { getDiscordToken } from "./lib/config.mjs";
57
+ import { bootProfile, localTimestamp } from "./lib/boot-profile.mjs";
58
+ import {
59
+ isChannelsDegraded,
60
+ logCrash,
61
+ _isBenignCrash,
62
+ BENIGN_CRASH_FATAL_THRESHOLD,
63
+ BENIGN_CRASH_STREAK_WINDOW_MS,
64
+ } from "./lib/crash-log.mjs";
65
+ import { dropTrace, preview, _dtIdxFlush } from "./lib/index-drop-trace.mjs";
66
+ import { normalizeWhisperLanguage, detectDeviceLanguage } from "./lib/whisper-language.mjs";
65
67
  const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.url).href;
66
68
  const {
67
69
  appendEntry: memoryAppendEntry,
68
70
  ingestTranscript: memoryIngestTranscript,
69
71
  } = await import(memoryClientModulePath);
70
- const DEFAULT_PLUGIN_VERSION = "0.0.1";
71
- const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ""));
72
- const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
73
- function bootProfile(event, fields = {}) {
74
- if (!BOOT_PROFILE_ENABLED) return;
75
- const elapsedMs = performance.now() - BOOT_PROFILE_START;
76
- const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `channels:${event}`];
77
- for (const [key, value] of Object.entries(fields || {})) {
78
- if (value === undefined || value === null || value === "") continue;
79
- parts.push(`${key}=${String(value).replace(/\s+/g, "_")}`);
80
- }
81
- try { process.stderr.write(`${parts.join(" ")}\n`); } catch {}
82
- }
83
- function localTimestamp() {
84
- return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
85
- }
86
- function readPluginVersion() {
87
- try {
88
- const pkg = JSON.parse(fs.readFileSync(new URL("../../../package.json", import.meta.url), "utf8"));
89
- return pkg.version || DEFAULT_PLUGIN_VERSION;
90
- } catch {
91
- return DEFAULT_PLUGIN_VERSION;
92
- }
93
- }
94
- const PLUGIN_VERSION = readPluginVersion();
95
- let crashLogging = false;
96
- let _channelsDegraded = false;
97
- let _stderrBroken = false;
98
- function isChannelsDegraded() { return _channelsDegraded; }
99
-
100
- // stderr can break when the parent stdio pipe closes. Node then emits an
101
- // async 'error' on process.stderr, which sync try/catch around write() does
102
- // not catch — without a listener, that error becomes uncaughtException and
103
- // re-enters logCrash, looping until the disk fills. Register a suppressor
104
- // once at load time and stop writing to stderr after the first EPIPE so the
105
- // loop cannot start.
106
- try {
107
- process.stderr.on('error', (e) => {
108
- if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
109
- _stderrBroken = true;
110
- _channelsDegraded = true;
111
- }
112
- });
113
- } catch {}
114
-
115
- // Crash log guards: dedup repeated identical errors (a single broken handler
116
- // can fire thousands of times per minute) and rotate at a 10 MB cap so the
117
- // file cannot grow unbounded. One .old generation is kept; older rolls drop.
118
- const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
119
- let _lastCrashSig = "";
120
- let _crashRepeatCount = 0;
121
-
122
- function _writeCrashLine(crashLog, line) {
123
- try {
124
- let size = 0;
125
- try { size = fs.statSync(crashLog).size; } catch {}
126
- if (size + line.length > CRASH_LOG_MAX_BYTES) {
127
- try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
128
- }
129
- fs.appendFileSync(crashLog, line);
130
- } catch {}
131
- }
132
-
133
- function logCrash(label, err) {
134
- if (crashLogging) return;
135
- crashLogging = true;
136
- const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
137
- ${err instanceof Error ? err.stack : ""}
138
- `;
139
- if (!_stderrBroken) {
140
- try { process.stderr.write(msg); } catch (e) {
141
- if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
142
- _stderrBroken = true;
143
- }
144
- }
145
- }
146
- const sig = `${label}|${err && err.message ? err.message : String(err)}`;
147
- const crashLog = path.join(DATA_DIR, "crash.log");
148
- if (sig === _lastCrashSig) {
149
- // Same error repeating — count it but skip the disk write. The next
150
- // distinct error (or EPIPE branch below) flushes the suppressed total.
151
- _crashRepeatCount += 1;
72
+ // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
73
+ // after an unhandled rejection whose async state was already corrupted
74
+ // (observed: EPERM on active-instance.json rename retry), so it spun
75
+ // forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
76
+ let _benignCrashStreak = 0;
77
+ let _lastBenignCrashAt = 0;
78
+ function _fatalCrash(label, err) {
79
+ logCrash(label, err);
80
+ const benign = _isBenignCrash(err);
81
+ if (benign) {
82
+ const now = Date.now();
83
+ _benignCrashStreak = (now - _lastBenignCrashAt) <= BENIGN_CRASH_STREAK_WINDOW_MS
84
+ ? _benignCrashStreak + 1
85
+ : 1;
86
+ _lastBenignCrashAt = now;
87
+ if (_benignCrashStreak < BENIGN_CRASH_FATAL_THRESHOLD) return;
152
88
  } else {
153
- if (_crashRepeatCount > 0) {
154
- _writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
155
- _crashRepeatCount = 0;
156
- }
157
- _lastCrashSig = sig;
158
- _writeCrashLine(crashLog, msg);
89
+ _benignCrashStreak = 0;
159
90
  }
160
- if (err instanceof Error && err.message.includes("EPIPE")) {
161
- _channelsDegraded = true;
162
- _stderrBroken = true;
163
- }
164
- crashLogging = false;
91
+ Promise.resolve()
92
+ .then(() => (typeof stop === "function" ? stop(`fatal:${label}`) : null))
93
+ .catch(() => {})
94
+ .finally(() => {
95
+ try { process.exitCode = 1; } catch {}
96
+ process.exit(1);
97
+ });
98
+ // Best-effort stop() may itself hang (e.g. IPC to a dead child) — a bare
99
+ // .finally() would then never fire and we're back to a zombie. Force the
100
+ // exit unconditionally after a short grace window regardless of outcome.
101
+ setTimeout(() => { try { process.exit(1); } catch {} }, 3000).unref?.();
165
102
  }
166
- process.on("unhandledRejection", (err) => logCrash("unhandled rejection", err));
167
- process.on("uncaughtException", (err) => logCrash("uncaught exception", err));
103
+ process.on("unhandledRejection", (err) => _fatalCrash("unhandled rejection", err));
104
+ process.on("uncaughtException", (err) => _fatalCrash("uncaught exception", err));
168
105
  if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
169
106
  process.exit(0);
170
107
  }
171
108
  const _isWorkerMode = process.env.MIXDOG_WORKER_MODE === '1'
172
- const _isCliOwnedMode = process.env.MIXDOG_CLI_OWNED === '1'
173
- const _isChannelDaemonMode = process.env.MIXDOG_CHANNEL_DAEMON === '1'
174
- const CHANNEL_DAEMON_IDLE_TTL_MS = Math.max(0, Number(process.env.MIXDOG_CHANNEL_IDLE_TTL_MS) || 60_000)
175
109
  const _bootLogEarly = path.join(
176
110
  DATA_DIR || path.join(os.tmpdir(), "mixdog"),
177
111
  "boot.log"
@@ -193,17 +127,6 @@ let config = loadConfig();
193
127
  let backend = createBackend(config);
194
128
  const INSTANCE_ID = makeInstanceId();
195
129
  const TERMINAL_LEAD_PID = getTerminalLeadPid();
196
- // ── drop-trace instrumentation ──────────────────────────────────────────────
197
- const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
198
- const DROP_TRACE_ENABLED =
199
- process.env.MIXDOG_DROP_TRACE === "1" ||
200
- process.env.MIXDOG_DROP_TRACE === "true" ||
201
- process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
202
- process.env.MIXDOG_DEBUG_CHANNELS === "true";
203
- // One-shot rotation for drop-trace.log at worker boot.
204
- if (DROP_TRACE_ENABLED) {
205
- try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
206
- }
207
130
  // Rotate additional worker logs (10 MB threshold).
208
131
  for (const _rotLog of ["channels-worker.log", "schedule.log", "event.log", "memory-worker.log", "mcp-debug.log", "webhook.log", "pg.log", "session-start.log"]) {
209
132
  const _rotPath = path.join(DATA_DIR, _rotLog);
@@ -244,42 +167,6 @@ try {
244
167
  try {
245
168
  pruneStalePluginDataLogSiblings(DATA_DIR, DEFAULT_STALE_LOG_SIBLING_MAX);
246
169
  } catch {}
247
-
248
- // ── Buffered drop-trace writer (channels/index) ──────────────────────────────
249
- // Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
250
- // Drains on process exit so no log lines are lost.
251
- let _dtIdxBuf = "";
252
- let _dtIdxBytes = 0;
253
- let _dtIdxFlushTimer = null;
254
- let _dtIdxStream = null;
255
- function _dtIdxGetStream() {
256
- if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
257
- return _dtIdxStream;
258
- }
259
- async function _dtIdxFlush() {
260
- if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
261
- if (!_dtIdxBuf) return;
262
- const stream = _dtIdxGetStream();
263
- const buf = _dtIdxBuf;
264
- _dtIdxBuf = "";
265
- _dtIdxBytes = 0;
266
- try {
267
- const ok = stream.write(buf);
268
- if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
269
- } catch {}
270
- }
271
- function _dtIdxScheduleFlush() {
272
- if (_dtIdxFlushTimer) return;
273
- _dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
274
- if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
275
- }
276
- function _dtIdxAppend(line) {
277
- _dtIdxBuf += line;
278
- _dtIdxBytes += Buffer.byteLength(line);
279
- if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
280
- _dtIdxScheduleFlush();
281
- }
282
- process.on("exit", () => { void _dtIdxFlush(); });
283
170
  // SIGTERM: flush the drop-trace buffer, but do NOT exit here. In worker
284
171
  // mode the graceful `_channelsShutdownHandler` below owns shutdown
285
172
  // (stop() → cleanup → process.exit). In non-worker mode no SIGTERM
@@ -289,21 +176,6 @@ process.on("SIGTERM", () => {
289
176
  void _dtIdxFlush();
290
177
  if (!_isWorkerMode) process.exit(0);
291
178
  });
292
-
293
- function preview(text) {
294
- if (!text) return "";
295
- const s = String(text).replace(/\n/g, "\\n");
296
- return s.length > 120 ? s.slice(0, 120) + "…" : s;
297
- }
298
- function dropTrace(event, fields) {
299
- if (!DROP_TRACE_ENABLED) return;
300
- try {
301
- const ts = (/* @__PURE__ */ new Date()).toISOString();
302
- const loc = `[${ts}][pid=${process.pid}] ${event}`;
303
- const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
304
- _dtIdxAppend(loc + kv + "\n");
305
- } catch {}
306
- }
307
179
  // ────────────────────────────────────────────────────────────────────────────
308
180
  ensureRuntimeDirs();
309
181
  cleanupStaleRuntimeFiles();
@@ -329,10 +201,6 @@ const INSTRUCTIONS = "";
329
201
  // never `connect()`ed to any transport, so `.notification()` silently
330
202
  // threw 'Not connected' inside the SDK and every call was dropped by an
331
203
  // outer `.catch(() => {})`. That regression is what this path replaces.
332
- let _channelNotifyBusSeq = 0;
333
- const CHANNEL_NOTIFY_BUS_FILE = path.join(RUNTIME_ROOT, 'channel-notifications.jsonl');
334
- const CHANNEL_NOTIFY_BUS_MAX_BYTES = 5 * 1024 * 1024;
335
-
336
204
  function normalizeChannelNotifyParams(method, params) {
337
205
  if (method === 'notifications/claude/channel' && params && params.meta) {
338
206
  const m = {};
@@ -345,29 +213,6 @@ function normalizeChannelNotifyParams(method, params) {
345
213
  return params;
346
214
  }
347
215
 
348
- function publishChannelNotify(method, params) {
349
- try {
350
- fs.mkdirSync(RUNTIME_ROOT, { recursive: true });
351
- try {
352
- const st = fs.statSync(CHANNEL_NOTIFY_BUS_FILE);
353
- if (st.size > CHANNEL_NOTIFY_BUS_MAX_BYTES) {
354
- try { fs.unlinkSync(CHANNEL_NOTIFY_BUS_FILE + '.1'); } catch {}
355
- try { fs.renameSync(CHANNEL_NOTIFY_BUS_FILE, CHANNEL_NOTIFY_BUS_FILE + '.1'); } catch {}
356
- }
357
- } catch {}
358
- const item = {
359
- id: `${Date.now()}-${process.pid}-${++_channelNotifyBusSeq}`,
360
- ts: Date.now(),
361
- pid: process.pid,
362
- method,
363
- params,
364
- };
365
- fs.appendFileSync(CHANNEL_NOTIFY_BUS_FILE, JSON.stringify(item) + '\n');
366
- } catch (err) {
367
- try { process.stderr.write(`mixdog channels: notify bus write failed: ${err && err.message || err}\n`); } catch {}
368
- }
369
- }
370
-
371
216
  function sendNotifyToParent(method, params) {
372
217
  // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
373
218
  // Coerce every meta value to string so a non-string (e.g. a Discord
@@ -375,9 +220,8 @@ function sendNotifyToParent(method, params) {
375
220
  // silent_to_agent stays boolean — an internal routing flag the daemon
376
221
  // router / agentNotify consume (=== true) before the CC zod boundary.
377
222
  const outParams = normalizeChannelNotifyParams(method, params);
378
- publishChannelNotify(method, outParams);
379
223
  if (!process.send) {
380
- try { process.stderr.write(`mixdog channels: notify queued on bus (no IPC): ${method}\n`); } catch {}
224
+ try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
381
225
  return;
382
226
  }
383
227
  try {
@@ -454,6 +298,9 @@ const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
454
298
  const TURN_END_BASENAME = path.basename(TURN_END_FILE);
455
299
  const TURN_END_DIR = path.dirname(TURN_END_FILE);
456
300
  let turnEndWatcher = null;
301
+ // Config hot-reload watcher (installed by start(); torn down by stop()).
302
+ let _configWatcher = null;
303
+ let _reloadDebounce = null;
457
304
  if (!_isWorkerMode) {
458
305
  removeFileIfExists(TURN_END_FILE);
459
306
  turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
@@ -488,12 +335,13 @@ function pickUsableTranscriptPath(bound, previousPath) {
488
335
  return sessionIdFromTranscriptPath(previousPath) === bound.sessionId ? previousPath : "";
489
336
  }
490
337
  const forwarder = new OutputForwarder({
491
- send: async (ch, text) => {
338
+ send: async (ch, text, opts) => {
492
339
  if (!channelBridgeActive) {
493
340
  throw new Error("send() called while channel bridge is inactive");
494
341
  }
495
- await backend.sendMessage(ch, text);
342
+ await backend.sendMessage(ch, text, opts);
496
343
  },
344
+ formatOutgoing: (text) => backend.formatOutgoing ? backend.formatOutgoing(text) : text,
497
345
  recordAssistantTurn: async () => {
498
346
  },
499
347
  react: (ch, mid, emoji) => {
@@ -513,9 +361,9 @@ forwarder.setOnIdle(() => {
513
361
  // also sets this, but that path only runs when the event pipeline starts
514
362
  // (webhook enabled or event rules present). Without an event pipeline the
515
363
  // forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
516
- // non-owner / proxy process forward transcript output (duplicate Discord
517
- // sends). The closure reads bridgeRuntimeConnected/proxyMode at call time.
518
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
364
+ // non-owner process forward transcript output (duplicate Discord sends).
365
+ // The closure reads bridgeRuntimeConnected at call time.
366
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
519
367
  function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
520
368
  if (!transcriptPath) return;
521
369
  forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
@@ -536,9 +384,68 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
536
384
  });
537
385
  }
538
386
  }
387
+ // ── Pending-transcript re-arm ────────────────────────────────────────
388
+ // fs.watch cannot watch a file that does not exist yet, so when we bind a
389
+ // session's known-but-not-yet-written transcript path, OutputForwarder.
390
+ // startWatch() silently fails (watch.start.catch) and the file's later
391
+ // creation is never observed. This bounded poll bridges that gap: once the
392
+ // transcript-writer creates the file, we install the watch and forward the
393
+ // backlog. It self-cancels on success, on timeout, and whenever a fresh
394
+ // (re)bind supersedes it — so no timers leak and no double-forward occurs.
395
+ let _pendingRearmTimer = null;
396
+ const PENDING_REARM_INTERVAL_MS = 250;
397
+ const PENDING_REARM_MAX_MS = 60_000;
398
+ function cancelPendingTranscriptRearm() {
399
+ if (_pendingRearmTimer) {
400
+ clearTimeout(_pendingRearmTimer);
401
+ _pendingRearmTimer = null;
402
+ dropTrace("rebind.rearm.cancel");
403
+ }
404
+ }
405
+ function schedulePendingTranscriptRearm(channelId, boundPath) {
406
+ cancelPendingTranscriptRearm();
407
+ if (!boundPath) return;
408
+ const deadline = Date.now() + PENDING_REARM_MAX_MS;
409
+ dropTrace("rebind.rearm.schedule", { channelId, path: boundPath });
410
+ const tick = () => {
411
+ _pendingRearmTimer = null;
412
+ // A different transcript got bound in the meantime — abandon this poll.
413
+ if (forwarder.transcriptPath !== boundPath) {
414
+ dropTrace("rebind.rearm.superseded", { path: boundPath, now: forwarder.transcriptPath || "(none)" });
415
+ return;
416
+ }
417
+ // Ownership may have been lost (bridge deactivated / superseded owner)
418
+ // while this poll was pending. Do not reinstall the fs.watch handle after
419
+ // teardown; startWatch() is not owner-gated so guard it here.
420
+ if (!bridgeRuntimeConnected) {
421
+ dropTrace("rebind.rearm.not-owner", { path: boundPath });
422
+ return;
423
+ }
424
+ if (fs.existsSync(boundPath)) {
425
+ dropTrace("rebind.rearm.fire", { channelId, path: boundPath });
426
+ forwarder.startWatch();
427
+ void forwarder.forwardNewText().catch((err) => {
428
+ try { process.stderr.write(`mixdog: rearm forwardNewText rejection: ${err?.stack || err}\n`); } catch {}
429
+ });
430
+ return;
431
+ }
432
+ if (Date.now() >= deadline) {
433
+ dropTrace("rebind.rearm.timeout", { channelId, path: boundPath });
434
+ return;
435
+ }
436
+ _pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
437
+ _pendingRearmTimer?.unref?.();
438
+ };
439
+ _pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
440
+ _pendingRearmTimer?.unref?.();
441
+ }
539
442
  async function rebindTranscriptContext(channelId, options = {}) {
540
443
  const previousPath = options.previousPath ?? "";
541
444
  const mode = options.mode ?? "same";
445
+ // A new (re)bind supersedes any pending re-arm poll left over from a prior
446
+ // bind of a not-yet-existing transcript, so we never leak timers or
447
+ // double-forward once the fresh bind takes over.
448
+ cancelPendingTranscriptRearm();
542
449
  const explicitTranscriptPath = typeof options.transcriptPath === "string" ? options.transcriptPath.trim() : "";
543
450
  if (explicitTranscriptPath) {
544
451
  let explicitExists = false;
@@ -561,7 +468,17 @@ async function rebindTranscriptContext(channelId, options = {}) {
561
468
  }
562
469
  let sawPendingTranscript = false;
563
470
  let pendingSessionId = "";
564
- for (let attempt = 0; attempt < 30; attempt += 1) {
471
+ // Distinct from sawPendingTranscript/pendingSessionId (which only track the
472
+ // sessionId): remember the FULL not-yet-on-disk candidate — its concrete
473
+ // transcriptPath (from the session record) + cwd — so we can bind it after
474
+ // the loop even though the `.jsonl` does not exist yet. This breaks the
475
+ // chicken-and-egg deadlock where the first assistant reply is only written
476
+ // seconds after inbound time.
477
+ let pendingTranscriptPath = "";
478
+ let pendingTranscriptCwd = null;
479
+ const maxAttempts = Number.isFinite(options.maxAttempts) ? Math.max(1, Math.floor(options.maxAttempts)) : 30;
480
+ const pollMs = Number.isFinite(options.pollMs) ? Math.max(25, Math.floor(options.pollMs)) : 150;
481
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
565
482
  const bound = discoverSessionBoundTranscript();
566
483
  if (bound?.exists) {
567
484
  const acceptable = mode === "same" || !previousPath || bound.transcriptPath !== previousPath;
@@ -583,12 +500,63 @@ async function rebindTranscriptContext(channelId, options = {}) {
583
500
  } else if (bound?.sessionId) {
584
501
  sawPendingTranscript = true;
585
502
  pendingSessionId = bound.sessionId;
503
+ if (bound.transcriptPath) {
504
+ pendingTranscriptPath = bound.transcriptPath;
505
+ pendingTranscriptCwd = bound.sessionCwd ?? null;
506
+ }
507
+ }
508
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
509
+ }
510
+ // No existing transcript surfaced during the loop, but the session record
511
+ // named a concrete transcript path that simply is not on disk yet. Bind it
512
+ // now: applyTranscriptBinding persists state.transcriptPath (so the
513
+ // getPersistedTranscriptPath() fallback works for future rebinds) and calls
514
+ // forwarder.startWatch(). Because fs.watch cannot watch a missing file, we
515
+ // also schedule a bounded re-arm poll that installs the watch + catches up
516
+ // once the transcript-writer creates the file.
517
+ if (pendingTranscriptPath) {
518
+ // Same wrong-session guard as the exists:true path: refuse to bind a
519
+ // candidate that conflicts with an explicit previousPath when this is a
520
+ // switch (mode!=="same").
521
+ const acceptable = mode === "same" || !previousPath || pendingTranscriptPath !== previousPath;
522
+ if (acceptable) {
523
+ dropTrace("rebind.pending.bind", { channelId, path: pendingTranscriptPath, sessionId: pendingSessionId });
524
+ // If the persisted cursor belongs to THIS transcript, resume from it;
525
+ // otherwise this is a freshly-discovered session transcript that was
526
+ // never bound before (the chicken-and-egg case), so forward from the
527
+ // start of file. Binding with catchUpFromPersisted against a
528
+ // non-matching persisted path would set the read cursor to EOF and
529
+ // silently skip the first reply (output-forwarder setContext).
530
+ // Resume from the persisted cursor only when it belongs to THIS
531
+ // transcript; otherwise forward from the start of file (see comment
532
+ // above). sameResolvedPath handles Windows case-insensitive paths.
533
+ const samePersisted = sameResolvedPath(getPersistedTranscriptPath(), pendingTranscriptPath);
534
+ applyTranscriptBinding(channelId, pendingTranscriptPath, {
535
+ replayFromStart: !samePersisted,
536
+ catchUpFromPersisted: samePersisted,
537
+ cwd: pendingTranscriptCwd,
538
+ persistStatus: options.persistStatus,
539
+ });
540
+ const boundPath = forwarder.transcriptPath || pendingTranscriptPath;
541
+ if (fs.existsSync(boundPath)) {
542
+ // Raced: file appeared between discovery and bind — forward immediately.
543
+ await forwarder.forwardNewText();
544
+ } else {
545
+ schedulePendingTranscriptRearm(channelId, boundPath);
546
+ }
547
+ process.stderr.write(`mixdog: rebind pending: bound not-yet-existing transcript ${boundPath}\n`);
548
+ return pendingTranscriptPath;
586
549
  }
587
- await new Promise((resolve) => setTimeout(resolve, 150));
588
550
  }
589
- if (previousPath) {
551
+ if (previousPath && options.fallbackPrevious !== false) {
590
552
  applyTranscriptBinding(channelId, previousPath, { catchUpFromPersisted: true, cwd: statusState.read().sessionCwd });
591
- await forwarder.forwardNewText();
553
+ if (fs.existsSync(previousPath)) {
554
+ await forwarder.forwardNewText();
555
+ } else {
556
+ // Same not-yet-on-disk situation as the pending branch: arm a poll so
557
+ // forwarding starts when the file is created.
558
+ schedulePendingTranscriptRearm(channelId, forwarder.transcriptPath || previousPath);
559
+ }
592
560
  process.stderr.write(`mixdog: rebind fallback: bound previous transcript ${previousPath}\n`);
593
561
  return previousPath;
594
562
  }
@@ -636,688 +604,15 @@ const ACTIVE_OWNER_STALE_MS = 1e4;
636
604
  // never blocks process exit. Single JSON atomic write, no measurable load.
637
605
  const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
638
606
  let ownerHeartbeatTimer = null;
639
- let channelDaemonIdleTimer = null;
640
- let channelDaemonLastClientAt = Date.now();
641
- let channelDaemonBackgroundLogAt = 0;
642
- const CHANNEL_CLIENT_DIR = path.join(RUNTIME_ROOT, 'channel-clients');
643
-
644
- function pidAlive(pid) {
645
- const n = Number(pid);
646
- if (!Number.isInteger(n) || n <= 0) return false;
647
- try {
648
- process.kill(n, 0);
649
- return true;
650
- } catch (e) {
651
- return e?.code === 'EPERM';
652
- }
653
- }
654
-
655
- function countLiveChannelClients() {
656
- let live = 0;
657
- const now = Date.now();
658
- try {
659
- fs.mkdirSync(CHANNEL_CLIENT_DIR, { recursive: true });
660
- for (const file of fs.readdirSync(CHANNEL_CLIENT_DIR)) {
661
- if (!file.endsWith('.json')) continue;
662
- const full = path.join(CHANNEL_CLIENT_DIR, file);
663
- let item = null;
664
- let st = null;
665
- try {
666
- st = fs.statSync(full);
667
- item = JSON.parse(fs.readFileSync(full, 'utf8'));
668
- } catch {
669
- try { fs.unlinkSync(full); } catch {}
670
- continue;
671
- }
672
- const pid = Number(item?.pid ?? file.replace(/\.json$/, ''));
673
- const fresh = now - Math.max(Number(item?.updatedAt) || 0, st.mtimeMs) < 20_000;
674
- if (pidAlive(pid) && fresh) {
675
- live += 1;
676
- } else {
677
- try { fs.unlinkSync(full); } catch {}
678
- }
679
- }
680
- } catch {}
681
- return live;
682
- }
683
-
684
- function hasChannelBackgroundWork() {
685
- if (!channelBridgeActive || !bridgeRuntimeConnected) return false;
686
- if (config.webhook?.enabled === true) return true;
687
- if (Array.isArray(config.events?.rules) && config.events.rules.length > 0) return true;
688
- if (Array.isArray(config.nonInteractive) && config.nonInteractive.length > 0) return true;
689
- if (Array.isArray(config.interactive) && config.interactive.length > 0) return true;
690
- return false;
691
- }
692
-
693
- function checkChannelDaemonIdle() {
694
- if (!_isChannelDaemonMode || CHANNEL_DAEMON_IDLE_TTL_MS <= 0) return;
695
- const liveClients = countLiveChannelClients();
696
- if (liveClients > 0) {
697
- channelDaemonLastClientAt = Date.now();
698
- return;
699
- }
700
- if (hasChannelBackgroundWork()) {
701
- const now = Date.now();
702
- if (now - channelDaemonBackgroundLogAt > 60_000) {
703
- channelDaemonBackgroundLogAt = now;
704
- try { process.stderr.write('[channels-worker] daemon idle: keeping alive for configured background work\n'); } catch {}
705
- }
706
- channelDaemonLastClientAt = Date.now();
707
- return;
708
- }
709
- if (Date.now() - channelDaemonLastClientAt < CHANNEL_DAEMON_IDLE_TTL_MS) return;
710
- try { process.stderr.write(`[channels-worker] daemon idle TTL elapsed (${CHANNEL_DAEMON_IDLE_TTL_MS}ms) — shutting down\n`); } catch {}
711
- stop()
712
- .then(() => process.exit(0))
713
- .catch((e) => {
714
- try { process.stderr.write(`[channels-worker] daemon idle shutdown failed: ${e?.message || e}\n`); } catch {}
715
- process.exit(1);
716
- });
717
- }
718
-
719
- function startChannelDaemonIdleMonitor() {
720
- if (!_isChannelDaemonMode || CHANNEL_DAEMON_IDLE_TTL_MS <= 0 || channelDaemonIdleTimer) return;
721
- channelDaemonLastClientAt = Date.now();
722
- channelDaemonIdleTimer = setInterval(checkChannelDaemonIdle, 5000);
723
- channelDaemonIdleTimer.unref?.();
724
- }
725
-
726
- function stopChannelDaemonIdleMonitor() {
727
- if (!channelDaemonIdleTimer) return;
728
- clearInterval(channelDaemonIdleTimer);
729
- channelDaemonIdleTimer = null;
730
- }
731
607
  // Owner gating here is multi-process runtime coordination: only the active
732
608
  // bindingReady gates all send paths until the boot-time refreshBridgeOwnership
733
609
  // ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
734
610
  // emissions fired within the first ~few hundred ms after restart drop because
735
611
  // the Discord backend binding has not yet been established.
736
612
  let bindingReadyStatus = "pending";
737
- // Channel-flag detection result, stored at module scope so the worker-mode
738
- // ready IPC can forward it to the daemon for caching across respawns.
739
- let _channelFlagDetected = false;
740
613
  let _bindingReadyResolve;
741
614
  const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
742
615
  dropTrace("bindingReady.create", { status: bindingReadyStatus });
743
- // owner runs webhook/event ticks. It is not webhook HTTP authentication.
744
- let proxyMode = false;
745
- let ownerHttpPort = 0;
746
- let ownerHttpServer = null;
747
- const PROXY_PORT_MIN = 3460;
748
- const PROXY_PORT_MAX = 3467;
749
- // Per-owner-process auth secret. Generated once at HTTP server start and
750
- // published into runtime/owner-secret-<instanceId>.json with 0o600 perms so
751
- // only the owner UID can read it back. requireOwnerToken below checks THIS
752
- // secret (not the public-by-/ping instanceId) so any local caller that
753
- // scrapes /ping cannot forge owner-side calls. The file is keyed on the
754
- // owner's INSTANCE_ID — the SAME identifier published into active-instance
755
- // as `instanceId` and validated by requireOwnerToken's x-owner-instance
756
- // header check — so proxy readers can resolve the path off readActiveInstance()
757
- // without depending on getActiveOwnerPid(), which prefers ownerLeadPid/
758
- // terminalLeadPid/supervisor_pid and would diverge from process.pid in
759
- // supervisor-backed sessions.
760
- let OWNER_SECRET = "";
761
- function getOwnerSecretPath(instanceId) {
762
- return path.join(RUNTIME_ROOT, `owner-secret-${String(instanceId)}.json`);
763
- }
764
- function publishOwnerSecret(secret) {
765
- const file = getOwnerSecretPath(INSTANCE_ID);
766
- try { ensureDir(RUNTIME_ROOT); } catch {}
767
- // Best-effort restrictive write: O_CREAT|O_TRUNC|O_WRONLY with mode 0o600.
768
- // On Windows mode bits are largely ignored, but the file still lives in
769
- // the per-user tmp dir; an attacker without the same UID cannot read it.
770
- try { fs.unlinkSync(file); } catch {}
771
- const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY, 0o600);
772
- try {
773
- fs.writeSync(fd, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, secret, updatedAt: Date.now() }));
774
- } finally {
775
- try { fs.closeSync(fd); } catch {}
776
- }
777
- try { fs.chmodSync(file, 0o600); } catch {}
778
- }
779
- function clearOwnerSecret() {
780
- try { fs.unlinkSync(getOwnerSecretPath(INSTANCE_ID)); } catch {}
781
- }
782
- function readOwnerSecretFor(ownerInstanceId) {
783
- if (!ownerInstanceId) return "";
784
- try {
785
- const raw = fs.readFileSync(getOwnerSecretPath(ownerInstanceId), "utf8");
786
- const parsed = JSON.parse(raw);
787
- return typeof parsed?.secret === "string" ? parsed.secret : "";
788
- } catch {
789
- return "";
790
- }
791
- }
792
- async function proxyRequest(endpoint, method, body) {
793
- return new Promise((resolve) => {
794
- const url = new URL(`http://127.0.0.1:${ownerHttpPort}${endpoint}`);
795
- // Auth: read the owner's per-process secret from the restricted
796
- // owner-secret file (0o600). The instanceId header is kept only as a
797
- // secondary diagnostic — requireOwnerToken on the owner side checks
798
- // the secret, not the instanceId.
799
- const active = readActiveInstance();
800
- const ownerInstanceId = active?.instanceId || INSTANCE_ID;
801
- // Key the secret-file lookup on the owner's published instanceId — the
802
- // SAME identifier the owner used when writing owner-secret-<instanceId>.json
803
- // (publishOwnerSecret above) and what requireOwnerToken's x-owner-instance
804
- // header check compares against. Do NOT route this through
805
- // getActiveOwnerPid(active): that helper prefers ownerLeadPid /
806
- // terminalLeadPid / supervisor_pid, which in a supervisor-backed session
807
- // diverge from the owner-HTTP process.pid (== owner's INSTANCE_ID),
808
- // causing the proxy to read owner-secret-<supervisorPid>.json while the
809
- // owner wrote owner-secret-<process.pid>.json → empty secret → 401.
810
- const ownerSecret = readOwnerSecretFor(ownerInstanceId);
811
- if (!ownerSecret) {
812
- resolve({ ok: false, error: "owner secret unavailable (file missing or unreadable)" });
813
- return;
814
- }
815
- const reqOpts = {
816
- hostname: "127.0.0.1",
817
- port: ownerHttpPort,
818
- path: url.pathname + url.search,
819
- method,
820
- headers: {
821
- "Content-Type": "application/json",
822
- "x-owner-token": ownerSecret,
823
- "x-owner-instance": ownerInstanceId,
824
- },
825
- timeout: 3e4
826
- };
827
- const req = http.request(reqOpts, (res) => {
828
- let data = "";
829
- res.on("data", (chunk) => {
830
- data += chunk;
831
- });
832
- res.on("end", () => {
833
- try {
834
- const parsed = JSON.parse(data);
835
- resolve({ ok: res.statusCode === 200, data: parsed, error: parsed.error });
836
- } catch {
837
- resolve({ ok: false, error: `invalid response from owner: ${data.slice(0, 200)}` });
838
- }
839
- });
840
- });
841
- req.on("error", (err) => {
842
- resolve({ ok: false, error: `proxy request failed: ${err.message}` });
843
- });
844
- req.on("timeout", () => {
845
- req.destroy();
846
- resolve({ ok: false, error: "proxy request timed out" });
847
- });
848
- if (body) req.write(JSON.stringify(body));
849
- req.end();
850
- });
851
- }
852
- async function pingOwner(port) {
853
- return new Promise((resolve) => {
854
- const req = http.request({
855
- hostname: "127.0.0.1",
856
- port,
857
- path: "/ping",
858
- method: "GET",
859
- timeout: 3e3
860
- }, (res) => {
861
- res.resume();
862
- resolve(res.statusCode === 200);
863
- });
864
- req.on("error", () => resolve(false));
865
- req.on("timeout", () => {
866
- req.destroy();
867
- resolve(false);
868
- });
869
- req.end();
870
- });
871
- }
872
- function tryListenPort(server, port) {
873
- return new Promise((resolve) => {
874
- server.once("error", () => resolve(false));
875
- server.listen(port, "127.0.0.1", () => resolve(true));
876
- });
877
- }
878
- // Owner-token auth gate. Compares x-owner-token against the per-process
879
- // OWNER_SECRET generated at startOwnerHttpServer time. The secret is NOT
880
- // returned by /ping (only the public instanceId is) so a local caller that
881
- // scrapes /ping still cannot forge owner-side calls. Constant-time compare
882
- // to avoid trivial timing leakage on the local socket. Optional secondary
883
- // instanceId check via x-owner-instance: when present it must match this
884
- // process's INSTANCE_ID, catching stale clients targeting an old owner.
885
- function requireOwnerToken(req, res) {
886
- const token = req.headers["x-owner-token"];
887
- if (!OWNER_SECRET || typeof token !== "string" || token.length !== OWNER_SECRET.length) {
888
- res.writeHead(401, { "Content-Type": "application/json" });
889
- res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
890
- return false;
891
- }
892
- let ok = false;
893
- try {
894
- ok = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(OWNER_SECRET));
895
- } catch {
896
- ok = false;
897
- }
898
- if (!ok) {
899
- res.writeHead(401, { "Content-Type": "application/json" });
900
- res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
901
- return false;
902
- }
903
- const instanceHeader = req.headers["x-owner-instance"];
904
- if (instanceHeader && instanceHeader !== INSTANCE_ID) {
905
- res.writeHead(401, { "Content-Type": "application/json" });
906
- res.end(JSON.stringify({ error: "unauthorized: instance mismatch" }));
907
- return false;
908
- }
909
- return true;
910
- }
911
- // Per-route handler table. Each handler matches the original switch-case
912
- // behavior byte-for-byte (auth checks, status codes, response shapes); the
913
- // outer dispatch loop just looks up the entry instead of running a long
914
- // switch. `methods` mirrors any pre-existing 405 guard.
915
- const OWNER_ROUTES = {
916
- "/ping": async (req, res /*, body, url*/) => {
917
- res.writeHead(200);
918
- res.end(JSON.stringify({ ok: true, instanceId: INSTANCE_ID, pid: process.pid }));
919
- },
920
- "/send": async (req, res, body) => {
921
- if (!requireOwnerToken(req, res)) return;
922
- // Pre/post-send activity bumps keep idle gating consistent across
923
- // slow network / attachment / rate-limited sends; double bump is
924
- // harmless.
925
- scheduler.noteActivity();
926
- const sendResult = await backend.sendMessage(body.chatId, body.text, body.opts);
927
- scheduler.noteActivity();
928
- res.writeHead(200);
929
- res.end(JSON.stringify({ sentIds: sendResult.sentIds }));
930
- },
931
- "/react": async (req, res, body) => {
932
- if (!requireOwnerToken(req, res)) return;
933
- await backend.react(body.chatId, body.messageId, body.emoji);
934
- res.writeHead(200);
935
- res.end(JSON.stringify({ ok: true }));
936
- },
937
- "/edit": async (req, res, body) => {
938
- if (!requireOwnerToken(req, res)) return;
939
- const editId = await backend.editMessage(body.chatId, body.messageId, body.text, body.opts);
940
- res.writeHead(200);
941
- res.end(JSON.stringify({ id: editId }));
942
- },
943
- "/fetch": async (req, res, body, url) => {
944
- if (!requireOwnerToken(req, res)) return;
945
- const channelId = url.searchParams.get("channel") ?? "";
946
- const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
947
- const msgs = await backend.fetchMessages(channelId, limit);
948
- recordFetchedMessages(channelId, labelForChannelId(channelId), msgs);
949
- res.writeHead(200);
950
- res.end(JSON.stringify({ messages: msgs }));
951
- },
952
- "/download": async (req, res, body) => {
953
- if (!requireOwnerToken(req, res)) return;
954
- const files = await backend.downloadAttachment(body.chatId, body.messageId);
955
- res.writeHead(200);
956
- res.end(JSON.stringify({ files }));
957
- },
958
- "/typing/start": async (req, res, body) => {
959
- if (!requireOwnerToken(req, res)) return;
960
- backend.startTyping(body.channelId);
961
- res.writeHead(200);
962
- res.end(JSON.stringify({ ok: true }));
963
- },
964
- "/typing/stop": async (req, res, body) => {
965
- if (!requireOwnerToken(req, res)) return;
966
- backend.stopTyping(body.channelId);
967
- res.writeHead(200);
968
- res.end(JSON.stringify({ ok: true }));
969
- },
970
- "/inject": async (req, res, body) => {
971
- // Require owner-token header to prevent unauthenticated local injection.
972
- if (!requireOwnerToken(req, res)) return;
973
- const content = body.content;
974
- if (!content) {
975
- res.writeHead(400);
976
- res.end(JSON.stringify({ error: "content required" }));
977
- return;
978
- }
979
- const source = body.source || "mixdog-agent";
980
- const injMeta = { user: source, user_id: "system", ts: (/* @__PURE__ */ new Date()).toISOString() };
981
- if (body.instruction) injMeta.instruction = body.instruction;
982
- if (body.type) injMeta.type = body.type;
983
- sendNotifyToParent("notifications/claude/channel", { content, meta: injMeta });
984
- res.writeHead(200);
985
- res.end(JSON.stringify({ ok: true }));
986
- },
987
- "/trigger-schedule": async (req, res, body) => {
988
- // Native fallback for `mcp__trigger_schedule` so out-of-band
989
- // verification works when the MCP stdio bridge is down (host agent
990
- // disconnected, supervisor restart pending, etc.). Same authz as
991
- // /inject — x-owner-token must equal INSTANCE_ID.
992
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
993
- if (!requireOwnerToken(req, res)) return;
994
- const triggerName = body.name;
995
- if (!triggerName) {
996
- res.writeHead(400);
997
- res.end(JSON.stringify({ error: "name required" }));
998
- return;
999
- }
1000
- try {
1001
- const r = await scheduler.triggerManual(triggerName);
1002
- res.writeHead(200);
1003
- res.end(JSON.stringify({ ok: true, result: r ?? null }));
1004
- } catch (e) {
1005
- res.writeHead(500);
1006
- res.end(JSON.stringify({ error: e?.message || String(e) }));
1007
- }
1008
- },
1009
- "/schedule-status": async (req, res) => {
1010
- // Owner-side schedule_status so standby/proxy sessions read the LIVE
1011
- // scheduler instead of their own stale local state. Mirrors the MCP
1012
- // schedule_status handler's formatting (kept byte-identical via the
1013
- // shared scheduleStatusResult() helper).
1014
- if (!requireOwnerToken(req, res)) return;
1015
- try {
1016
- const r = scheduleStatusResult();
1017
- res.writeHead(200);
1018
- res.end(JSON.stringify({ ok: true, result: r }));
1019
- } catch (e) {
1020
- res.writeHead(500);
1021
- res.end(JSON.stringify({ error: e?.message || String(e) }));
1022
- }
1023
- },
1024
- "/schedule-control": async (req, res, body) => {
1025
- // Owner-side schedule_control so standby/proxy sessions mutate the LIVE
1026
- // scheduler (defer/skip_today) instead of their own stale local state.
1027
- // Validation lives here because the proxy side's scheduler.nonInteractive/
1028
- // interactive lists are not authoritative.
1029
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
1030
- if (!requireOwnerToken(req, res)) return;
1031
- try {
1032
- const r = scheduleControlResult(body || {});
1033
- res.writeHead(200);
1034
- res.end(JSON.stringify({ ok: true, result: r }));
1035
- } catch (e) {
1036
- res.writeHead(500);
1037
- res.end(JSON.stringify({ error: e?.message || String(e) }));
1038
- }
1039
- },
1040
- "/bridge": async (req, res, body) => {
1041
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
1042
- if (!requireOwnerToken(req, res)) return;
1043
- const bridgeFile = body.file;
1044
- const bridgePrompt = body.prompt;
1045
- const bridgeRef = body.ref;
1046
- const bridgeRole = body.role;
1047
- const bridgeContext = body.context;
1048
- let bridgePromptFinal = bridgePrompt;
1049
- if (!bridgePromptFinal && bridgeFile) {
1050
- try { bridgePromptFinal = fs.readFileSync(bridgeFile, "utf-8").trim(); } catch (e) {
1051
- res.writeHead(400); res.end(JSON.stringify({ error: `Cannot read file: ${e.message}` })); return;
1052
- }
1053
- }
1054
- if (!bridgePromptFinal && !bridgeRef) { res.writeHead(400); res.end(JSON.stringify({ error: "prompt, file, or ref required" })); return; }
1055
- try {
1056
- const agentMod = await import(pathToFileURL(path.join(path.dirname(import.meta.url.replace("file:///", "").replace(/\//g, path.sep)), "..", "agent", "index.mjs")).href);
1057
- if (agentMod.init) await agentMod.init();
1058
- const toolArgs = {};
1059
- if (bridgePromptFinal) toolArgs.prompt = bridgePromptFinal;
1060
- if (bridgeRef) toolArgs.ref = bridgeRef;
1061
- if (bridgeRole) toolArgs.role = bridgeRole;
1062
- if (bridgeContext) toolArgs.context = bridgeContext;
1063
- const notifyFn = (text, extraMeta) => {
1064
- sendNotifyToParent("notifications/claude/channel", {
1065
- content: text,
1066
- meta: {
1067
- user: "mixdog-agent",
1068
- user_id: "system",
1069
- ts: new Date().toISOString(),
1070
- ...(extraMeta || {})
1071
- }
1072
- });
1073
- };
1074
- const BRIDGE_HTTP_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
1075
- const bridgeAbort = new AbortController();
1076
- const bridgeTimer = setTimeout(() => bridgeAbort.abort(new Error("bridge HTTP timeout")), BRIDGE_HTTP_TIMEOUT_MS);
1077
- const onReqClose = () => bridgeAbort.abort(new Error("client disconnected"));
1078
- req.on("close", onReqClose);
1079
- let result;
1080
- try {
1081
- result = await Promise.race([
1082
- agentMod.handleToolCall("bridge", toolArgs, { notifyFn, requestSignal: bridgeAbort.signal }),
1083
- new Promise((_, reject) => bridgeAbort.signal.addEventListener("abort", () => reject(bridgeAbort.signal.reason), { once: true })),
1084
- ]);
1085
- } finally {
1086
- clearTimeout(bridgeTimer);
1087
- req.removeListener("close", onReqClose);
1088
- }
1089
- res.writeHead(200);
1090
- res.end(JSON.stringify(result));
1091
- } catch (e) {
1092
- res.writeHead(500); res.end(JSON.stringify({ error: e.message })); return;
1093
- }
1094
- },
1095
- "/bridge/activate": async (req, res, body) => {
1096
- if (!requireOwnerToken(req, res)) return;
1097
- const active = Boolean(body.active);
1098
- const wasActive = channelBridgeActive;
1099
- channelBridgeActive = active;
1100
- writeBridgeState(active);
1101
- if (!active && wasActive) {
1102
- // Mirror the MCP activate_channel_bridge deactivate path: tear down
1103
- // owner-side runtime (Discord/scheduler/webhook/event/owner-HTTP/
1104
- // heartbeat) so a deactivated bridge doesn't keep running and this
1105
- // owner can't later proxyMode against its own port.
1106
- stopServerTyping();
1107
- try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
1108
- process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
1109
- }
1110
- }
1111
- res.writeHead(200);
1112
- res.end(JSON.stringify({ ok: true, active: channelBridgeActive }));
1113
- },
1114
- "/mcp": async (req, res, body) => {
1115
- if (req.method === "POST") {
1116
- // Require owner-token header to prevent unauthenticated local MCP dispatch.
1117
- if (!requireOwnerToken(req, res)) return;
1118
- const httpMcp = createHttpMcpServer();
1119
- const httpTransport = new StreamableHTTPServerTransport({
1120
- sessionIdGenerator: void 0,
1121
- enableJsonResponse: true
1122
- });
1123
- res.on("close", () => {
1124
- httpTransport.close();
1125
- void httpMcp.close();
1126
- });
1127
- await httpMcp.connect(httpTransport);
1128
- await httpTransport.handleRequest(req, res, body);
1129
- } else {
1130
- res.writeHead(405);
1131
- res.end(JSON.stringify({ error: "Method not allowed" }));
1132
- }
1133
- },
1134
- "/cycle1": async (req, res, body) => {
1135
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ ok: false, reason: "method-not-allowed", error: "POST required" })); return; }
1136
- if (!requireOwnerToken(req, res)) return;
1137
- const tCycleEntry = Date.now();
1138
- const timeoutMs = Number(body?.timeout_ms) > 0 ? Math.min(60000, Number(body.timeout_ms)) : 15000;
1139
- // IPC timer must outlive the worker-side deadline so a graceful
1140
- // {timedOutWaiting:true} resolve has time to traverse IPC before
1141
- // the channel timer rejects with memory-timeout. Without the
1142
- // buffer, the worker resolves at deadline-0ms and the local
1143
- // setTimeout fires at deadline+0ms in the same tick — race won by
1144
- // whichever scheduler ordering wins, turning intended 200 flags
1145
- // into 503 responses.
1146
- const ipcTimeoutMs = timeoutMs + 2000;
1147
- try {
1148
- // Carry the caller deadline through to the memory worker so a
1149
- // pending cycle1 in-flight is awaited under the same budget.
1150
- // Without this, when the previous cycle1's LLM call lives past
1151
- // 60s, every later SessionStart slot stacks another full 60s
1152
- // wait behind the same zombie promise.
1153
- const result = await callMemoryAction(
1154
- 'cycle1',
1155
- { ...(body?.args || {}), _callerDeadlineMs: timeoutMs },
1156
- ipcTimeoutMs,
1157
- );
1158
- // A successful IPC round-trip can still carry a nested MCP error
1159
- // envelope ({ isError: true }) when the memory worker served the
1160
- // call but the action failed — e.g. a promoted fork-proxy whose
1161
- // local `db` is still null. Surfacing that as outer { ok: true }
1162
- // masks the failure and makes session-start log a phantom success.
1163
- // Return a transient 503 so the hook's 503-retry path (which gates
1164
- // only on statusCode===503) re-polls instead of trusting it.
1165
- if (result && typeof result === 'object' && result.isError === true) {
1166
- const nestedText = Array.isArray(result.content)
1167
- ? result.content.map(c => (c && c.text) || '').join(' ').trim()
1168
- : '';
1169
- try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry} nestedError=1\n`); } catch {}
1170
- res.writeHead(503);
1171
- res.end(JSON.stringify({ ok: false, reason: 'memory-not-ready', error: nestedText || 'memory cycle1 returned isError' }));
1172
- } else {
1173
- try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry}\n`); } catch {}
1174
- res.writeHead(200);
1175
- res.end(JSON.stringify({ ok: true, result }));
1176
- }
1177
- } catch (e) {
1178
- // Classify transient/unavailable failures so the session-start hook
1179
- // (and other 503-retry callers) can distinguish boot-time races from
1180
- // IPC-layer faults and timeouts. All four reasons stay on 503 to
1181
- // preserve the hook retry contract (hooks/session-start.cjs:516
1182
- // gates only on statusCode===503); only the `reason` label changes.
1183
- //
1184
- // Source → reason mapping (upstream messages from server.mjs
1185
- // callWorker at 457-490 and local callMemoryAction at 169-187):
1186
- // server.mjs:470 "not ready (still booting)" → memory-not-ready
1187
- // server.mjs:464/467 "not available (...)" → worker-unavailable
1188
- // server.mjs:435 "exited unexpectedly" → worker-unavailable
1189
- // local "not a worker process" guard → worker-unavailable
1190
- // server.mjs:483 "IPC channel full or closed" → ipc-error
1191
- // server.mjs:488 "send failed: ..." → ipc-error
1192
- // server.mjs:475 "worker ... call ... timed out" → memory-timeout
1193
- // local "memory_call <action> timed out after Nms" → memory-timeout
1194
- const msg = e?.message || String(e);
1195
- let reason;
1196
- if (/worker memory not ready/i.test(msg)) {
1197
- reason = 'memory-not-ready';
1198
- } else if (/worker memory (IPC channel|send failed)/i.test(msg)) {
1199
- reason = 'ipc-error';
1200
- } else if (/timed out/i.test(msg)) {
1201
- reason = 'memory-timeout';
1202
- } else if (msg.includes('restart cap exceeded') || msg.includes('degraded')) {
1203
- // Permanent degraded state: restart cap hit or boot-time init failure.
1204
- // Use a distinct reason so callers can fail-fast without retrying.
1205
- // NOTE: checked before 'not available' — the error message
1206
- // "worker memory not available (restart cap exceeded)" contains both
1207
- // substrings and must land in 'memory-degraded', not 'worker-unavailable'.
1208
- reason = 'memory-degraded';
1209
- } else if (msg.includes('worker memory not available') || msg.includes('worker memory exited unexpectedly') || msg.includes('not a worker process')) {
1210
- reason = 'worker-unavailable';
1211
- }
1212
- const transient = Boolean(reason);
1213
- res.writeHead(transient ? 503 : 500);
1214
- res.end(JSON.stringify({ ok: false, reason, error: msg }));
1215
- }
1216
- },
1217
- "/rebind": async (req, res, body) => {
1218
- if (!requireOwnerToken(req, res)) return;
1219
- const channelId = statusState.read().channelId;
1220
- if (!channelId) {
1221
- res.writeHead(200);
1222
- res.end(JSON.stringify({ rebound: false, reason: "no channelId" }));
1223
- return;
1224
- }
1225
- const previousPath = getPersistedTranscriptPath();
1226
- const explicitTranscriptPath = typeof body?.transcriptPath === "string" ? body.transcriptPath.trim() : "";
1227
- const bound = await rebindTranscriptContext(channelId, {
1228
- previousPath,
1229
- persistStatus: true,
1230
- catchUp: true,
1231
- ...(explicitTranscriptPath ? { transcriptPath: explicitTranscriptPath } : {})
1232
- });
1233
- const reboundChanged = Boolean(bound) && bound !== previousPath;
1234
- res.writeHead(200);
1235
- res.end(JSON.stringify({ rebound: reboundChanged, path: bound || null }));
1236
- },
1237
- };
1238
- const BACKEND_DEPENDENT_PATHS = new Set([
1239
- "/send",
1240
- "/react",
1241
- "/edit",
1242
- "/fetch",
1243
- "/download",
1244
- "/typing/start",
1245
- "/typing/stop",
1246
- "/mcp"
1247
- ]);
1248
- async function ownerRequestHandler(req, res) {
1249
- res.setHeader("Content-Type", "application/json");
1250
- let body = {};
1251
- if (req.method === "POST") {
1252
- const chunks = [];
1253
- for await (const chunk of req) chunks.push(chunk);
1254
- try {
1255
- const rawBody = Buffer.concat(chunks).toString();
1256
- body = rawBody.trim() ? JSON.parse(rawBody) : {};
1257
- } catch {
1258
- res.writeHead(400);
1259
- res.end(JSON.stringify({ error: "invalid JSON body" }));
1260
- return;
1261
- }
1262
- }
1263
- try {
1264
- const url = new URL(req.url ?? "/", `http://127.0.0.1`);
1265
- if (BACKEND_DEPENDENT_PATHS.has(url.pathname) && !bridgeRuntimeConnected) {
1266
- res.writeHead(503);
1267
- res.end(JSON.stringify({ ok: false, reason: "backend-not-ready" }));
1268
- return;
1269
- }
1270
- const handler = OWNER_ROUTES[url.pathname];
1271
- if (handler) {
1272
- await handler(req, res, body, url);
1273
- return;
1274
- }
1275
- res.writeHead(404);
1276
- res.end(JSON.stringify({ error: "not found" }));
1277
- } catch (err) {
1278
- const msg = err instanceof Error ? err.message : String(err);
1279
- res.writeHead(500);
1280
- res.end(JSON.stringify({ error: msg }));
1281
- }
1282
- }
1283
- async function startOwnerHttpServer() {
1284
- if (ownerHttpServer) return ownerHttpServer.address().port;
1285
- // Generate a fresh cryptographic owner-secret BEFORE the listener accepts
1286
- // traffic so requireOwnerToken always has a real secret to compare. Stored
1287
- // in a 0o600 sidecar file (owner-secret-<pid>.json) under RUNTIME_ROOT so
1288
- // only the same UID + same active owner pid can read it back. /ping does
1289
- // NOT return this value — only the public instanceId.
1290
- if (!OWNER_SECRET) {
1291
- OWNER_SECRET = crypto.randomBytes(32).toString("hex");
1292
- try { publishOwnerSecret(OWNER_SECRET); }
1293
- catch (e) {
1294
- process.stderr.write(`mixdog: failed to publish owner secret: ${e?.message || e}\n`);
1295
- }
1296
- }
1297
- const server = http.createServer(ownerRequestHandler);
1298
- for (let port = PROXY_PORT_MIN; port <= PROXY_PORT_MAX; port++) {
1299
- if (await tryListenPort(server, port)) {
1300
- ownerHttpServer = server;
1301
- process.stderr.write(`mixdog: owner HTTP server listening on 127.0.0.1:${port}
1302
- `);
1303
- return port;
1304
- }
1305
- server.removeAllListeners("error");
1306
- }
1307
- throw new Error(`no available port in range ${PROXY_PORT_MIN}-${PROXY_PORT_MAX}`);
1308
- }
1309
- function stopOwnerHttpServer() {
1310
- if (!ownerHttpServer) return;
1311
- ownerHttpServer.close();
1312
- ownerHttpServer = null;
1313
- // Drop the per-process secret + sidecar file. A future startOwnerHttpServer()
1314
- // call regenerates a fresh one, so a stale standby that read the old secret
1315
- // before the restart cannot authenticate against the new owner.
1316
- OWNER_SECRET = "";
1317
- try { clearOwnerSecret(); } catch {}
1318
- globalThis.__mixdogBeaconRealHandler = null;
1319
- globalThis.__mixdogBeacon = null;
1320
- }
1321
616
  function logOwnership(note) {
1322
617
  if (lastOwnershipNote === note) return;
1323
618
  lastOwnershipNote = note;
@@ -1328,47 +623,24 @@ function currentOwnerState() {
1328
623
  const active = readActiveInstance();
1329
624
  return {
1330
625
  active,
1331
- owned: active?.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID
626
+ // Strict last-wins: this process owns the bridge ONLY when active-instance
627
+ // names exactly this INSTANCE_ID. A newer remote session that claims the
628
+ // seat overwrites instanceId, so the old owner immediately reads owned=false
629
+ // and disconnects on its next refresh tick. No PID/terminal fallback —
630
+ // that used to let a co-terminal worker wrongly self-claim.
631
+ owned: active?.instanceId === INSTANCE_ID
1332
632
  };
1333
633
  }
1334
634
  function getBridgeOwnershipSnapshot() {
1335
635
  return currentOwnerState();
1336
636
  }
1337
- // MIXDOG_PIN_OWNER=1 in the owning process writes `pinned:true` into
1338
- // active-instance.json. Pinned owners ignore the 10 s stale window — they
1339
- // only relinquish ownership when their OS process actually dies. Set per
1340
- // session (env var on the host agent shell) to lock that Lead as the
1341
- // schedule/webhook receiver across multi-session use.
1342
- function canStealOwnership(active) {
1343
- if (!active) return true;
1344
- if (active.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID) return true;
1345
- if (active.pinned) {
1346
- const pinnedPid = getActiveOwnerPid(active);
1347
- if (!pinnedPid) return true;
1348
- try { process.kill(pinnedPid, 0); return false; }
1349
- catch { return true; }
1350
- }
1351
- if (Date.now() - active.updatedAt > ACTIVE_OWNER_STALE_MS) return true;
1352
- const ownerPid = getActiveOwnerPid(active);
1353
- try {
1354
- if (!ownerPid) throw new Error("missing owner pid");
1355
- process.kill(ownerPid, 0);
1356
- return false;
1357
- } catch {
1358
- return true;
1359
- }
1360
- }
1361
637
  function claimBridgeOwnership(reason) {
1362
638
  refreshActiveInstance(INSTANCE_ID);
1363
639
  logOwnership(`claimed owner (${reason})`);
1364
640
  }
1365
- function noteStartupHandoff(previous) {
1366
- if (!previous) return;
1367
- if (previous.instanceId === INSTANCE_ID) return;
1368
- if (getActiveOwnerPid(previous) === TERMINAL_LEAD_PID) return;
1369
- logOwnership(`startup handoff from ${previous.instanceId}`);
1370
- }
1371
641
  async function bindPersistedTranscriptIfAny() {
642
+ // Main-channel fallback requires channelBridgeActive (set in start() before
643
+ // refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
1372
644
  // Resolve channelId first from persisted status; fall back to the most
1373
645
  // recent status-*.json snapshot, then to the configured main channel when
1374
646
  // the bridge is active. No exists-gate here — once we have a channelId,
@@ -1505,17 +777,17 @@ async function startOwnedRuntime(options = {}) {
1505
777
  if (!channelBridgeActive) return;
1506
778
  bridgeRuntimeStarting = true;
1507
779
  _ownedRuntimeStopRequested = false;
1508
- // Advertise active-instance.json BEFORE backend connect so peers can
1509
- // discover this owner (httpPort) immediately. backendReady=false marks
1510
- // the partial state until backend.connect() succeeds.
1511
- let httpPort;
1512
- try {
1513
- httpPort = await startOwnerHttpServer();
1514
- } catch (e) {
1515
- process.stderr.write(`mixdog: HTTP server start failed (non-fatal): ${e instanceof Error ? e.message : String(e)}
1516
- `);
1517
- }
1518
- refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: false });
780
+ // Capture the backend instance that THIS start operation will connect. A
781
+ // reloadRuntimeConfig() hot-swap can replace the global `backend` while this
782
+ // start is still awaiting connect(); using the captured instance for both
783
+ // connect() and the bail-path disconnect() guarantees we tear down the
784
+ // backend WE started (not the freshly-swapped one), closing the
785
+ // both-backends-live window.
786
+ const startingBackend = backend;
787
+ // Advertise active-instance.json BEFORE backend connect so a newer remote
788
+ // session's last-wins claim is visible immediately. backendReady=false
789
+ // marks the partial state until backend.connect() succeeds.
790
+ refreshActiveInstance(INSTANCE_ID, { backendReady: false });
1519
791
  startOwnerHeartbeat();
1520
792
  // Re-check after each post-connect await so a stopOwnedRuntime() landing
1521
793
  // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
@@ -1526,8 +798,7 @@ async function startOwnedRuntime(options = {}) {
1526
798
  // (stop did disconnect; redo to be defensive).
1527
799
  const bailIfStopRequested = async () => {
1528
800
  if (!_ownedRuntimeStopRequested) return false;
1529
- try { await backend.disconnect(); } catch {}
1530
- try { stopOwnerHttpServer(); } catch {}
801
+ try { await startingBackend.disconnect(); } catch {}
1531
802
  try { stopOwnerHeartbeat(); } catch {}
1532
803
  try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
1533
804
  try { clearActiveInstance(INSTANCE_ID); } catch {}
@@ -1535,16 +806,26 @@ async function startOwnedRuntime(options = {}) {
1535
806
  _ownedRuntimeStopRequested = false;
1536
807
  return true;
1537
808
  };
809
+ const restoreBinding = options.restoreBinding !== false;
810
+ const bindPersistedTranscriptTask = restoreBinding
811
+ ? bindPersistedTranscriptIfAny().catch((e) => {
812
+ process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
813
+ })
814
+ : null;
1538
815
  // Await backend.connect() so callers (and bindingReady) only resolve after
1539
816
  // the Discord binding is real. Previously this was fire-and-forget and
1540
817
  // refreshBridgeOwnership returned immediately, letting bindingReady fire
1541
818
  // before backend listeners were attached.
1542
819
  try {
1543
- await backend.connect();
1544
- if (await bailIfStopRequested()) return;
820
+ await startingBackend.connect();
821
+ if (await bailIfStopRequested()) {
822
+ cancelPendingTranscriptRearm();
823
+ try { forwarder.stopWatch(); } catch {}
824
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
825
+ return;
826
+ }
1545
827
  bridgeRuntimeConnected = true;
1546
- refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: true });
1547
- proxyMode = false;
828
+ refreshActiveInstance(INSTANCE_ID, { backendReady: true });
1548
829
  // initProviders must complete before scheduler.start() — otherwise the
1549
830
  // scheduler's first fire can land before the registry is populated and
1550
831
  // return `Provider "<name>" not found or not enabled`. The previous
@@ -1555,22 +836,37 @@ async function startOwnedRuntime(options = {}) {
1555
836
  } catch (e) {
1556
837
  process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1557
838
  }
1558
- if (await bailIfStopRequested()) return;
839
+ if (await bailIfStopRequested()) {
840
+ cancelPendingTranscriptRearm();
841
+ try { forwarder.stopWatch(); } catch {}
842
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
843
+ return;
844
+ }
1559
845
  scheduler.start();
1560
846
  startSnapshotWriter(scheduler);
1561
847
  syncOwnedWebhookAndEventRuntime();
1562
- if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
1563
- process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1564
- });
848
+ if (restoreBinding) {
849
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
850
+ const pendingTranscriptPath = forwarder.transcriptPath;
851
+ if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
852
+ // Pre-connect bind may have armed rearm while !bridgeRuntimeConnected;
853
+ // the first tick then exits without rescheduling. Re-arm now that we own.
854
+ schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
855
+ } else {
856
+ void forwarder.forwardNewText().catch((err) => {
857
+ process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
858
+ });
859
+ }
860
+ }
1565
861
  process.stderr.write(`mixdog: running with ${backend.name} backend\n`);
1566
862
  logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
1567
863
  } catch (e) {
1568
864
  process.stderr.write(`mixdog: backend connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
865
+ cancelPendingTranscriptRearm();
866
+ try { forwarder.stopWatch(); } catch {}
867
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
1569
868
  // Roll back partial owner-side state advertised before connect() ran:
1570
- // HTTP server, heartbeat, and active-instance entry. Without this cleanup
1571
- // stopOwnedRuntime() at shutdown will short-circuit on !bridgeRuntimeConnected
1572
- // and leave the port bound + active-instance.json stale.
1573
- try { stopOwnerHttpServer(); } catch {}
869
+ // heartbeat and active-instance entry.
1574
870
  try { stopOwnerHeartbeat(); } catch {}
1575
871
  try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
1576
872
  try { clearActiveInstance(INSTANCE_ID); } catch {}
@@ -1578,59 +874,12 @@ async function startOwnedRuntime(options = {}) {
1578
874
  bridgeRuntimeStarting = false;
1579
875
  }
1580
876
  }
1581
- async function startCliOwnedRuntime(options = {}) {
1582
- if (bridgeRuntimeConnected) return;
1583
- if (bridgeRuntimeStarting) return;
1584
- if (!channelBridgeActive) return;
1585
- const startedAt = performance.now();
1586
- bootProfile("cli-owned:start");
1587
- bridgeRuntimeStarting = true;
1588
- _ownedRuntimeStopRequested = false;
1589
- try {
1590
- const backendStartedAt = performance.now();
1591
- await backend.connect();
1592
- bootProfile("backend:connected", { ms: (performance.now() - backendStartedAt).toFixed(1), backend: backend.name });
1593
- if (_ownedRuntimeStopRequested) {
1594
- try { await backend.disconnect(); } catch {}
1595
- bridgeRuntimeConnected = false;
1596
- _ownedRuntimeStopRequested = false;
1597
- return;
1598
- }
1599
- bridgeRuntimeConnected = true;
1600
- proxyMode = false;
1601
- ownerHttpPort = 0;
1602
- try {
1603
- const providersStartedAt = performance.now();
1604
- const agentCfg = loadAgentConfig();
1605
- await initProviders(agentCfg.providers || {});
1606
- bootProfile("providers:ready", { ms: (performance.now() - providersStartedAt).toFixed(1) });
1607
- } catch (e) {
1608
- bootProfile("providers:failed", { error: e instanceof Error ? e.message : String(e) });
1609
- process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1610
- }
1611
- if (_ownedRuntimeStopRequested) {
1612
- await stopOwnedRuntime("cli-owned start cancelled");
1613
- return;
1614
- }
1615
- scheduler.start();
1616
- startSnapshotWriter(scheduler);
1617
- bootProfile("scheduler:started");
1618
- syncOwnedWebhookAndEventRuntime();
1619
- bootProfile("webhook-event:ready");
1620
- if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
1621
- process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1622
- });
1623
- bootProfile("cli-owned:ready", { ms: (performance.now() - startedAt).toFixed(1) });
1624
- process.stderr.write(`mixdog: running with ${backend.name} backend (cli-owned)\n`);
1625
- } catch (e) {
1626
- bootProfile("cli-owned:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e instanceof Error ? e.message : String(e) });
1627
- process.stderr.write(`mixdog: backend connect failed (non-fatal, cli-owned): ${e instanceof Error ? e.message : String(e)}\n`);
1628
- try { await stopOwnedRuntime("cli-owned start failed"); } catch {}
1629
- } finally {
1630
- bridgeRuntimeStarting = false;
1631
- }
1632
- }
1633
877
  async function stopOwnedRuntime(reason) {
878
+ // Cancel any pending transcript re-arm poll BEFORE the connected/starting
879
+ // early-return below. Otherwise a poll armed against a not-yet-existing
880
+ // transcript could fire after teardown and reinstall the fs.watch handle
881
+ // (startWatch is not owner-gated), leaking a live watcher past shutdown.
882
+ cancelPendingTranscriptRearm();
1634
883
  // startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
1635
884
  // claims channel locks BEFORE awaiting backend.connect(). If shutdown lands
1636
885
  // during that window (bridgeRuntimeStarting=true, bridgeRuntimeConnected
@@ -1649,7 +898,6 @@ async function stopOwnedRuntime(reason) {
1649
898
  // and the drain/retry timers stay live after ownership is dropped, leaking a
1650
899
  // file handle + timers for the rest of the process lifetime.
1651
900
  try { forwarder.stopWatch(); } catch {}
1652
- stopOwnerHttpServer();
1653
901
  stopOwnerHeartbeat();
1654
902
  scheduler.stop();
1655
903
  stopSnapshotWriter();
@@ -1671,8 +919,14 @@ function refreshBridgeOwnershipSafe(options = {}) {
1671
919
  function startOwnerHeartbeat() {
1672
920
  if (ownerHeartbeatTimer) return;
1673
921
  ownerHeartbeatTimer = setInterval(() => {
1674
- try { refreshActiveInstance(INSTANCE_ID); }
1675
- catch (e) {
922
+ try {
923
+ // Last-wins guard: only refresh the seat if we STILL own it. If a newer
924
+ // remote session claimed active-instance.json since our last tick, do
925
+ // NOT overwrite it back — that would re-steal ownership and cause
926
+ // ping-pong / double backend connections. The bridgeOwnershipTimer's
927
+ // refreshBridgeOwnership() will observe owned=false and disconnect us.
928
+ if (currentOwnerState().owned) refreshActiveInstance(INSTANCE_ID);
929
+ } catch (e) {
1676
930
  process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
1677
931
  }
1678
932
  }, OWNER_HEARTBEAT_INTERVAL_MS);
@@ -1689,98 +943,41 @@ async function refreshBridgeOwnership(options = {}) {
1689
943
  // instead of returning early and observing spurious auto-connect failure.
1690
944
  if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
1691
945
  bridgeOwnershipRefreshInFlight = (async () => {
946
+ // Opt-in remote, single-owner, last-wins. Only a remote session with an
947
+ // active bridge participates. If this instance is the active owner (its
948
+ // INSTANCE_ID is the one advertised in active-instance.json) it ensures
949
+ // the owned runtime is up. If a newer remote session has since claimed
950
+ // ownership (last-wins overwrite), this instance is no longer owner and
951
+ // quietly tears its backend down on the next tick. No proxy, no steal.
1692
952
  if (!channelBridgeActive) {
1693
- const { active: active2 } = currentOwnerState();
1694
- if (active2?.httpPort && !proxyMode) {
1695
- const alive = await pingOwner(active2.httpPort);
1696
- if (alive) {
1697
- proxyMode = true;
1698
- ownerHttpPort = active2.httpPort;
1699
- logOwnership(`non-channel session \u2014 proxy mode via ${active2.instanceId}`);
1700
- }
1701
- }
953
+ if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
1702
954
  return;
1703
955
  }
1704
956
  const { active, owned } = currentOwnerState();
1705
- const activeHttpPort = Number(active?.httpPort) || 0;
1706
- let activeHttpChecked = false;
1707
- let activeHttpAlive = false;
1708
- const checkActiveHttp = async () => {
1709
- if (!activeHttpPort) return false;
1710
- if (!activeHttpChecked) {
1711
- activeHttpAlive = await pingOwner(activeHttpPort);
1712
- activeHttpChecked = true;
1713
- }
1714
- return activeHttpAlive;
1715
- };
1716
- const enterProxyMode = (note) => {
1717
- proxyMode = true;
1718
- ownerHttpPort = activeHttpPort;
1719
- if (note) logOwnership(note);
1720
- };
1721
- if (proxyMode && !owned && activeHttpPort) {
1722
- const alive = await checkActiveHttp();
1723
- if (!alive) {
1724
- process.stderr.write(`[ownership] owner ping failed, attempting takeover
1725
- `);
1726
- proxyMode = false;
1727
- ownerHttpPort = 0;
1728
- claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
1729
- const next2 = currentOwnerState();
1730
- if (next2.owned) {
1731
- refreshActiveInstance(INSTANCE_ID);
1732
- await startOwnedRuntime(options);
1733
- }
1734
- return;
1735
- }
1736
- // Active owner is alive but may have rebound to a new port since the
1737
- // previous refresh (owner restart on a different PROXY_PORT). Sync
1738
- // ownerHttpPort so subsequent proxyRequest() hits the new port instead
1739
- // of the stale value cached at proxy-mode entry.
1740
- if (ownerHttpPort !== activeHttpPort) {
1741
- ownerHttpPort = activeHttpPort;
1742
- logOwnership(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
1743
- }
1744
- return;
1745
- }
1746
- if (!owned && activeHttpPort) {
1747
- const alive = await checkActiveHttp();
1748
- if (alive) {
1749
- enterProxyMode(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
1750
- return;
1751
- }
1752
- const updatedAt = Number(active?.updatedAt);
1753
- const activeAgeMs = Number.isFinite(updatedAt) ? Date.now() - updatedAt : Number.POSITIVE_INFINITY;
1754
- if (active?.backendReady === true || activeAgeMs > ACTIVE_OWNER_STALE_MS) {
1755
- logOwnership(`owner ${active.instanceId} port ${activeHttpPort} unreachable`);
1756
- claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
1757
- }
1758
- }
1759
- if (!owned && canStealOwnership(active)) {
1760
- claimBridgeOwnership(active ? `takeover from ${active.instanceId}` : "startup");
1761
- }
1762
- const next = currentOwnerState();
1763
- if (next.owned) {
957
+ if (owned) {
1764
958
  refreshActiveInstance(INSTANCE_ID);
1765
959
  await startOwnedRuntime(options);
1766
960
  return;
1767
961
  }
1768
- if (bridgeRuntimeConnected) {
1769
- const reason = next.active?.instanceId ? `newer server ${next.active.instanceId}` : "no active owner";
1770
- await stopOwnedRuntime(reason);
1771
- return;
1772
- }
1773
- if (next.active?.httpPort && !proxyMode) {
1774
- const alive = await pingOwner(next.active.httpPort);
1775
- if (alive) {
1776
- proxyMode = true;
1777
- ownerHttpPort = next.active.httpPort;
1778
- logOwnership(`proxy mode via owner ${next.active.instanceId} port ${next.active.httpPort}`);
1779
- return;
962
+ // Not the owner. Two sub-cases:
963
+ // (a) A live remote session holds the seat (active-instance names a
964
+ // different, non-stale instance) → last-wins: we lost, go quiet
965
+ // (disconnect if we were connected).
966
+ // (b) There is NO live owner (active is null/stale — e.g. our own entry
967
+ // was cleared after a backend-connect failure or a bridge
968
+ // deactivate/reactivate) this remote session claims the empty seat
969
+ // and starts the owned runtime. Without this, a remote session could
970
+ // never (re)acquire ownership once its active entry was cleared.
971
+ if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
972
+ if (bridgeRuntimeConnected) {
973
+ await stopOwnedRuntime("ownership lost (newer remote session)");
1780
974
  }
975
+ return;
1781
976
  }
1782
- if (next.active?.instanceId) {
1783
- logOwnership(`standby under owner ${next.active.instanceId}`);
977
+ // No live owner — claim the empty seat and start.
978
+ claimBridgeOwnership("no active owner");
979
+ if (currentOwnerState().owned) {
980
+ await startOwnedRuntime(options);
1784
981
  }
1785
982
  })();
1786
983
  try {
@@ -1809,6 +1006,24 @@ async function reloadRuntimeConfig() {
1809
1006
  const shouldRestart = bridgeRuntimeConnected || bridgeRuntimeStarting;
1810
1007
  if (shouldRestart) await stopOwnedRuntime("backend config changed");
1811
1008
  backend = nextBackend;
1009
+ // The persisted routing channelId belongs to the OLD backend (e.g. a
1010
+ // Discord snowflake) and is meaningless for the new one — sending to it
1011
+ // would 400 "chat not found". There is no id mapping between platforms, so
1012
+ // CLEAR the stale binding: drop the forwarder's context + watcher and wipe
1013
+ // status.channelId/transcriptPath. The next inbound from the new backend
1014
+ // rebinds the correct chat via applyTranscriptBinding(). Only done on
1015
+ // backendChanged — same-backend reloads keep their binding untouched.
1016
+ // (active-instance is cleared by stopOwnedRuntime on the restart path; we
1017
+ // don't re-advertise here to avoid resurrecting a just-cleared entry.)
1018
+ try { forwarder.stopWatch(); } catch {}
1019
+ forwarder.channelId = "";
1020
+ forwarder.transcriptPath = "";
1021
+ try {
1022
+ statusState.update((state) => {
1023
+ state.channelId = "";
1024
+ state.transcriptPath = "";
1025
+ });
1026
+ } catch {}
1812
1027
  if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
1813
1028
  } else if (nextBackend !== previousBackend) {
1814
1029
  try { await nextBackend.disconnect?.(); } catch {}
@@ -1983,11 +1198,10 @@ function wireEventQueueHandlers(eventQueue) {
1983
1198
  injectAndRecord(channelId, name, content, options);
1984
1199
  });
1985
1200
  // Defensive ownership probe: the queue tick should only run in the active
1986
- // owner process. Standby / proxy instances see bridgeRuntimeConnected=false
1987
- // or proxyMode=true and will skip the tick even if an errant start() slipped
1988
- // through.
1989
- eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
1990
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
1201
+ // owner process. Non-owner instances see bridgeRuntimeConnected=false and
1202
+ // will skip the tick even if an errant start() slipped through.
1203
+ eventQueue.setOwnerGetter(() => bridgeRuntimeConnected);
1204
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
1991
1205
  }
1992
1206
  function editDiscordMessage(channelId, messageId, label) {
1993
1207
  // Behavior-preserving: route through the backend abstraction (which uses
@@ -2235,41 +1449,6 @@ function runCmd(cmd, args, capture = false) {
2235
1449
  proc.on("error", reject);
2236
1450
  });
2237
1451
  }
2238
- let resolvedWhisperLanguage = null;
2239
- function normalizeWhisperLanguage(value) {
2240
- const raw = String(value ?? "").trim().toLowerCase();
2241
- if (!raw || raw === "auto") return null;
2242
- if (raw.startsWith("ko")) return "ko";
2243
- if (raw.startsWith("ja")) return "ja";
2244
- if (raw.startsWith("en")) return "en";
2245
- if (raw.startsWith("zh")) return "zh";
2246
- if (raw.startsWith("de")) return "de";
2247
- if (raw.startsWith("fr")) return "fr";
2248
- if (raw.startsWith("es")) return "es";
2249
- if (raw.startsWith("it")) return "it";
2250
- if (raw.startsWith("pt")) return "pt";
2251
- if (raw.startsWith("ru")) return "ru";
2252
- return raw;
2253
- }
2254
- function detectDeviceLanguage() {
2255
- if (resolvedWhisperLanguage) return resolvedWhisperLanguage;
2256
- const candidates = [
2257
- process.env.MIXDOG_CHANNELS_WHISPER_LANGUAGE,
2258
- process.env.LC_ALL,
2259
- process.env.LC_MESSAGES,
2260
- process.env.LANG,
2261
- Intl.DateTimeFormat().resolvedOptions().locale
2262
- ];
2263
- for (const candidate of candidates) {
2264
- const normalized = normalizeWhisperLanguage(candidate);
2265
- if (normalized) {
2266
- resolvedWhisperLanguage = normalized;
2267
- return normalized;
2268
- }
2269
- }
2270
- resolvedWhisperLanguage = "auto";
2271
- return resolvedWhisperLanguage;
2272
- }
2273
1452
  // ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
2274
1453
  const _voiceTranscriptionQueue = (() => {
2275
1454
  let running = 0;
@@ -2404,29 +1583,15 @@ async function _doTranscribeVoice(audioPath, attachmentId) {
2404
1583
  }
2405
1584
  }
2406
1585
  import { TOOL_DEFS } from './tool-defs.mjs';
2407
- function createHttpMcpServer() {
2408
- const s = new Server(
2409
- { name: "mixdog", version: PLUGIN_VERSION },
2410
- { capabilities: { tools: {} }, instructions: INSTRUCTIONS }
2411
- );
2412
- s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
2413
- s.setRequestHandler(CallToolRequestSchema, async (req) => {
2414
- const toolName = req.params.name;
2415
- const args = req.params.arguments ?? {};
2416
- return handleToolCallWithBridgeRetry(toolName, args);
2417
- });
2418
- return s;
2419
- }
2420
1586
  // Tool dispatch in worker mode goes through the IPC `call` handler at the
2421
- // bottom of this file (parent's `callWorker` → `handleToolCall`). The HTTP
2422
- // MCP path uses its own short-lived `Server` instance built by
2423
- // `createHttpMcpServer()` above. There is no orphan worker-level Server.
1587
+ // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
1588
+ // orphan worker-level MCP Server: the parent (server.mjs) owns the single
1589
+ // connected transport and routes CallTool through the IPC `call` path.
2424
1590
  const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
2425
1591
  // ── Backend-tool dispatch helpers ───────────────────────────────────────────
2426
- // Each helper transparently routes through proxyRequest() when this instance
2427
- // is in proxyMode (non-owner), or through the local backend otherwise. The
2428
- // MCP-result formatting (text shape, cache invalidation, isError flag) is
2429
- // shared so both branches produce byte-identical output.
1592
+ // Each helper dispatches through the local backend (this process is always the
1593
+ // owner in opt-in remote mode). The MCP-result formatting (text shape, cache
1594
+ // invalidation, isError flag) is kept here so results stay consistent.
2430
1595
  // schedule_status / schedule_control share their result-formatting between
2431
1596
  // the local (owner) MCP case handlers and the owner-side HTTP routes that
2432
1597
  // serve proxied standby sessions. Keeping the body here makes both paths
@@ -2473,24 +1638,11 @@ async function dispatchReply(args) {
2473
1638
  components: args.components ?? []
2474
1639
  };
2475
1640
  let ids;
2476
- if (proxyMode) {
2477
- const proxyResult = await proxyRequest("/send", "POST", {
2478
- chatId: args.chat_id,
2479
- text: args.text,
2480
- opts: sendOpts
2481
- });
2482
- if (!proxyResult.ok) {
2483
- return { content: [{ type: "text", text: `proxy reply failed: ${proxyResult.error}` }], isError: true };
2484
- }
2485
- ids = proxyResult.data?.sentIds ?? [];
2486
- } else {
2487
- // Pre-send activity bump keeps idle gating consistent during the await.
2488
- scheduler.noteActivity();
2489
- const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
2490
- // Lead-originated reply via proxy-mode MCP — bump activity.
2491
- scheduler.noteActivity();
2492
- ids = sendResult.sentIds;
2493
- }
1641
+ // Pre-send activity bump keeps idle gating consistent during the await.
1642
+ scheduler.noteActivity();
1643
+ const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
1644
+ scheduler.noteActivity();
1645
+ ids = sendResult.sentIds;
2494
1646
  const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
2495
1647
  return { content: [{ type: "text", text }] };
2496
1648
  }
@@ -2498,17 +1650,8 @@ async function dispatchFetch(args) {
2498
1650
  const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
2499
1651
  const limit = args.limit ?? 20;
2500
1652
  let msgs;
2501
- if (proxyMode) {
2502
- const proxyResult = await proxyRequest(`/fetch?channel=${encodeURIComponent(channelId)}&limit=${limit}`, "GET");
2503
- if (!proxyResult.ok) {
2504
- return { content: [{ type: "text", text: `proxy fetch failed: ${proxyResult.error}` }], isError: true };
2505
- }
2506
- msgs = proxyResult.data?.messages ?? [];
2507
- // recordFetchedMessages already ran on the owner side (/fetch route).
2508
- } else {
2509
- msgs = await backend.fetchMessages(channelId, limit);
2510
- recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
2511
- }
1653
+ msgs = await backend.fetchMessages(channelId, limit);
1654
+ recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
2512
1655
  const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
2513
1656
  const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
2514
1657
  return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
@@ -2516,53 +1659,18 @@ async function dispatchFetch(args) {
2516
1659
  return { content: [{ type: "text", text }] };
2517
1660
  }
2518
1661
  async function dispatchReact(args) {
2519
- if (proxyMode) {
2520
- const proxyResult = await proxyRequest("/react", "POST", {
2521
- chatId: args.chat_id,
2522
- messageId: args.message_id,
2523
- emoji: args.emoji
2524
- });
2525
- if (!proxyResult.ok) {
2526
- return { content: [{ type: "text", text: `proxy react failed: ${proxyResult.error}` }], isError: true };
2527
- }
2528
- } else {
2529
- await backend.react(args.chat_id, args.message_id, args.emoji);
2530
- }
1662
+ await backend.react(args.chat_id, args.message_id, args.emoji);
2531
1663
  return { content: [{ type: "text", text: "reacted" }] };
2532
1664
  }
2533
1665
  async function dispatchEditMessage(args) {
2534
1666
  const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
2535
1667
  let id;
2536
- if (proxyMode) {
2537
- const proxyResult = await proxyRequest("/edit", "POST", {
2538
- chatId: args.chat_id,
2539
- messageId: args.message_id,
2540
- text: args.text,
2541
- opts
2542
- });
2543
- if (!proxyResult.ok) {
2544
- return { content: [{ type: "text", text: `proxy edit failed: ${proxyResult.error}` }], isError: true };
2545
- }
2546
- id = proxyResult.data?.id;
2547
- } else {
2548
- id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
2549
- }
1668
+ id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
2550
1669
  return { content: [{ type: "text", text: `edited (id: ${id})` }] };
2551
1670
  }
2552
1671
  async function dispatchDownloadAttachment(args) {
2553
1672
  let files;
2554
- if (proxyMode) {
2555
- const proxyResult = await proxyRequest("/download", "POST", {
2556
- chatId: args.chat_id,
2557
- messageId: args.message_id
2558
- });
2559
- if (!proxyResult.ok) {
2560
- return { content: [{ type: "text", text: `proxy download failed: ${proxyResult.error}` }], isError: true };
2561
- }
2562
- files = proxyResult.data?.files ?? [];
2563
- } else {
2564
- files = await backend.downloadAttachment(args.chat_id, args.message_id);
2565
- }
1673
+ files = await backend.downloadAttachment(args.chat_id, args.message_id);
2566
1674
  if (files.length === 0) {
2567
1675
  return { content: [{ type: "text", text: "message has no attachments" }] };
2568
1676
  }
@@ -2581,7 +1689,7 @@ async function dispatchDownloadAttachment(args) {
2581
1689
  ${lines.join("\n")}` }] };
2582
1690
  }
2583
1691
  async function handleToolCall(name, args, _signal) {
2584
- if (_channelsDegraded) {
1692
+ if (isChannelsDegraded()) {
2585
1693
  return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
2586
1694
  }
2587
1695
  let result;
@@ -2603,97 +1711,35 @@ async function handleToolCall(name, args, _signal) {
2603
1711
  result = await dispatchDownloadAttachment(args);
2604
1712
  break;
2605
1713
  case "schedule_status": {
2606
- if (proxyMode) {
2607
- const proxyResult = await proxyRequest("/schedule-status", "GET");
2608
- if (!proxyResult.ok) {
2609
- result = { content: [{ type: "text", text: `proxy schedule_status failed: ${proxyResult.error}` }], isError: true };
2610
- break;
2611
- }
2612
- result = proxyResult.data?.result ?? { content: [{ type: "text", text: "no schedules configured" }] };
2613
- } else {
2614
- result = scheduleStatusResult();
2615
- }
1714
+ result = scheduleStatusResult();
2616
1715
  break;
2617
1716
  }
2618
1717
  case "trigger_schedule": {
2619
- if (proxyMode) {
2620
- const proxyResult = await proxyRequest("/trigger-schedule", "POST", { name: args.name });
2621
- if (!proxyResult.ok) {
2622
- result = { content: [{ type: "text", text: `proxy trigger_schedule failed: ${proxyResult.error}` }], isError: true };
2623
- break;
2624
- }
2625
- const triggerResult = proxyResult.data?.result;
2626
- result = { content: [{ type: "text", text: triggerResult == null ? "" : String(triggerResult) }] };
2627
- } else {
2628
- const triggerResult = await scheduler.triggerManual(args.name);
2629
- result = { content: [{ type: "text", text: triggerResult }] };
2630
- }
1718
+ const triggerResult = await scheduler.triggerManual(args.name);
1719
+ result = { content: [{ type: "text", text: triggerResult }] };
2631
1720
  break;
2632
1721
  }
2633
1722
  case "schedule_control": {
2634
- if (proxyMode) {
2635
- const proxyResult = await proxyRequest("/schedule-control", "POST", {
2636
- name: args.name,
2637
- action: args.action,
2638
- minutes: args.minutes
2639
- });
2640
- if (!proxyResult.ok) {
2641
- result = { content: [{ type: "text", text: `proxy schedule_control failed: ${proxyResult.error}` }], isError: true };
2642
- break;
2643
- }
2644
- result = proxyResult.data?.result ?? { content: [{ type: "text", text: `unknown action: ${args.action}` }], isError: true };
2645
- } else {
2646
- result = scheduleControlResult(args);
2647
- }
1723
+ result = scheduleControlResult(args);
2648
1724
  break;
2649
1725
  }
2650
1726
  case "activate_channel_bridge": {
2651
- if (proxyMode) {
2652
- const proxyRes = await proxyRequest("/bridge/activate", "POST", { active: args.active === true });
2653
- if (!proxyRes.ok) {
2654
- result = { content: [{ type: "text", text: `proxy bridge activate failed: ${proxyRes.error}` }], isError: true };
2655
- } else {
2656
- channelBridgeActive = Boolean(args.active);
2657
- writeBridgeState(channelBridgeActive);
2658
- // Remote owner just deactivated and is tearing its owner-HTTP
2659
- // server down. Drop our proxy pointer so subsequent direct
2660
- // tool calls don't route through proxyRequest() to a port
2661
- // about to close (ECONNREFUSED) or stripped of auth (401).
2662
- if (!args.active) {
2663
- proxyMode = false;
2664
- ownerHttpPort = 0;
2665
- }
2666
- result = { content: [{ type: "text", text: `channel bridge ${args.active ? "activated" : "deactivated"}` }] };
2667
- }
2668
- } else {
2669
- const active = args.active === true;
2670
- const wasActive = channelBridgeActive;
2671
- channelBridgeActive = active;
2672
- writeBridgeState(active);
2673
- if (active && !wasActive) {
2674
- refreshBridgeOwnershipSafe({ restoreBinding: true });
2675
- }
2676
- if (!active && wasActive) {
2677
- stopServerTyping();
2678
- // Tear down the owner-side runtime so Discord/scheduler/webhook/
2679
- // event-pipeline/owner-HTTP/heartbeat don't keep running on a
2680
- // deactivated bridge (and to prevent this owner from later
2681
- // entering proxyMode against its own port).
2682
- try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
2683
- process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
2684
- }
2685
- // Also clear proxyMode/ownerHttpPort. Without this, a session
2686
- // that was acting as proxy when deactivate landed keeps the
2687
- // stale flag + port set; later direct tool calls then route
2688
- // through proxyRequest() to a port whose owner has just been
2689
- // stopped or stripped of auth, returning ECONNREFUSED/401.
2690
- if (proxyMode) {
2691
- proxyMode = false;
2692
- ownerHttpPort = 0;
2693
- }
1727
+ const active = args.active === true;
1728
+ const wasActive = channelBridgeActive;
1729
+ channelBridgeActive = active;
1730
+ writeBridgeState(active);
1731
+ if (active && !wasActive) {
1732
+ refreshBridgeOwnershipSafe({ restoreBinding: true });
1733
+ }
1734
+ if (!active && wasActive) {
1735
+ stopServerTyping();
1736
+ // Tear down the owner-side runtime so Discord/scheduler/webhook/
1737
+ // event-pipeline don't keep running on a deactivated bridge.
1738
+ try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
1739
+ process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
2694
1740
  }
2695
- result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
2696
1741
  }
1742
+ result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
2697
1743
  break;
2698
1744
  }
2699
1745
  case "reload_config": {
@@ -2716,7 +1762,7 @@ async function handleToolCall(name, args, _signal) {
2716
1762
  }
2717
1763
  case "inject_command": {
2718
1764
  const cmd = String(args?.command || "").trim();
2719
- const ALLOW = new Set(["reload-plugins", "clear"]);
1765
+ const ALLOW = new Set(["clear"]);
2720
1766
  if (!ALLOW.has(cmd)) {
2721
1767
  result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
2722
1768
  break;
@@ -2772,36 +1818,21 @@ async function handleToolCallWithBridgeRetry(toolName, args, signal) {
2772
1818
  _lastForwardMs = now;
2773
1819
  await forwarder.forwardNewText();
2774
1820
  }
2775
- if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected && !proxyMode) {
2776
- if (_isCliOwnedMode) {
2777
- await startCliOwnedRuntime({ restoreBinding: true });
2778
- if (!bridgeRuntimeConnected) {
2779
- return {
2780
- content: [{ type: "text", text: `Channel runtime is not connected. Check token and network.` }],
2781
- isError: true
2782
- };
2783
- }
2784
- } else {
2785
- // Do NOT pre-claim ownership here. claimBridgeOwnership() overwrites the
2786
- // active-instance advert immediately, which kicks a live owner offline if
2787
- // refreshBridgeOwnership() would have otherwise discovered them via
2788
- // pingOwner() and entered proxyMode. Let refreshBridgeOwnership() below
2789
- // ping/proxy the existing owner first and only fall through to a takeover
2790
- // when the live owner is unreachable.
2791
- for (let i = 0; i < 2 && !bridgeRuntimeConnected && !proxyMode; i++) {
1821
+ if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected) {
1822
+ // Remote-owner startup: ensure this owner's backend is connected.
1823
+ for (let i = 0; i < 2 && !bridgeRuntimeConnected; i++) {
2792
1824
  try {
2793
1825
  await refreshBridgeOwnership();
2794
1826
  } catch {
2795
1827
  }
2796
- if (!bridgeRuntimeConnected && !proxyMode) await new Promise((r) => setTimeout(r, 300));
1828
+ if (!bridgeRuntimeConnected) await new Promise((r) => setTimeout(r, 300));
2797
1829
  }
2798
- if (!bridgeRuntimeConnected && !proxyMode) {
1830
+ if (!bridgeRuntimeConnected) {
2799
1831
  return {
2800
1832
  content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
2801
1833
  isError: true
2802
1834
  };
2803
1835
  }
2804
- }
2805
1836
  }
2806
1837
  const result = await handleToolCall(toolName, args, signal);
2807
1838
  const toolLine = OutputForwarder.buildToolLine(toolName, args);
@@ -2937,14 +1968,42 @@ backend.onMessage = (msg) => {
2937
1968
  }).finally(() => forwarder.reset());
2938
1969
  const previousPath = getPersistedTranscriptPath();
2939
1970
  let boundTranscript = null;
1971
+ let stoleSelfTranscript = false;
2940
1972
  let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
1973
+ // Reuse the current binding only while it still points at THIS owner's own
1974
+ // session. discoverSessionBoundTranscript() now ranks the live parent-chain
1975
+ // session (the one that forked this worker and receives injected input)
1976
+ // above a more-recently-touched neighbour, so when a co-located session
1977
+ // owns the stale binding we steal it back here instead of tailing the wrong
1978
+ // transcript for the rest of the process lifetime. Steal whenever the live
1979
+ // parent-chain (self) candidate resolves to a different path — even when its
1980
+ // transcript is not on disk yet: we keep selfBound.exists=false so the
1981
+ // downstream `!boundTranscript?.exists` branch routes through
1982
+ // rebindTranscriptContext()'s pending-bind + re-arm poll, which forwards the
1983
+ // first assistant reply once the self transcript is created. Marking the
1984
+ // stale neighbour path as exists=true here would suppress that rearm and
1985
+ // keep tailing the wrong session for the whole turn.
2941
1986
  if (transcriptPath) {
2942
- boundTranscript = {
2943
- sessionId: sessionIdFromTranscriptPath(transcriptPath),
2944
- sessionCwd: statusState.read().sessionCwd ?? null,
2945
- transcriptPath,
2946
- exists: true
2947
- };
1987
+ const selfBound = discoverSessionBoundTranscript();
1988
+ const shouldStealBoundTranscript = Boolean(
1989
+ selfBound?.transcriptPath &&
1990
+ !sameResolvedPath(selfBound.transcriptPath, transcriptPath) &&
1991
+ selfBound.active === true &&
1992
+ (selfBound.parentChain === true || selfBound.cwdMatches === true)
1993
+ );
1994
+ if (shouldStealBoundTranscript) {
1995
+ process.stderr.write(`mixdog: inbound rebind: stealing transcript ${transcriptPath} -> ${selfBound.transcriptPath} (source=${selfBound.source || "unknown"}, exists=${selfBound.exists})\n`);
1996
+ transcriptPath = selfBound.transcriptPath;
1997
+ boundTranscript = selfBound;
1998
+ stoleSelfTranscript = true;
1999
+ } else {
2000
+ boundTranscript = {
2001
+ sessionId: sessionIdFromTranscriptPath(transcriptPath),
2002
+ sessionCwd: statusState.read().sessionCwd ?? null,
2003
+ transcriptPath,
2004
+ exists: true
2005
+ };
2006
+ }
2948
2007
  } else {
2949
2008
  boundTranscript = discoverSessionBoundTranscript();
2950
2009
  transcriptPath = pickUsableTranscriptPath(boundTranscript, previousPath);
@@ -2983,8 +2042,17 @@ backend.onMessage = (msg) => {
2983
2042
  });
2984
2043
  if (!boundTranscript?.exists) {
2985
2044
  await rebindTranscriptContext(route.targetChatId, {
2045
+ // For a stolen self transcript (not yet on disk) the sync bind above
2046
+ // persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
2047
+ // setContext resume from offset 0 once the file appears — forwarding
2048
+ // the first assistant reply. Relying on replayFromStart instead would
2049
+ // race: the discovery loop only sets replayFromStart when it first saw
2050
+ // the transcript as PENDING, so a file that already exists on the first
2051
+ // loop iteration would bind at EOF and skip the reply. Non-steal keeps
2052
+ // the original catch-up-from-cursor behaviour.
2986
2053
  previousPath: transcriptPath,
2987
2054
  catchUp: true,
2055
+ catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
2988
2056
  persistStatus: true
2989
2057
  });
2990
2058
  }
@@ -3011,24 +2079,29 @@ async function handleInbound(msg, route, options = {}) {
3011
2079
  let text = msg.text;
3012
2080
  const voiceAtts = msg.attachments.filter((a) => isVoiceAttachment(a.contentType));
3013
2081
  if (voiceAtts.length > 0) {
3014
- try {
3015
- const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
3016
- // concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
3017
- for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
3018
- const _t0 = Date.now();
3019
- const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
3020
- const _elapsed = Date.now() - _t0;
3021
- if (transcript) {
3022
- text = transcript;
3023
- process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
3024
- } else {
3025
- process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
3026
- text = text || "[voice message \u2014 transcription failed]";
2082
+ if (config.voice?.enabled === false) {
2083
+ process.stderr.write(`mixdog: voice.transcription skipped voice.enabled=false\n`);
2084
+ text = text || "[voice message]";
2085
+ } else {
2086
+ try {
2087
+ const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
2088
+ // concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
2089
+ for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
2090
+ const _t0 = Date.now();
2091
+ const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
2092
+ const _elapsed = Date.now() - _t0;
2093
+ if (transcript) {
2094
+ text = transcript;
2095
+ process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
2096
+ } else {
2097
+ process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
2098
+ text = text || "[voice message \u2014 transcription failed]";
2099
+ }
3027
2100
  }
2101
+ } catch (err) {
2102
+ process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
2103
+ text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
3028
2104
  }
3029
- } catch (err) {
3030
- process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
3031
- text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
3032
2105
  }
3033
2106
  }
3034
2107
  const hasVoiceAtt = voiceAtts.length > 0;
@@ -3037,7 +2110,6 @@ async function handleInbound(msg, route, options = {}) {
3037
2110
  attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
3038
2111
  } : {};
3039
2112
  const messageBody = route.sourceMode === "monitor" && route.sourceLabel ? `[monitor:${route.sourceLabel}] ${text}` : text;
3040
- const now = (/* @__PURE__ */ new Date()).toLocaleString();
3041
2113
  const notificationMeta = {
3042
2114
  chat_id: route.targetChatId,
3043
2115
  message_id: msg.messageId,
@@ -3052,8 +2124,7 @@ async function handleInbound(msg, route, options = {}) {
3052
2124
  ...attMeta,
3053
2125
  ...msg.imagePath ? { image_path: msg.imagePath } : {}
3054
2126
  };
3055
- const notificationContent = `[${now}]
3056
- ${messageBody}`;
2127
+ const notificationContent = messageBody;
3057
2128
  sendNotifyToParent("notifications/claude/channel", {
3058
2129
  content: notificationContent,
3059
2130
  meta: notificationMeta
@@ -3085,19 +2156,47 @@ async function init(_sharedMcp) {
3085
2156
  });
3086
2157
  }
3087
2158
  async function start() {
3088
- startChannelDaemonIdleMonitor();
3089
2159
  channelBridgeActive = true;
3090
2160
  writeBridgeState(true);
3091
- if (_isCliOwnedMode) {
3092
- await startCliOwnedRuntime({ restoreBinding: true });
3093
- } else {
3094
- await refreshBridgeOwnership({ restoreBinding: true });
2161
+ // Opt-in remote, single-owner, last-wins. Claim the seat immediately so a
2162
+ // later `mixdog --remote` session overwrites us and we drop on our next
2163
+ // refresh tick. Then connect the owned runtime and arm the ownership timer
2164
+ // that keeps checking whether a newer session has taken over.
2165
+ claimBridgeOwnership("remote start");
2166
+ const _bindingReadyStart = Date.now();
2167
+ try {
2168
+ await refreshBridgeOwnership({ restoreBinding: true });
2169
+ bindingReadyStatus = "resolved";
2170
+ dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
2171
+ _bindingReadyResolve(true);
2172
+ } catch (e) {
2173
+ bindingReadyStatus = "rejected";
2174
+ dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
2175
+ _bindingReadyResolve(e);
2176
+ }
2177
+ // Ownership timer: keep checking whether a newer remote session has taken
2178
+ // over (last-wins) so a superseded owner disconnects promptly.
2179
+ if (!bridgeOwnershipTimer) {
2180
+ bridgeOwnershipTimer = setInterval(() => {
2181
+ refreshBridgeOwnershipSafe();
2182
+ }, 3e3);
2183
+ bridgeOwnershipTimer.unref?.();
2184
+ }
2185
+ // Hot-reload config on file change (schedules/webhooks/events).
2186
+ if (!_configWatcher) {
2187
+ try {
2188
+ _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
2189
+ if (_reloadDebounce) clearTimeout(_reloadDebounce);
2190
+ _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
2191
+ });
2192
+ } catch {}
3095
2193
  }
3096
2194
  // Pre-warm the whisper-server manager once at owner startup so the first
3097
2195
  // voice transcription does not pay cold-start cost. Non-blocking: failures
3098
2196
  // (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
3099
2197
  void (async () => {
3100
2198
  try {
2199
+ if (config.voice?.enabled === false) return;
3101
2200
  const runtime = resolveVoiceRuntime(DATA_DIR);
3102
2201
  if (!runtime?.installed) return;
3103
2202
  const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
@@ -3109,7 +2208,6 @@ async function start() {
3109
2208
  })();
3110
2209
  }
3111
2210
  async function stop() {
3112
- stopChannelDaemonIdleMonitor();
3113
2211
  try { await stopVoiceWhisperServer(); } catch {}
3114
2212
  await stopOwnedRuntime("unified server stop");
3115
2213
  cleanupInstanceRuntimeFiles(INSTANCE_ID);
@@ -3117,113 +2215,13 @@ async function stop() {
3117
2215
  clearInterval(bridgeOwnershipTimer);
3118
2216
  bridgeOwnershipTimer = null;
3119
2217
  }
2218
+ if (_reloadDebounce) { clearTimeout(_reloadDebounce); _reloadDebounce = null; }
2219
+ if (_configWatcher) { try { _configWatcher.close(); } catch {} _configWatcher = null; }
3120
2220
  if (turnEndWatcher) {
3121
2221
  try { turnEndWatcher.close(); } catch {}
3122
2222
  turnEndWatcher = null;
3123
2223
  }
3124
2224
  }
3125
- if (process.env.MIXDOG_CHANNELS_AUTO_BOOT !== '0') {
3126
- let detectChannelFlag = function() {
3127
- const isWin = process.platform === "win32";
3128
- const flagRe = /--channels\b|--dangerously-load-development-channels\b/;
3129
- if (process.env.MIXDOG_CHANNEL_FLAG === "1") return true;
3130
- if (process.env.MIXDOG_CHANNEL_FLAG === "0") return false;
3131
- if (isWin) {
3132
- // Single CIM snapshot + in-process chain walk: one powershell.exe spawn
3133
- // instead of up to 12 synchronous wmic/powershell spawns. Snapshots all
3134
- // processes into a map, walks from process.ppid up to 6 ancestors
3135
- // (closest first), and emits each ancestor CommandLine on its own line
3136
- // for the same flagRe test below. Any failure returns false.
3137
- try {
3138
- const ps = [
3139
- '$procs = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine;',
3140
- '$map = @{};',
3141
- 'foreach ($p in $procs) { $map[[int]$p.ProcessId] = $p }',
3142
- `$cur = ${Number(process.ppid)};`,
3143
- 'for ($i = 0; $i -lt 6; $i++) {',
3144
- ' if (-not $cur -or $cur -le 1) { break }',
3145
- ' $p = $map[[int]$cur]; if ($null -eq $p) { break }',
3146
- ' [Console]::WriteLine($p.CommandLine);',
3147
- ' $next = [int]$p.ParentProcessId;',
3148
- ' if ($next -eq [int]$cur -or $next -le 1) { break }',
3149
- ' $cur = $next',
3150
- '}',
3151
- ].join(" ");
3152
- const r = spawnSync("powershell.exe", ["-NoProfile", "-Command", ps], {
3153
- encoding: "utf8",
3154
- timeout: 5e3,
3155
- windowsHide: true,
3156
- });
3157
- const out = String(r.stdout || "");
3158
- for (const line of out.split(/\r?\n/)) {
3159
- if (flagRe.test(line)) return true;
3160
- }
3161
- } catch {}
3162
- return false;
3163
- }
3164
- let pid = process.ppid;
3165
- for (let depth = 0; pid && pid > 1 && depth < 6; depth++) {
3166
- try {
3167
- const cmdLine = execSync(`ps -p ${pid} -o args=`, { encoding: "utf8", timeout: 3e3, windowsHide: true });
3168
- if (flagRe.test(cmdLine)) return true;
3169
- pid = parseInt(execSync(`ps -p ${pid} -o ppid=`, { encoding: "utf8", timeout: 3e3, windowsHide: true }).trim(), 10);
3170
- } catch {
3171
- break;
3172
- }
3173
- }
3174
- return false;
3175
- };
3176
- _channelFlagDetected = detectChannelFlag();
3177
- if (isMixdogDebug()) {
3178
- fs.appendFileSync(_bootLog, `[${localTimestamp()}] channelFlag: ${_channelFlagDetected}\n`);
3179
- if (_channelFlagDetected) {
3180
- fs.appendFileSync(_bootLog, `[${localTimestamp()}] channel mode detected — bridge auto-activated\n`);
3181
- }
3182
- }
3183
- if (_channelFlagDetected) {
3184
- channelBridgeActive = true;
3185
- }
3186
- writeBridgeState(channelBridgeActive);
3187
- const previousOwner = readActiveInstance();
3188
- noteStartupHandoff(previousOwner);
3189
- // Do not claim ownership just because this terminal is channel-capable.
3190
- // refreshBridgeOwnership() below pings/proxies a live owner first and only
3191
- // claims when there is no reachable active owner or the record is stale.
3192
- const _bindingReadyStart = Date.now();
3193
- void refreshBridgeOwnership({ restoreBinding: true }).then(
3194
- (v) => {
3195
- bindingReadyStatus = "resolved";
3196
- dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
3197
- _bindingReadyResolve(v);
3198
- },
3199
- (e) => {
3200
- bindingReadyStatus = "rejected";
3201
- dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
3202
- _bindingReadyResolve(e);
3203
- }
3204
- );
3205
- bridgeOwnershipTimer = setInterval(() => {
3206
- refreshBridgeOwnershipSafe();
3207
- }, 3e3);
3208
- // Hook/statusline IPC is owned by the MCP parent process so it is available
3209
- // before channels finishes bridge ownership and backend startup.
3210
- const configPath = path.join(DATA_DIR, "mixdog-config.json");
3211
- let reloadDebounce = null;
3212
- let configWatcher = null;
3213
- try {
3214
- configWatcher = fs.watch(configPath, () => {
3215
- if (reloadDebounce) clearTimeout(reloadDebounce);
3216
- reloadDebounce = setTimeout(() => {
3217
- reloadRuntimeConfig().catch(() => {});
3218
- }, 500);
3219
- });
3220
- } catch {
3221
- }
3222
- process.on("exit", () => {
3223
- if (configWatcher) { try { configWatcher.close(); } catch {} }
3224
- if (bridgeOwnershipTimer) { clearInterval(bridgeOwnershipTimer); }
3225
- });
3226
- }
3227
2225
  // ── IPC worker mode ──────────────────────────────────────────────
3228
2226
  if (_isWorkerMode && process.send) {
3229
2227
  // SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
@@ -3385,11 +2383,11 @@ if (_isWorkerMode && process.send) {
3385
2383
  try {
3386
2384
  await start()
3387
2385
  bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1) })
3388
- process.send({ type: 'ready', channelFlag: _channelFlagDetected })
2386
+ process.send({ type: 'ready' })
3389
2387
  } catch (e) {
3390
2388
  bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e?.message || String(e) })
3391
2389
  process.stderr.write(`[channels-worker] start() failed: ${e && (e.message || e)}\n`)
3392
- process.send({ type: 'ready', channelFlag: _channelFlagDetected, degraded: true, error: e?.message || String(e) })
2390
+ process.send({ type: 'ready', degraded: true, error: e?.message || String(e) })
3393
2391
  }
3394
2392
  })()
3395
2393
  }