mixdog 0.9.0 → 0.9.2

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 (240) hide show
  1. package/package.json +10 -3
  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/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -4,8 +4,7 @@ import {
4
4
  ListToolsRequestSchema,
5
5
  CallToolRequestSchema
6
6
  } from "@modelcontextprotocol/sdk/types.js";
7
- import { spawn, execSync, spawnSync } from "child_process";
8
- import * as crypto from "crypto";
7
+ import { spawn } from "child_process";
9
8
  import * as fs from "fs";
10
9
  import * as http from "http";
11
10
  import * as os from "os";
@@ -33,7 +32,8 @@ import { startCliWorker } from "./lib/cli-worker-host.mjs";
33
32
  import {
34
33
  OutputForwarder,
35
34
  discoverSessionBoundTranscript,
36
- findLatestTranscriptByMtime
35
+ findLatestTranscriptByMtime,
36
+ sameResolvedPath
37
37
  } from "./lib/output-forwarder.mjs";
38
38
  import { controlClaudeSession } from "./lib/session-control.mjs";
39
39
  import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./lib/state-file.mjs";
@@ -163,15 +163,58 @@ ${err instanceof Error ? err.stack : ""}
163
163
  }
164
164
  crashLogging = false;
165
165
  }
166
- process.on("unhandledRejection", (err) => logCrash("unhandled rejection", err));
167
- process.on("uncaughtException", (err) => logCrash("uncaught exception", err));
166
+ // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
167
+ // after an unhandled rejection whose async state was already corrupted
168
+ // (observed: EPERM on active-instance.json rename retry), so it spun
169
+ // forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
170
+ // Benign whitelist: transient EPERM/EACCES/EBUSY on the active-instance
171
+ // rename path is expected under Windows file-lock contention and is
172
+ // already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
173
+ // occurrence must NOT be fatal, only a run of 3+ in a row without an
174
+ // intervening distinct/successful event.
175
+ const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
176
+ const BENIGN_CRASH_FATAL_THRESHOLD = 3;
177
+ // "In a row" needs a time dimension: benign errors minutes/hours apart are
178
+ // independent contention events, not a corrupted-state run. Only count a
179
+ // streak when hits land within this window of the previous one.
180
+ const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
181
+ let _benignCrashStreak = 0;
182
+ let _lastBenignCrashAt = 0;
183
+ function _isBenignCrash(err) {
184
+ const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
185
+ return BENIGN_CRASH_CODES.has(code);
186
+ }
187
+ function _fatalCrash(label, err) {
188
+ logCrash(label, err);
189
+ const benign = _isBenignCrash(err);
190
+ if (benign) {
191
+ const now = Date.now();
192
+ _benignCrashStreak = (now - _lastBenignCrashAt) <= BENIGN_CRASH_STREAK_WINDOW_MS
193
+ ? _benignCrashStreak + 1
194
+ : 1;
195
+ _lastBenignCrashAt = now;
196
+ if (_benignCrashStreak < BENIGN_CRASH_FATAL_THRESHOLD) return;
197
+ } else {
198
+ _benignCrashStreak = 0;
199
+ }
200
+ Promise.resolve()
201
+ .then(() => (typeof stop === "function" ? stop(`fatal:${label}`) : null))
202
+ .catch(() => {})
203
+ .finally(() => {
204
+ try { process.exitCode = 1; } catch {}
205
+ process.exit(1);
206
+ });
207
+ // Best-effort stop() may itself hang (e.g. IPC to a dead child) — a bare
208
+ // .finally() would then never fire and we're back to a zombie. Force the
209
+ // exit unconditionally after a short grace window regardless of outcome.
210
+ setTimeout(() => { try { process.exit(1); } catch {} }, 3000).unref?.();
211
+ }
212
+ process.on("unhandledRejection", (err) => _fatalCrash("unhandled rejection", err));
213
+ process.on("uncaughtException", (err) => _fatalCrash("uncaught exception", err));
168
214
  if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
169
215
  process.exit(0);
170
216
  }
171
217
  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
218
  const _bootLogEarly = path.join(
176
219
  DATA_DIR || path.join(os.tmpdir(), "mixdog"),
177
220
  "boot.log"
@@ -329,10 +372,6 @@ const INSTRUCTIONS = "";
329
372
  // never `connect()`ed to any transport, so `.notification()` silently
330
373
  // threw 'Not connected' inside the SDK and every call was dropped by an
331
374
  // 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
375
  function normalizeChannelNotifyParams(method, params) {
337
376
  if (method === 'notifications/claude/channel' && params && params.meta) {
338
377
  const m = {};
@@ -345,29 +384,6 @@ function normalizeChannelNotifyParams(method, params) {
345
384
  return params;
346
385
  }
347
386
 
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
387
  function sendNotifyToParent(method, params) {
372
388
  // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
373
389
  // Coerce every meta value to string so a non-string (e.g. a Discord
@@ -375,9 +391,8 @@ function sendNotifyToParent(method, params) {
375
391
  // silent_to_agent stays boolean — an internal routing flag the daemon
376
392
  // router / agentNotify consume (=== true) before the CC zod boundary.
377
393
  const outParams = normalizeChannelNotifyParams(method, params);
378
- publishChannelNotify(method, outParams);
379
394
  if (!process.send) {
380
- try { process.stderr.write(`mixdog channels: notify queued on bus (no IPC): ${method}\n`); } catch {}
395
+ try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
381
396
  return;
382
397
  }
383
398
  try {
@@ -387,16 +402,6 @@ function sendNotifyToParent(method, params) {
387
402
  }
388
403
  }
389
404
 
390
- const recapState = { state: 'idle', running: false, startedAt: null, lastCompletedAt: null, updatedAt: null, errorMessage: null };
391
- function sendRecapStateToParent() {
392
- if (!process.send) return;
393
- try {
394
- process.send({ type: 'recap_status', recap: { ...recapState } });
395
- } catch (err) {
396
- try { process.stderr.write(`mixdog channels: recap status IPC send failed: ${err && err.message || err}\n`); } catch {}
397
- }
398
- }
399
-
400
405
  // ── Memory worker bridge (worker → parent → memory) ─────────────────
401
406
  // The channels worker does not own the memory worker handle. To trigger
402
407
  // memory tool actions (e.g. cycle1) we send `memory_call_request` to the
@@ -464,6 +469,9 @@ const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
464
469
  const TURN_END_BASENAME = path.basename(TURN_END_FILE);
465
470
  const TURN_END_DIR = path.dirname(TURN_END_FILE);
466
471
  let turnEndWatcher = null;
472
+ // Config hot-reload watcher (installed by start(); torn down by stop()).
473
+ let _configWatcher = null;
474
+ let _reloadDebounce = null;
467
475
  if (!_isWorkerMode) {
468
476
  removeFileIfExists(TURN_END_FILE);
469
477
  turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
@@ -498,12 +506,13 @@ function pickUsableTranscriptPath(bound, previousPath) {
498
506
  return sessionIdFromTranscriptPath(previousPath) === bound.sessionId ? previousPath : "";
499
507
  }
500
508
  const forwarder = new OutputForwarder({
501
- send: async (ch, text) => {
509
+ send: async (ch, text, opts) => {
502
510
  if (!channelBridgeActive) {
503
511
  throw new Error("send() called while channel bridge is inactive");
504
512
  }
505
- await backend.sendMessage(ch, text);
513
+ await backend.sendMessage(ch, text, opts);
506
514
  },
515
+ formatOutgoing: (text) => backend.formatOutgoing ? backend.formatOutgoing(text) : text,
507
516
  recordAssistantTurn: async () => {
508
517
  },
509
518
  react: (ch, mid, emoji) => {
@@ -523,9 +532,9 @@ forwarder.setOnIdle(() => {
523
532
  // also sets this, but that path only runs when the event pipeline starts
524
533
  // (webhook enabled or event rules present). Without an event pipeline the
525
534
  // forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
526
- // non-owner / proxy process forward transcript output (duplicate Discord
527
- // sends). The closure reads bridgeRuntimeConnected/proxyMode at call time.
528
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
535
+ // non-owner process forward transcript output (duplicate Discord sends).
536
+ // The closure reads bridgeRuntimeConnected at call time.
537
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
529
538
  function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
530
539
  if (!transcriptPath) return;
531
540
  forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
@@ -546,9 +555,68 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
546
555
  });
547
556
  }
548
557
  }
558
+ // ── Pending-transcript re-arm ────────────────────────────────────────
559
+ // fs.watch cannot watch a file that does not exist yet, so when we bind a
560
+ // session's known-but-not-yet-written transcript path, OutputForwarder.
561
+ // startWatch() silently fails (watch.start.catch) and the file's later
562
+ // creation is never observed. This bounded poll bridges that gap: once the
563
+ // transcript-writer creates the file, we install the watch and forward the
564
+ // backlog. It self-cancels on success, on timeout, and whenever a fresh
565
+ // (re)bind supersedes it — so no timers leak and no double-forward occurs.
566
+ let _pendingRearmTimer = null;
567
+ const PENDING_REARM_INTERVAL_MS = 250;
568
+ const PENDING_REARM_MAX_MS = 60_000;
569
+ function cancelPendingTranscriptRearm() {
570
+ if (_pendingRearmTimer) {
571
+ clearTimeout(_pendingRearmTimer);
572
+ _pendingRearmTimer = null;
573
+ dropTrace("rebind.rearm.cancel");
574
+ }
575
+ }
576
+ function schedulePendingTranscriptRearm(channelId, boundPath) {
577
+ cancelPendingTranscriptRearm();
578
+ if (!boundPath) return;
579
+ const deadline = Date.now() + PENDING_REARM_MAX_MS;
580
+ dropTrace("rebind.rearm.schedule", { channelId, path: boundPath });
581
+ const tick = () => {
582
+ _pendingRearmTimer = null;
583
+ // A different transcript got bound in the meantime — abandon this poll.
584
+ if (forwarder.transcriptPath !== boundPath) {
585
+ dropTrace("rebind.rearm.superseded", { path: boundPath, now: forwarder.transcriptPath || "(none)" });
586
+ return;
587
+ }
588
+ // Ownership may have been lost (bridge deactivated / superseded owner)
589
+ // while this poll was pending. Do not reinstall the fs.watch handle after
590
+ // teardown; startWatch() is not owner-gated so guard it here.
591
+ if (!bridgeRuntimeConnected) {
592
+ dropTrace("rebind.rearm.not-owner", { path: boundPath });
593
+ return;
594
+ }
595
+ if (fs.existsSync(boundPath)) {
596
+ dropTrace("rebind.rearm.fire", { channelId, path: boundPath });
597
+ forwarder.startWatch();
598
+ void forwarder.forwardNewText().catch((err) => {
599
+ try { process.stderr.write(`mixdog: rearm forwardNewText rejection: ${err?.stack || err}\n`); } catch {}
600
+ });
601
+ return;
602
+ }
603
+ if (Date.now() >= deadline) {
604
+ dropTrace("rebind.rearm.timeout", { channelId, path: boundPath });
605
+ return;
606
+ }
607
+ _pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
608
+ _pendingRearmTimer?.unref?.();
609
+ };
610
+ _pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
611
+ _pendingRearmTimer?.unref?.();
612
+ }
549
613
  async function rebindTranscriptContext(channelId, options = {}) {
550
614
  const previousPath = options.previousPath ?? "";
551
615
  const mode = options.mode ?? "same";
616
+ // A new (re)bind supersedes any pending re-arm poll left over from a prior
617
+ // bind of a not-yet-existing transcript, so we never leak timers or
618
+ // double-forward once the fresh bind takes over.
619
+ cancelPendingTranscriptRearm();
552
620
  const explicitTranscriptPath = typeof options.transcriptPath === "string" ? options.transcriptPath.trim() : "";
553
621
  if (explicitTranscriptPath) {
554
622
  let explicitExists = false;
@@ -571,7 +639,17 @@ async function rebindTranscriptContext(channelId, options = {}) {
571
639
  }
572
640
  let sawPendingTranscript = false;
573
641
  let pendingSessionId = "";
574
- for (let attempt = 0; attempt < 30; attempt += 1) {
642
+ // Distinct from sawPendingTranscript/pendingSessionId (which only track the
643
+ // sessionId): remember the FULL not-yet-on-disk candidate — its concrete
644
+ // transcriptPath (from the session record) + cwd — so we can bind it after
645
+ // the loop even though the `.jsonl` does not exist yet. This breaks the
646
+ // chicken-and-egg deadlock where the first assistant reply is only written
647
+ // seconds after inbound time.
648
+ let pendingTranscriptPath = "";
649
+ let pendingTranscriptCwd = null;
650
+ const maxAttempts = Number.isFinite(options.maxAttempts) ? Math.max(1, Math.floor(options.maxAttempts)) : 30;
651
+ const pollMs = Number.isFinite(options.pollMs) ? Math.max(25, Math.floor(options.pollMs)) : 150;
652
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
575
653
  const bound = discoverSessionBoundTranscript();
576
654
  if (bound?.exists) {
577
655
  const acceptable = mode === "same" || !previousPath || bound.transcriptPath !== previousPath;
@@ -593,12 +671,63 @@ async function rebindTranscriptContext(channelId, options = {}) {
593
671
  } else if (bound?.sessionId) {
594
672
  sawPendingTranscript = true;
595
673
  pendingSessionId = bound.sessionId;
674
+ if (bound.transcriptPath) {
675
+ pendingTranscriptPath = bound.transcriptPath;
676
+ pendingTranscriptCwd = bound.sessionCwd ?? null;
677
+ }
678
+ }
679
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
680
+ }
681
+ // No existing transcript surfaced during the loop, but the session record
682
+ // named a concrete transcript path that simply is not on disk yet. Bind it
683
+ // now: applyTranscriptBinding persists state.transcriptPath (so the
684
+ // getPersistedTranscriptPath() fallback works for future rebinds) and calls
685
+ // forwarder.startWatch(). Because fs.watch cannot watch a missing file, we
686
+ // also schedule a bounded re-arm poll that installs the watch + catches up
687
+ // once the transcript-writer creates the file.
688
+ if (pendingTranscriptPath) {
689
+ // Same wrong-session guard as the exists:true path: refuse to bind a
690
+ // candidate that conflicts with an explicit previousPath when this is a
691
+ // switch (mode!=="same").
692
+ const acceptable = mode === "same" || !previousPath || pendingTranscriptPath !== previousPath;
693
+ if (acceptable) {
694
+ dropTrace("rebind.pending.bind", { channelId, path: pendingTranscriptPath, sessionId: pendingSessionId });
695
+ // If the persisted cursor belongs to THIS transcript, resume from it;
696
+ // otherwise this is a freshly-discovered session transcript that was
697
+ // never bound before (the chicken-and-egg case), so forward from the
698
+ // start of file. Binding with catchUpFromPersisted against a
699
+ // non-matching persisted path would set the read cursor to EOF and
700
+ // silently skip the first reply (output-forwarder setContext).
701
+ // Resume from the persisted cursor only when it belongs to THIS
702
+ // transcript; otherwise forward from the start of file (see comment
703
+ // above). sameResolvedPath handles Windows case-insensitive paths.
704
+ const samePersisted = sameResolvedPath(getPersistedTranscriptPath(), pendingTranscriptPath);
705
+ applyTranscriptBinding(channelId, pendingTranscriptPath, {
706
+ replayFromStart: !samePersisted,
707
+ catchUpFromPersisted: samePersisted,
708
+ cwd: pendingTranscriptCwd,
709
+ persistStatus: options.persistStatus,
710
+ });
711
+ const boundPath = forwarder.transcriptPath || pendingTranscriptPath;
712
+ if (fs.existsSync(boundPath)) {
713
+ // Raced: file appeared between discovery and bind — forward immediately.
714
+ await forwarder.forwardNewText();
715
+ } else {
716
+ schedulePendingTranscriptRearm(channelId, boundPath);
717
+ }
718
+ process.stderr.write(`mixdog: rebind pending: bound not-yet-existing transcript ${boundPath}\n`);
719
+ return pendingTranscriptPath;
596
720
  }
597
- await new Promise((resolve) => setTimeout(resolve, 150));
598
721
  }
599
- if (previousPath) {
722
+ if (previousPath && options.fallbackPrevious !== false) {
600
723
  applyTranscriptBinding(channelId, previousPath, { catchUpFromPersisted: true, cwd: statusState.read().sessionCwd });
601
- await forwarder.forwardNewText();
724
+ if (fs.existsSync(previousPath)) {
725
+ await forwarder.forwardNewText();
726
+ } else {
727
+ // Same not-yet-on-disk situation as the pending branch: arm a poll so
728
+ // forwarding starts when the file is created.
729
+ schedulePendingTranscriptRearm(channelId, forwarder.transcriptPath || previousPath);
730
+ }
602
731
  process.stderr.write(`mixdog: rebind fallback: bound previous transcript ${previousPath}\n`);
603
732
  return previousPath;
604
733
  }
@@ -646,708 +775,15 @@ const ACTIVE_OWNER_STALE_MS = 1e4;
646
775
  // never blocks process exit. Single JSON atomic write, no measurable load.
647
776
  const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
648
777
  let ownerHeartbeatTimer = null;
649
- let channelDaemonIdleTimer = null;
650
- let channelDaemonLastClientAt = Date.now();
651
- let channelDaemonBackgroundLogAt = 0;
652
- const CHANNEL_CLIENT_DIR = path.join(RUNTIME_ROOT, 'channel-clients');
653
-
654
- function pidAlive(pid) {
655
- const n = Number(pid);
656
- if (!Number.isInteger(n) || n <= 0) return false;
657
- try {
658
- process.kill(n, 0);
659
- return true;
660
- } catch (e) {
661
- return e?.code === 'EPERM';
662
- }
663
- }
664
-
665
- function countLiveChannelClients() {
666
- let live = 0;
667
- const now = Date.now();
668
- try {
669
- fs.mkdirSync(CHANNEL_CLIENT_DIR, { recursive: true });
670
- for (const file of fs.readdirSync(CHANNEL_CLIENT_DIR)) {
671
- if (!file.endsWith('.json')) continue;
672
- const full = path.join(CHANNEL_CLIENT_DIR, file);
673
- let item = null;
674
- let st = null;
675
- try {
676
- st = fs.statSync(full);
677
- item = JSON.parse(fs.readFileSync(full, 'utf8'));
678
- } catch {
679
- try { fs.unlinkSync(full); } catch {}
680
- continue;
681
- }
682
- const pid = Number(item?.pid ?? file.replace(/\.json$/, ''));
683
- const fresh = now - Math.max(Number(item?.updatedAt) || 0, st.mtimeMs) < 20_000;
684
- if (pidAlive(pid) && fresh) {
685
- live += 1;
686
- } else {
687
- try { fs.unlinkSync(full); } catch {}
688
- }
689
- }
690
- } catch {}
691
- return live;
692
- }
693
-
694
- function hasChannelBackgroundWork() {
695
- if (!channelBridgeActive || !bridgeRuntimeConnected) return false;
696
- if (config.webhook?.enabled === true) return true;
697
- if (Array.isArray(config.events?.rules) && config.events.rules.length > 0) return true;
698
- if (Array.isArray(config.nonInteractive) && config.nonInteractive.length > 0) return true;
699
- if (Array.isArray(config.interactive) && config.interactive.length > 0) return true;
700
- return false;
701
- }
702
-
703
- function checkChannelDaemonIdle() {
704
- if (!_isChannelDaemonMode || CHANNEL_DAEMON_IDLE_TTL_MS <= 0) return;
705
- const liveClients = countLiveChannelClients();
706
- if (liveClients > 0) {
707
- channelDaemonLastClientAt = Date.now();
708
- return;
709
- }
710
- if (hasChannelBackgroundWork()) {
711
- const now = Date.now();
712
- if (now - channelDaemonBackgroundLogAt > 60_000) {
713
- channelDaemonBackgroundLogAt = now;
714
- try { process.stderr.write('[channels-worker] daemon idle: keeping alive for configured background work\n'); } catch {}
715
- }
716
- channelDaemonLastClientAt = Date.now();
717
- return;
718
- }
719
- if (Date.now() - channelDaemonLastClientAt < CHANNEL_DAEMON_IDLE_TTL_MS) return;
720
- try { process.stderr.write(`[channels-worker] daemon idle TTL elapsed (${CHANNEL_DAEMON_IDLE_TTL_MS}ms) — shutting down\n`); } catch {}
721
- stop()
722
- .then(() => process.exit(0))
723
- .catch((e) => {
724
- try { process.stderr.write(`[channels-worker] daemon idle shutdown failed: ${e?.message || e}\n`); } catch {}
725
- process.exit(1);
726
- });
727
- }
728
-
729
- function startChannelDaemonIdleMonitor() {
730
- if (!_isChannelDaemonMode || CHANNEL_DAEMON_IDLE_TTL_MS <= 0 || channelDaemonIdleTimer) return;
731
- channelDaemonLastClientAt = Date.now();
732
- channelDaemonIdleTimer = setInterval(checkChannelDaemonIdle, 5000);
733
- channelDaemonIdleTimer.unref?.();
734
- }
735
-
736
- function stopChannelDaemonIdleMonitor() {
737
- if (!channelDaemonIdleTimer) return;
738
- clearInterval(channelDaemonIdleTimer);
739
- channelDaemonIdleTimer = null;
740
- }
741
778
  // Owner gating here is multi-process runtime coordination: only the active
742
779
  // bindingReady gates all send paths until the boot-time refreshBridgeOwnership
743
780
  // ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
744
781
  // emissions fired within the first ~few hundred ms after restart drop because
745
782
  // the Discord backend binding has not yet been established.
746
783
  let bindingReadyStatus = "pending";
747
- // Channel-flag detection result, stored at module scope so the worker-mode
748
- // ready IPC can forward it to the daemon for caching across respawns.
749
- let _channelFlagDetected = false;
750
784
  let _bindingReadyResolve;
751
785
  const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
752
786
  dropTrace("bindingReady.create", { status: bindingReadyStatus });
753
- // owner runs webhook/event ticks. It is not webhook HTTP authentication.
754
- let proxyMode = false;
755
- let ownerHttpPort = 0;
756
- let ownerHttpServer = null;
757
- const PROXY_PORT_MIN = 3460;
758
- const PROXY_PORT_MAX = 3467;
759
- // Per-owner-process auth secret. Generated once at HTTP server start and
760
- // published into runtime/owner-secret-<instanceId>.json with 0o600 perms so
761
- // only the owner UID can read it back. requireOwnerToken below checks THIS
762
- // secret (not the public-by-/ping instanceId) so any local caller that
763
- // scrapes /ping cannot forge owner-side calls. The file is keyed on the
764
- // owner's INSTANCE_ID — the SAME identifier published into active-instance
765
- // as `instanceId` and validated by requireOwnerToken's x-owner-instance
766
- // header check — so proxy readers can resolve the path off readActiveInstance()
767
- // without depending on getActiveOwnerPid(), which prefers ownerLeadPid/
768
- // terminalLeadPid/supervisor_pid and would diverge from process.pid in
769
- // supervisor-backed sessions.
770
- let OWNER_SECRET = "";
771
- function getOwnerSecretPath(instanceId) {
772
- return path.join(RUNTIME_ROOT, `owner-secret-${String(instanceId)}.json`);
773
- }
774
- function publishOwnerSecret(secret) {
775
- const file = getOwnerSecretPath(INSTANCE_ID);
776
- try { ensureDir(RUNTIME_ROOT); } catch {}
777
- // Best-effort restrictive write: O_CREAT|O_TRUNC|O_WRONLY with mode 0o600.
778
- // On Windows mode bits are largely ignored, but the file still lives in
779
- // the per-user tmp dir; an attacker without the same UID cannot read it.
780
- try { fs.unlinkSync(file); } catch {}
781
- const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY, 0o600);
782
- try {
783
- fs.writeSync(fd, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, secret, updatedAt: Date.now() }));
784
- } finally {
785
- try { fs.closeSync(fd); } catch {}
786
- }
787
- try { fs.chmodSync(file, 0o600); } catch {}
788
- }
789
- function clearOwnerSecret() {
790
- try { fs.unlinkSync(getOwnerSecretPath(INSTANCE_ID)); } catch {}
791
- }
792
- function readOwnerSecretFor(ownerInstanceId) {
793
- if (!ownerInstanceId) return "";
794
- try {
795
- const raw = fs.readFileSync(getOwnerSecretPath(ownerInstanceId), "utf8");
796
- const parsed = JSON.parse(raw);
797
- return typeof parsed?.secret === "string" ? parsed.secret : "";
798
- } catch {
799
- return "";
800
- }
801
- }
802
- async function proxyRequest(endpoint, method, body) {
803
- return new Promise((resolve) => {
804
- const url = new URL(`http://127.0.0.1:${ownerHttpPort}${endpoint}`);
805
- // Auth: read the owner's per-process secret from the restricted
806
- // owner-secret file (0o600). The instanceId header is kept only as a
807
- // secondary diagnostic — requireOwnerToken on the owner side checks
808
- // the secret, not the instanceId.
809
- const active = readActiveInstance();
810
- const ownerInstanceId = active?.instanceId || INSTANCE_ID;
811
- // Key the secret-file lookup on the owner's published instanceId — the
812
- // SAME identifier the owner used when writing owner-secret-<instanceId>.json
813
- // (publishOwnerSecret above) and what requireOwnerToken's x-owner-instance
814
- // header check compares against. Do NOT route this through
815
- // getActiveOwnerPid(active): that helper prefers ownerLeadPid /
816
- // terminalLeadPid / supervisor_pid, which in a supervisor-backed session
817
- // diverge from the owner-HTTP process.pid (== owner's INSTANCE_ID),
818
- // causing the proxy to read owner-secret-<supervisorPid>.json while the
819
- // owner wrote owner-secret-<process.pid>.json → empty secret → 401.
820
- const ownerSecret = readOwnerSecretFor(ownerInstanceId);
821
- if (!ownerSecret) {
822
- resolve({ ok: false, error: "owner secret unavailable (file missing or unreadable)" });
823
- return;
824
- }
825
- const reqOpts = {
826
- hostname: "127.0.0.1",
827
- port: ownerHttpPort,
828
- path: url.pathname + url.search,
829
- method,
830
- headers: {
831
- "Content-Type": "application/json",
832
- "x-owner-token": ownerSecret,
833
- "x-owner-instance": ownerInstanceId,
834
- },
835
- timeout: 3e4
836
- };
837
- const req = http.request(reqOpts, (res) => {
838
- let data = "";
839
- res.on("data", (chunk) => {
840
- data += chunk;
841
- });
842
- res.on("end", () => {
843
- try {
844
- const parsed = JSON.parse(data);
845
- resolve({ ok: res.statusCode === 200, data: parsed, error: parsed.error });
846
- } catch {
847
- resolve({ ok: false, error: `invalid response from owner: ${data.slice(0, 200)}` });
848
- }
849
- });
850
- });
851
- req.on("error", (err) => {
852
- resolve({ ok: false, error: `proxy request failed: ${err.message}` });
853
- });
854
- req.on("timeout", () => {
855
- req.destroy();
856
- resolve({ ok: false, error: "proxy request timed out" });
857
- });
858
- if (body) req.write(JSON.stringify(body));
859
- req.end();
860
- });
861
- }
862
- async function pingOwner(port) {
863
- return new Promise((resolve) => {
864
- const req = http.request({
865
- hostname: "127.0.0.1",
866
- port,
867
- path: "/ping",
868
- method: "GET",
869
- timeout: 3e3
870
- }, (res) => {
871
- res.resume();
872
- resolve(res.statusCode === 200);
873
- });
874
- req.on("error", () => resolve(false));
875
- req.on("timeout", () => {
876
- req.destroy();
877
- resolve(false);
878
- });
879
- req.end();
880
- });
881
- }
882
- function tryListenPort(server, port) {
883
- return new Promise((resolve) => {
884
- server.once("error", () => resolve(false));
885
- server.listen(port, "127.0.0.1", () => resolve(true));
886
- });
887
- }
888
- // Owner-token auth gate. Compares x-owner-token against the per-process
889
- // OWNER_SECRET generated at startOwnerHttpServer time. The secret is NOT
890
- // returned by /ping (only the public instanceId is) so a local caller that
891
- // scrapes /ping still cannot forge owner-side calls. Constant-time compare
892
- // to avoid trivial timing leakage on the local socket. Optional secondary
893
- // instanceId check via x-owner-instance: when present it must match this
894
- // process's INSTANCE_ID, catching stale clients targeting an old owner.
895
- function requireOwnerToken(req, res) {
896
- const token = req.headers["x-owner-token"];
897
- if (!OWNER_SECRET || typeof token !== "string" || token.length !== OWNER_SECRET.length) {
898
- res.writeHead(401, { "Content-Type": "application/json" });
899
- res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
900
- return false;
901
- }
902
- let ok = false;
903
- try {
904
- ok = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(OWNER_SECRET));
905
- } catch {
906
- ok = false;
907
- }
908
- if (!ok) {
909
- res.writeHead(401, { "Content-Type": "application/json" });
910
- res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
911
- return false;
912
- }
913
- const instanceHeader = req.headers["x-owner-instance"];
914
- if (instanceHeader && instanceHeader !== INSTANCE_ID) {
915
- res.writeHead(401, { "Content-Type": "application/json" });
916
- res.end(JSON.stringify({ error: "unauthorized: instance mismatch" }));
917
- return false;
918
- }
919
- return true;
920
- }
921
- // Per-route handler table. Each handler matches the original switch-case
922
- // behavior byte-for-byte (auth checks, status codes, response shapes); the
923
- // outer dispatch loop just looks up the entry instead of running a long
924
- // switch. `methods` mirrors any pre-existing 405 guard.
925
- const OWNER_ROUTES = {
926
- "/ping": async (req, res /*, body, url*/) => {
927
- res.writeHead(200);
928
- res.end(JSON.stringify({ ok: true, instanceId: INSTANCE_ID, pid: process.pid }));
929
- },
930
- "/send": async (req, res, body) => {
931
- if (!requireOwnerToken(req, res)) return;
932
- // Pre/post-send activity bumps keep idle gating consistent across
933
- // slow network / attachment / rate-limited sends; double bump is
934
- // harmless.
935
- scheduler.noteActivity();
936
- const sendResult = await backend.sendMessage(body.chatId, body.text, body.opts);
937
- scheduler.noteActivity();
938
- res.writeHead(200);
939
- res.end(JSON.stringify({ sentIds: sendResult.sentIds }));
940
- },
941
- "/react": async (req, res, body) => {
942
- if (!requireOwnerToken(req, res)) return;
943
- await backend.react(body.chatId, body.messageId, body.emoji);
944
- res.writeHead(200);
945
- res.end(JSON.stringify({ ok: true }));
946
- },
947
- "/edit": async (req, res, body) => {
948
- if (!requireOwnerToken(req, res)) return;
949
- const editId = await backend.editMessage(body.chatId, body.messageId, body.text, body.opts);
950
- res.writeHead(200);
951
- res.end(JSON.stringify({ id: editId }));
952
- },
953
- "/fetch": async (req, res, body, url) => {
954
- if (!requireOwnerToken(req, res)) return;
955
- const channelId = url.searchParams.get("channel") ?? "";
956
- const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
957
- const msgs = await backend.fetchMessages(channelId, limit);
958
- recordFetchedMessages(channelId, labelForChannelId(channelId), msgs);
959
- res.writeHead(200);
960
- res.end(JSON.stringify({ messages: msgs }));
961
- },
962
- "/download": async (req, res, body) => {
963
- if (!requireOwnerToken(req, res)) return;
964
- const files = await backend.downloadAttachment(body.chatId, body.messageId);
965
- res.writeHead(200);
966
- res.end(JSON.stringify({ files }));
967
- },
968
- "/typing/start": async (req, res, body) => {
969
- if (!requireOwnerToken(req, res)) return;
970
- backend.startTyping(body.channelId);
971
- res.writeHead(200);
972
- res.end(JSON.stringify({ ok: true }));
973
- },
974
- "/typing/stop": async (req, res, body) => {
975
- if (!requireOwnerToken(req, res)) return;
976
- backend.stopTyping(body.channelId);
977
- res.writeHead(200);
978
- res.end(JSON.stringify({ ok: true }));
979
- },
980
- "/inject": async (req, res, body) => {
981
- // Require owner-token header to prevent unauthenticated local injection.
982
- if (!requireOwnerToken(req, res)) return;
983
- const content = body.content;
984
- if (!content) {
985
- res.writeHead(400);
986
- res.end(JSON.stringify({ error: "content required" }));
987
- return;
988
- }
989
- const source = body.source || "mixdog-agent";
990
- const injMeta = { user: source, user_id: "system", ts: (/* @__PURE__ */ new Date()).toISOString() };
991
- if (body.instruction) injMeta.instruction = body.instruction;
992
- if (body.type) injMeta.type = body.type;
993
- sendNotifyToParent("notifications/claude/channel", { content, meta: injMeta });
994
- res.writeHead(200);
995
- res.end(JSON.stringify({ ok: true }));
996
- },
997
- "/trigger-schedule": async (req, res, body) => {
998
- // Native fallback for `mcp__trigger_schedule` so out-of-band
999
- // verification works when the MCP stdio bridge is down (host agent
1000
- // disconnected, supervisor restart pending, etc.). Same authz as
1001
- // /inject — x-owner-token must equal INSTANCE_ID.
1002
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
1003
- if (!requireOwnerToken(req, res)) return;
1004
- const triggerName = body.name;
1005
- if (!triggerName) {
1006
- res.writeHead(400);
1007
- res.end(JSON.stringify({ error: "name required" }));
1008
- return;
1009
- }
1010
- try {
1011
- const r = await scheduler.triggerManual(triggerName);
1012
- res.writeHead(200);
1013
- res.end(JSON.stringify({ ok: true, result: r ?? null }));
1014
- } catch (e) {
1015
- res.writeHead(500);
1016
- res.end(JSON.stringify({ error: e?.message || String(e) }));
1017
- }
1018
- },
1019
- "/schedule-status": async (req, res) => {
1020
- // Owner-side schedule_status so standby/proxy sessions read the LIVE
1021
- // scheduler instead of their own stale local state. Mirrors the MCP
1022
- // schedule_status handler's formatting (kept byte-identical via the
1023
- // shared scheduleStatusResult() helper).
1024
- if (!requireOwnerToken(req, res)) return;
1025
- try {
1026
- const r = scheduleStatusResult();
1027
- res.writeHead(200);
1028
- res.end(JSON.stringify({ ok: true, result: r }));
1029
- } catch (e) {
1030
- res.writeHead(500);
1031
- res.end(JSON.stringify({ error: e?.message || String(e) }));
1032
- }
1033
- },
1034
- "/schedule-control": async (req, res, body) => {
1035
- // Owner-side schedule_control so standby/proxy sessions mutate the LIVE
1036
- // scheduler (defer/skip_today) instead of their own stale local state.
1037
- // Validation lives here because the proxy side's scheduler.nonInteractive/
1038
- // interactive lists are not authoritative.
1039
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
1040
- if (!requireOwnerToken(req, res)) return;
1041
- try {
1042
- const r = scheduleControlResult(body || {});
1043
- res.writeHead(200);
1044
- res.end(JSON.stringify({ ok: true, result: r }));
1045
- } catch (e) {
1046
- res.writeHead(500);
1047
- res.end(JSON.stringify({ error: e?.message || String(e) }));
1048
- }
1049
- },
1050
- "/bridge": async (req, res, body) => {
1051
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
1052
- if (!requireOwnerToken(req, res)) return;
1053
- const bridgeFile = body.file;
1054
- const bridgePrompt = body.prompt;
1055
- const bridgeRef = body.ref;
1056
- const bridgeRole = body.role;
1057
- const bridgeContext = body.context;
1058
- let bridgePromptFinal = bridgePrompt;
1059
- if (!bridgePromptFinal && bridgeFile) {
1060
- try { bridgePromptFinal = fs.readFileSync(bridgeFile, "utf-8").trim(); } catch (e) {
1061
- res.writeHead(400); res.end(JSON.stringify({ error: `Cannot read file: ${e.message}` })); return;
1062
- }
1063
- }
1064
- if (!bridgePromptFinal && !bridgeRef) { res.writeHead(400); res.end(JSON.stringify({ error: "prompt, file, or ref required" })); return; }
1065
- try {
1066
- const agentMod = await import(pathToFileURL(path.join(path.dirname(import.meta.url.replace("file:///", "").replace(/\//g, path.sep)), "..", "agent", "index.mjs")).href);
1067
- if (agentMod.init) await agentMod.init();
1068
- const toolArgs = {};
1069
- if (bridgePromptFinal) toolArgs.prompt = bridgePromptFinal;
1070
- if (bridgeRef) toolArgs.ref = bridgeRef;
1071
- if (bridgeRole) toolArgs.role = bridgeRole;
1072
- if (bridgeContext) toolArgs.context = bridgeContext;
1073
- const notifyFn = (text, extraMeta) => {
1074
- sendNotifyToParent("notifications/claude/channel", {
1075
- content: text,
1076
- meta: {
1077
- user: "mixdog-agent",
1078
- user_id: "system",
1079
- ts: new Date().toISOString(),
1080
- ...(extraMeta || {})
1081
- }
1082
- });
1083
- };
1084
- const BRIDGE_HTTP_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
1085
- const bridgeAbort = new AbortController();
1086
- const bridgeTimer = setTimeout(() => bridgeAbort.abort(new Error("bridge HTTP timeout")), BRIDGE_HTTP_TIMEOUT_MS);
1087
- const onReqClose = () => bridgeAbort.abort(new Error("client disconnected"));
1088
- req.on("close", onReqClose);
1089
- let result;
1090
- try {
1091
- result = await Promise.race([
1092
- agentMod.handleToolCall("bridge", toolArgs, { notifyFn, requestSignal: bridgeAbort.signal }),
1093
- new Promise((_, reject) => bridgeAbort.signal.addEventListener("abort", () => reject(bridgeAbort.signal.reason), { once: true })),
1094
- ]);
1095
- } finally {
1096
- clearTimeout(bridgeTimer);
1097
- req.removeListener("close", onReqClose);
1098
- }
1099
- res.writeHead(200);
1100
- res.end(JSON.stringify(result));
1101
- } catch (e) {
1102
- res.writeHead(500); res.end(JSON.stringify({ error: e.message })); return;
1103
- }
1104
- },
1105
- "/bridge/activate": async (req, res, body) => {
1106
- if (!requireOwnerToken(req, res)) return;
1107
- const active = Boolean(body.active);
1108
- const wasActive = channelBridgeActive;
1109
- channelBridgeActive = active;
1110
- writeBridgeState(active);
1111
- if (!active && wasActive) {
1112
- // Mirror the MCP activate_channel_bridge deactivate path: tear down
1113
- // owner-side runtime (Discord/scheduler/webhook/event/owner-HTTP/
1114
- // heartbeat) so a deactivated bridge doesn't keep running and this
1115
- // owner can't later proxyMode against its own port.
1116
- stopServerTyping();
1117
- try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
1118
- process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
1119
- }
1120
- }
1121
- res.writeHead(200);
1122
- res.end(JSON.stringify({ ok: true, active: channelBridgeActive }));
1123
- },
1124
- "/mcp": async (req, res, body) => {
1125
- if (req.method === "POST") {
1126
- // Require owner-token header to prevent unauthenticated local MCP dispatch.
1127
- if (!requireOwnerToken(req, res)) return;
1128
- const httpMcp = createHttpMcpServer();
1129
- const httpTransport = new StreamableHTTPServerTransport({
1130
- sessionIdGenerator: void 0,
1131
- enableJsonResponse: true
1132
- });
1133
- res.on("close", () => {
1134
- httpTransport.close();
1135
- void httpMcp.close();
1136
- });
1137
- await httpMcp.connect(httpTransport);
1138
- await httpTransport.handleRequest(req, res, body);
1139
- } else {
1140
- res.writeHead(405);
1141
- res.end(JSON.stringify({ error: "Method not allowed" }));
1142
- }
1143
- },
1144
- "/recap/reset": async (req, res /*, body*/) => {
1145
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
1146
- if (!requireOwnerToken(req, res)) return;
1147
- // Called by hooks/session-start.cjs on `/clear` (matcher startup|clear).
1148
- // The session-start hook runs in a separate cjs process with no IPC
1149
- // handle to this forked channels child, so it can't drop recap
1150
- // status directly. Reset to an `empty` baseline so the statusline
1151
- // doesn't carry the prior session's `injected`/`error` recap badge
1152
- // into the cleared session.
1153
- const now = Date.now();
1154
- recapState.state = 'empty';
1155
- recapState.running = false;
1156
- recapState.startedAt = null;
1157
- recapState.lastCompletedAt = now;
1158
- recapState.updatedAt = now;
1159
- recapState.errorMessage = null;
1160
- sendRecapStateToParent();
1161
- res.writeHead(200);
1162
- res.end(JSON.stringify({ ok: true }));
1163
- },
1164
- "/cycle1": async (req, res, body) => {
1165
- if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ ok: false, reason: "method-not-allowed", error: "POST required" })); return; }
1166
- if (!requireOwnerToken(req, res)) return;
1167
- const tCycleEntry = Date.now();
1168
- const timeoutMs = Number(body?.timeout_ms) > 0 ? Math.min(60000, Number(body.timeout_ms)) : 15000;
1169
- // IPC timer must outlive the worker-side deadline so a graceful
1170
- // {timedOutWaiting:true} resolve has time to traverse IPC before
1171
- // the channel timer rejects with memory-timeout. Without the
1172
- // buffer, the worker resolves at deadline-0ms and the local
1173
- // setTimeout fires at deadline+0ms in the same tick — race won by
1174
- // whichever scheduler ordering wins, turning intended 200 flags
1175
- // into 503 responses.
1176
- const ipcTimeoutMs = timeoutMs + 2000;
1177
- try {
1178
- // Carry the caller deadline through to the memory worker so a
1179
- // pending cycle1 in-flight is awaited under the same budget.
1180
- // Without this, when the previous cycle1's LLM call lives past
1181
- // 60s, every later SessionStart slot stacks another full 60s
1182
- // wait behind the same zombie promise.
1183
- const result = await callMemoryAction(
1184
- 'cycle1',
1185
- { ...(body?.args || {}), _callerDeadlineMs: timeoutMs },
1186
- ipcTimeoutMs,
1187
- );
1188
- // A successful IPC round-trip can still carry a nested MCP error
1189
- // envelope ({ isError: true }) when the memory worker served the
1190
- // call but the action failed — e.g. a promoted fork-proxy whose
1191
- // local `db` is still null. Surfacing that as outer { ok: true }
1192
- // masks the failure and makes session-start log a phantom success.
1193
- // Return a transient 503 so the hook's 503-retry path (which gates
1194
- // only on statusCode===503) re-polls instead of trusting it.
1195
- if (result && typeof result === 'object' && result.isError === true) {
1196
- const nestedText = Array.isArray(result.content)
1197
- ? result.content.map(c => (c && c.text) || '').join(' ').trim()
1198
- : '';
1199
- try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry} nestedError=1\n`); } catch {}
1200
- res.writeHead(503);
1201
- res.end(JSON.stringify({ ok: false, reason: 'memory-not-ready', error: nestedText || 'memory cycle1 returned isError' }));
1202
- } else {
1203
- try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry}\n`); } catch {}
1204
- res.writeHead(200);
1205
- res.end(JSON.stringify({ ok: true, result }));
1206
- }
1207
- } catch (e) {
1208
- // Classify transient/unavailable failures so the session-start hook
1209
- // (and other 503-retry callers) can distinguish boot-time races from
1210
- // IPC-layer faults and timeouts. All four reasons stay on 503 to
1211
- // preserve the hook retry contract (hooks/session-start.cjs:516
1212
- // gates only on statusCode===503); only the `reason` label changes.
1213
- //
1214
- // Source → reason mapping (upstream messages from server.mjs
1215
- // callWorker at 457-490 and local callMemoryAction at 169-187):
1216
- // server.mjs:470 "not ready (still booting)" → memory-not-ready
1217
- // server.mjs:464/467 "not available (...)" → worker-unavailable
1218
- // server.mjs:435 "exited unexpectedly" → worker-unavailable
1219
- // local "not a worker process" guard → worker-unavailable
1220
- // server.mjs:483 "IPC channel full or closed" → ipc-error
1221
- // server.mjs:488 "send failed: ..." → ipc-error
1222
- // server.mjs:475 "worker ... call ... timed out" → memory-timeout
1223
- // local "memory_call <action> timed out after Nms" → memory-timeout
1224
- const msg = e?.message || String(e);
1225
- let reason;
1226
- if (/worker memory not ready/i.test(msg)) {
1227
- reason = 'memory-not-ready';
1228
- } else if (/worker memory (IPC channel|send failed)/i.test(msg)) {
1229
- reason = 'ipc-error';
1230
- } else if (/timed out/i.test(msg)) {
1231
- reason = 'memory-timeout';
1232
- } else if (msg.includes('restart cap exceeded') || msg.includes('degraded')) {
1233
- // Permanent degraded state: restart cap hit or boot-time init failure.
1234
- // Use a distinct reason so callers can fail-fast without retrying.
1235
- // NOTE: checked before 'not available' — the error message
1236
- // "worker memory not available (restart cap exceeded)" contains both
1237
- // substrings and must land in 'memory-degraded', not 'worker-unavailable'.
1238
- reason = 'memory-degraded';
1239
- } else if (msg.includes('worker memory not available') || msg.includes('worker memory exited unexpectedly') || msg.includes('not a worker process')) {
1240
- reason = 'worker-unavailable';
1241
- }
1242
- const transient = Boolean(reason);
1243
- res.writeHead(transient ? 503 : 500);
1244
- res.end(JSON.stringify({ ok: false, reason, error: msg }));
1245
- }
1246
- },
1247
- "/rebind": async (req, res, body) => {
1248
- if (!requireOwnerToken(req, res)) return;
1249
- const channelId = statusState.read().channelId;
1250
- if (!channelId) {
1251
- res.writeHead(200);
1252
- res.end(JSON.stringify({ rebound: false, reason: "no channelId" }));
1253
- return;
1254
- }
1255
- const previousPath = getPersistedTranscriptPath();
1256
- const explicitTranscriptPath = typeof body?.transcriptPath === "string" ? body.transcriptPath.trim() : "";
1257
- const bound = await rebindTranscriptContext(channelId, {
1258
- previousPath,
1259
- persistStatus: true,
1260
- catchUp: true,
1261
- ...(explicitTranscriptPath ? { transcriptPath: explicitTranscriptPath } : {})
1262
- });
1263
- const reboundChanged = Boolean(bound) && bound !== previousPath;
1264
- res.writeHead(200);
1265
- res.end(JSON.stringify({ rebound: reboundChanged, path: bound || null }));
1266
- },
1267
- };
1268
- const BACKEND_DEPENDENT_PATHS = new Set([
1269
- "/send",
1270
- "/react",
1271
- "/edit",
1272
- "/fetch",
1273
- "/download",
1274
- "/typing/start",
1275
- "/typing/stop",
1276
- "/mcp"
1277
- ]);
1278
- async function ownerRequestHandler(req, res) {
1279
- res.setHeader("Content-Type", "application/json");
1280
- let body = {};
1281
- if (req.method === "POST") {
1282
- const chunks = [];
1283
- for await (const chunk of req) chunks.push(chunk);
1284
- try {
1285
- const rawBody = Buffer.concat(chunks).toString();
1286
- body = rawBody.trim() ? JSON.parse(rawBody) : {};
1287
- } catch {
1288
- res.writeHead(400);
1289
- res.end(JSON.stringify({ error: "invalid JSON body" }));
1290
- return;
1291
- }
1292
- }
1293
- try {
1294
- const url = new URL(req.url ?? "/", `http://127.0.0.1`);
1295
- if (BACKEND_DEPENDENT_PATHS.has(url.pathname) && !bridgeRuntimeConnected) {
1296
- res.writeHead(503);
1297
- res.end(JSON.stringify({ ok: false, reason: "backend-not-ready" }));
1298
- return;
1299
- }
1300
- const handler = OWNER_ROUTES[url.pathname];
1301
- if (handler) {
1302
- await handler(req, res, body, url);
1303
- return;
1304
- }
1305
- res.writeHead(404);
1306
- res.end(JSON.stringify({ error: "not found" }));
1307
- } catch (err) {
1308
- const msg = err instanceof Error ? err.message : String(err);
1309
- res.writeHead(500);
1310
- res.end(JSON.stringify({ error: msg }));
1311
- }
1312
- }
1313
- async function startOwnerHttpServer() {
1314
- if (ownerHttpServer) return ownerHttpServer.address().port;
1315
- // Generate a fresh cryptographic owner-secret BEFORE the listener accepts
1316
- // traffic so requireOwnerToken always has a real secret to compare. Stored
1317
- // in a 0o600 sidecar file (owner-secret-<pid>.json) under RUNTIME_ROOT so
1318
- // only the same UID + same active owner pid can read it back. /ping does
1319
- // NOT return this value — only the public instanceId.
1320
- if (!OWNER_SECRET) {
1321
- OWNER_SECRET = crypto.randomBytes(32).toString("hex");
1322
- try { publishOwnerSecret(OWNER_SECRET); }
1323
- catch (e) {
1324
- process.stderr.write(`mixdog: failed to publish owner secret: ${e?.message || e}\n`);
1325
- }
1326
- }
1327
- const server = http.createServer(ownerRequestHandler);
1328
- for (let port = PROXY_PORT_MIN; port <= PROXY_PORT_MAX; port++) {
1329
- if (await tryListenPort(server, port)) {
1330
- ownerHttpServer = server;
1331
- process.stderr.write(`mixdog: owner HTTP server listening on 127.0.0.1:${port}
1332
- `);
1333
- return port;
1334
- }
1335
- server.removeAllListeners("error");
1336
- }
1337
- throw new Error(`no available port in range ${PROXY_PORT_MIN}-${PROXY_PORT_MAX}`);
1338
- }
1339
- function stopOwnerHttpServer() {
1340
- if (!ownerHttpServer) return;
1341
- ownerHttpServer.close();
1342
- ownerHttpServer = null;
1343
- // Drop the per-process secret + sidecar file. A future startOwnerHttpServer()
1344
- // call regenerates a fresh one, so a stale standby that read the old secret
1345
- // before the restart cannot authenticate against the new owner.
1346
- OWNER_SECRET = "";
1347
- try { clearOwnerSecret(); } catch {}
1348
- globalThis.__mixdogBeaconRealHandler = null;
1349
- globalThis.__mixdogBeacon = null;
1350
- }
1351
787
  function logOwnership(note) {
1352
788
  if (lastOwnershipNote === note) return;
1353
789
  lastOwnershipNote = note;
@@ -1358,47 +794,24 @@ function currentOwnerState() {
1358
794
  const active = readActiveInstance();
1359
795
  return {
1360
796
  active,
1361
- owned: active?.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID
797
+ // Strict last-wins: this process owns the bridge ONLY when active-instance
798
+ // names exactly this INSTANCE_ID. A newer remote session that claims the
799
+ // seat overwrites instanceId, so the old owner immediately reads owned=false
800
+ // and disconnects on its next refresh tick. No PID/terminal fallback —
801
+ // that used to let a co-terminal worker wrongly self-claim.
802
+ owned: active?.instanceId === INSTANCE_ID
1362
803
  };
1363
804
  }
1364
805
  function getBridgeOwnershipSnapshot() {
1365
806
  return currentOwnerState();
1366
807
  }
1367
- // MIXDOG_PIN_OWNER=1 in the owning process writes `pinned:true` into
1368
- // active-instance.json. Pinned owners ignore the 10 s stale window — they
1369
- // only relinquish ownership when their OS process actually dies. Set per
1370
- // session (env var on the host agent shell) to lock that Lead as the
1371
- // schedule/webhook receiver across multi-session use.
1372
- function canStealOwnership(active) {
1373
- if (!active) return true;
1374
- if (active.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID) return true;
1375
- if (active.pinned) {
1376
- const pinnedPid = getActiveOwnerPid(active);
1377
- if (!pinnedPid) return true;
1378
- try { process.kill(pinnedPid, 0); return false; }
1379
- catch { return true; }
1380
- }
1381
- if (Date.now() - active.updatedAt > ACTIVE_OWNER_STALE_MS) return true;
1382
- const ownerPid = getActiveOwnerPid(active);
1383
- try {
1384
- if (!ownerPid) throw new Error("missing owner pid");
1385
- process.kill(ownerPid, 0);
1386
- return false;
1387
- } catch {
1388
- return true;
1389
- }
1390
- }
1391
808
  function claimBridgeOwnership(reason) {
1392
809
  refreshActiveInstance(INSTANCE_ID);
1393
810
  logOwnership(`claimed owner (${reason})`);
1394
811
  }
1395
- function noteStartupHandoff(previous) {
1396
- if (!previous) return;
1397
- if (previous.instanceId === INSTANCE_ID) return;
1398
- if (getActiveOwnerPid(previous) === TERMINAL_LEAD_PID) return;
1399
- logOwnership(`startup handoff from ${previous.instanceId}`);
1400
- }
1401
812
  async function bindPersistedTranscriptIfAny() {
813
+ // Main-channel fallback requires channelBridgeActive (set in start() before
814
+ // refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
1402
815
  // Resolve channelId first from persisted status; fall back to the most
1403
816
  // recent status-*.json snapshot, then to the configured main channel when
1404
817
  // the bridge is active. No exists-gate here — once we have a channelId,
@@ -1535,17 +948,17 @@ async function startOwnedRuntime(options = {}) {
1535
948
  if (!channelBridgeActive) return;
1536
949
  bridgeRuntimeStarting = true;
1537
950
  _ownedRuntimeStopRequested = false;
1538
- // Advertise active-instance.json BEFORE backend connect so peers can
1539
- // discover this owner (httpPort) immediately. backendReady=false marks
1540
- // the partial state until backend.connect() succeeds.
1541
- let httpPort;
1542
- try {
1543
- httpPort = await startOwnerHttpServer();
1544
- } catch (e) {
1545
- process.stderr.write(`mixdog: HTTP server start failed (non-fatal): ${e instanceof Error ? e.message : String(e)}
1546
- `);
1547
- }
1548
- refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: false });
951
+ // Capture the backend instance that THIS start operation will connect. A
952
+ // reloadRuntimeConfig() hot-swap can replace the global `backend` while this
953
+ // start is still awaiting connect(); using the captured instance for both
954
+ // connect() and the bail-path disconnect() guarantees we tear down the
955
+ // backend WE started (not the freshly-swapped one), closing the
956
+ // both-backends-live window.
957
+ const startingBackend = backend;
958
+ // Advertise active-instance.json BEFORE backend connect so a newer remote
959
+ // session's last-wins claim is visible immediately. backendReady=false
960
+ // marks the partial state until backend.connect() succeeds.
961
+ refreshActiveInstance(INSTANCE_ID, { backendReady: false });
1549
962
  startOwnerHeartbeat();
1550
963
  // Re-check after each post-connect await so a stopOwnedRuntime() landing
1551
964
  // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
@@ -1556,8 +969,7 @@ async function startOwnedRuntime(options = {}) {
1556
969
  // (stop did disconnect; redo to be defensive).
1557
970
  const bailIfStopRequested = async () => {
1558
971
  if (!_ownedRuntimeStopRequested) return false;
1559
- try { await backend.disconnect(); } catch {}
1560
- try { stopOwnerHttpServer(); } catch {}
972
+ try { await startingBackend.disconnect(); } catch {}
1561
973
  try { stopOwnerHeartbeat(); } catch {}
1562
974
  try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
1563
975
  try { clearActiveInstance(INSTANCE_ID); } catch {}
@@ -1565,16 +977,26 @@ async function startOwnedRuntime(options = {}) {
1565
977
  _ownedRuntimeStopRequested = false;
1566
978
  return true;
1567
979
  };
980
+ const restoreBinding = options.restoreBinding !== false;
981
+ const bindPersistedTranscriptTask = restoreBinding
982
+ ? bindPersistedTranscriptIfAny().catch((e) => {
983
+ process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
984
+ })
985
+ : null;
1568
986
  // Await backend.connect() so callers (and bindingReady) only resolve after
1569
987
  // the Discord binding is real. Previously this was fire-and-forget and
1570
988
  // refreshBridgeOwnership returned immediately, letting bindingReady fire
1571
989
  // before backend listeners were attached.
1572
990
  try {
1573
- await backend.connect();
1574
- if (await bailIfStopRequested()) return;
991
+ await startingBackend.connect();
992
+ if (await bailIfStopRequested()) {
993
+ cancelPendingTranscriptRearm();
994
+ try { forwarder.stopWatch(); } catch {}
995
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
996
+ return;
997
+ }
1575
998
  bridgeRuntimeConnected = true;
1576
- refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: true });
1577
- proxyMode = false;
999
+ refreshActiveInstance(INSTANCE_ID, { backendReady: true });
1578
1000
  // initProviders must complete before scheduler.start() — otherwise the
1579
1001
  // scheduler's first fire can land before the registry is populated and
1580
1002
  // return `Provider "<name>" not found or not enabled`. The previous
@@ -1585,22 +1007,37 @@ async function startOwnedRuntime(options = {}) {
1585
1007
  } catch (e) {
1586
1008
  process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1587
1009
  }
1588
- if (await bailIfStopRequested()) return;
1010
+ if (await bailIfStopRequested()) {
1011
+ cancelPendingTranscriptRearm();
1012
+ try { forwarder.stopWatch(); } catch {}
1013
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
1014
+ return;
1015
+ }
1589
1016
  scheduler.start();
1590
1017
  startSnapshotWriter(scheduler);
1591
1018
  syncOwnedWebhookAndEventRuntime();
1592
- if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
1593
- process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1594
- });
1019
+ if (restoreBinding) {
1020
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
1021
+ const pendingTranscriptPath = forwarder.transcriptPath;
1022
+ if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
1023
+ // Pre-connect bind may have armed rearm while !bridgeRuntimeConnected;
1024
+ // the first tick then exits without rescheduling. Re-arm now that we own.
1025
+ schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
1026
+ } else {
1027
+ void forwarder.forwardNewText().catch((err) => {
1028
+ process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
1029
+ });
1030
+ }
1031
+ }
1595
1032
  process.stderr.write(`mixdog: running with ${backend.name} backend\n`);
1596
1033
  logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
1597
1034
  } catch (e) {
1598
1035
  process.stderr.write(`mixdog: backend connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
1036
+ cancelPendingTranscriptRearm();
1037
+ try { forwarder.stopWatch(); } catch {}
1038
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
1599
1039
  // Roll back partial owner-side state advertised before connect() ran:
1600
- // HTTP server, heartbeat, and active-instance entry. Without this cleanup
1601
- // stopOwnedRuntime() at shutdown will short-circuit on !bridgeRuntimeConnected
1602
- // and leave the port bound + active-instance.json stale.
1603
- try { stopOwnerHttpServer(); } catch {}
1040
+ // heartbeat and active-instance entry.
1604
1041
  try { stopOwnerHeartbeat(); } catch {}
1605
1042
  try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
1606
1043
  try { clearActiveInstance(INSTANCE_ID); } catch {}
@@ -1608,59 +1045,12 @@ async function startOwnedRuntime(options = {}) {
1608
1045
  bridgeRuntimeStarting = false;
1609
1046
  }
1610
1047
  }
1611
- async function startCliOwnedRuntime(options = {}) {
1612
- if (bridgeRuntimeConnected) return;
1613
- if (bridgeRuntimeStarting) return;
1614
- if (!channelBridgeActive) return;
1615
- const startedAt = performance.now();
1616
- bootProfile("cli-owned:start");
1617
- bridgeRuntimeStarting = true;
1618
- _ownedRuntimeStopRequested = false;
1619
- try {
1620
- const backendStartedAt = performance.now();
1621
- await backend.connect();
1622
- bootProfile("backend:connected", { ms: (performance.now() - backendStartedAt).toFixed(1), backend: backend.name });
1623
- if (_ownedRuntimeStopRequested) {
1624
- try { await backend.disconnect(); } catch {}
1625
- bridgeRuntimeConnected = false;
1626
- _ownedRuntimeStopRequested = false;
1627
- return;
1628
- }
1629
- bridgeRuntimeConnected = true;
1630
- proxyMode = false;
1631
- ownerHttpPort = 0;
1632
- try {
1633
- const providersStartedAt = performance.now();
1634
- const agentCfg = loadAgentConfig();
1635
- await initProviders(agentCfg.providers || {});
1636
- bootProfile("providers:ready", { ms: (performance.now() - providersStartedAt).toFixed(1) });
1637
- } catch (e) {
1638
- bootProfile("providers:failed", { error: e instanceof Error ? e.message : String(e) });
1639
- process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1640
- }
1641
- if (_ownedRuntimeStopRequested) {
1642
- await stopOwnedRuntime("cli-owned start cancelled");
1643
- return;
1644
- }
1645
- scheduler.start();
1646
- startSnapshotWriter(scheduler);
1647
- bootProfile("scheduler:started");
1648
- syncOwnedWebhookAndEventRuntime();
1649
- bootProfile("webhook-event:ready");
1650
- if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
1651
- process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
1652
- });
1653
- bootProfile("cli-owned:ready", { ms: (performance.now() - startedAt).toFixed(1) });
1654
- process.stderr.write(`mixdog: running with ${backend.name} backend (cli-owned)\n`);
1655
- } catch (e) {
1656
- bootProfile("cli-owned:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e instanceof Error ? e.message : String(e) });
1657
- process.stderr.write(`mixdog: backend connect failed (non-fatal, cli-owned): ${e instanceof Error ? e.message : String(e)}\n`);
1658
- try { await stopOwnedRuntime("cli-owned start failed"); } catch {}
1659
- } finally {
1660
- bridgeRuntimeStarting = false;
1661
- }
1662
- }
1663
1048
  async function stopOwnedRuntime(reason) {
1049
+ // Cancel any pending transcript re-arm poll BEFORE the connected/starting
1050
+ // early-return below. Otherwise a poll armed against a not-yet-existing
1051
+ // transcript could fire after teardown and reinstall the fs.watch handle
1052
+ // (startWatch is not owner-gated), leaking a live watcher past shutdown.
1053
+ cancelPendingTranscriptRearm();
1664
1054
  // startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
1665
1055
  // claims channel locks BEFORE awaiting backend.connect(). If shutdown lands
1666
1056
  // during that window (bridgeRuntimeStarting=true, bridgeRuntimeConnected
@@ -1679,7 +1069,6 @@ async function stopOwnedRuntime(reason) {
1679
1069
  // and the drain/retry timers stay live after ownership is dropped, leaking a
1680
1070
  // file handle + timers for the rest of the process lifetime.
1681
1071
  try { forwarder.stopWatch(); } catch {}
1682
- stopOwnerHttpServer();
1683
1072
  stopOwnerHeartbeat();
1684
1073
  scheduler.stop();
1685
1074
  stopSnapshotWriter();
@@ -1701,8 +1090,14 @@ function refreshBridgeOwnershipSafe(options = {}) {
1701
1090
  function startOwnerHeartbeat() {
1702
1091
  if (ownerHeartbeatTimer) return;
1703
1092
  ownerHeartbeatTimer = setInterval(() => {
1704
- try { refreshActiveInstance(INSTANCE_ID); }
1705
- catch (e) {
1093
+ try {
1094
+ // Last-wins guard: only refresh the seat if we STILL own it. If a newer
1095
+ // remote session claimed active-instance.json since our last tick, do
1096
+ // NOT overwrite it back — that would re-steal ownership and cause
1097
+ // ping-pong / double backend connections. The bridgeOwnershipTimer's
1098
+ // refreshBridgeOwnership() will observe owned=false and disconnect us.
1099
+ if (currentOwnerState().owned) refreshActiveInstance(INSTANCE_ID);
1100
+ } catch (e) {
1706
1101
  process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
1707
1102
  }
1708
1103
  }, OWNER_HEARTBEAT_INTERVAL_MS);
@@ -1719,98 +1114,41 @@ async function refreshBridgeOwnership(options = {}) {
1719
1114
  // instead of returning early and observing spurious auto-connect failure.
1720
1115
  if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
1721
1116
  bridgeOwnershipRefreshInFlight = (async () => {
1117
+ // Opt-in remote, single-owner, last-wins. Only a remote session with an
1118
+ // active bridge participates. If this instance is the active owner (its
1119
+ // INSTANCE_ID is the one advertised in active-instance.json) it ensures
1120
+ // the owned runtime is up. If a newer remote session has since claimed
1121
+ // ownership (last-wins overwrite), this instance is no longer owner and
1122
+ // quietly tears its backend down on the next tick. No proxy, no steal.
1722
1123
  if (!channelBridgeActive) {
1723
- const { active: active2 } = currentOwnerState();
1724
- if (active2?.httpPort && !proxyMode) {
1725
- const alive = await pingOwner(active2.httpPort);
1726
- if (alive) {
1727
- proxyMode = true;
1728
- ownerHttpPort = active2.httpPort;
1729
- logOwnership(`non-channel session \u2014 proxy mode via ${active2.instanceId}`);
1730
- }
1731
- }
1124
+ if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
1732
1125
  return;
1733
1126
  }
1734
1127
  const { active, owned } = currentOwnerState();
1735
- const activeHttpPort = Number(active?.httpPort) || 0;
1736
- let activeHttpChecked = false;
1737
- let activeHttpAlive = false;
1738
- const checkActiveHttp = async () => {
1739
- if (!activeHttpPort) return false;
1740
- if (!activeHttpChecked) {
1741
- activeHttpAlive = await pingOwner(activeHttpPort);
1742
- activeHttpChecked = true;
1743
- }
1744
- return activeHttpAlive;
1745
- };
1746
- const enterProxyMode = (note) => {
1747
- proxyMode = true;
1748
- ownerHttpPort = activeHttpPort;
1749
- if (note) logOwnership(note);
1750
- };
1751
- if (proxyMode && !owned && activeHttpPort) {
1752
- const alive = await checkActiveHttp();
1753
- if (!alive) {
1754
- process.stderr.write(`[ownership] owner ping failed, attempting takeover
1755
- `);
1756
- proxyMode = false;
1757
- ownerHttpPort = 0;
1758
- claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
1759
- const next2 = currentOwnerState();
1760
- if (next2.owned) {
1761
- refreshActiveInstance(INSTANCE_ID);
1762
- await startOwnedRuntime(options);
1763
- }
1764
- return;
1765
- }
1766
- // Active owner is alive but may have rebound to a new port since the
1767
- // previous refresh (owner restart on a different PROXY_PORT). Sync
1768
- // ownerHttpPort so subsequent proxyRequest() hits the new port instead
1769
- // of the stale value cached at proxy-mode entry.
1770
- if (ownerHttpPort !== activeHttpPort) {
1771
- ownerHttpPort = activeHttpPort;
1772
- logOwnership(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
1773
- }
1774
- return;
1775
- }
1776
- if (!owned && activeHttpPort) {
1777
- const alive = await checkActiveHttp();
1778
- if (alive) {
1779
- enterProxyMode(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
1780
- return;
1781
- }
1782
- const updatedAt = Number(active?.updatedAt);
1783
- const activeAgeMs = Number.isFinite(updatedAt) ? Date.now() - updatedAt : Number.POSITIVE_INFINITY;
1784
- if (active?.backendReady === true || activeAgeMs > ACTIVE_OWNER_STALE_MS) {
1785
- logOwnership(`owner ${active.instanceId} port ${activeHttpPort} unreachable`);
1786
- claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
1787
- }
1788
- }
1789
- if (!owned && canStealOwnership(active)) {
1790
- claimBridgeOwnership(active ? `takeover from ${active.instanceId}` : "startup");
1791
- }
1792
- const next = currentOwnerState();
1793
- if (next.owned) {
1128
+ if (owned) {
1794
1129
  refreshActiveInstance(INSTANCE_ID);
1795
1130
  await startOwnedRuntime(options);
1796
1131
  return;
1797
1132
  }
1798
- if (bridgeRuntimeConnected) {
1799
- const reason = next.active?.instanceId ? `newer server ${next.active.instanceId}` : "no active owner";
1800
- await stopOwnedRuntime(reason);
1801
- return;
1802
- }
1803
- if (next.active?.httpPort && !proxyMode) {
1804
- const alive = await pingOwner(next.active.httpPort);
1805
- if (alive) {
1806
- proxyMode = true;
1807
- ownerHttpPort = next.active.httpPort;
1808
- logOwnership(`proxy mode via owner ${next.active.instanceId} port ${next.active.httpPort}`);
1809
- return;
1133
+ // Not the owner. Two sub-cases:
1134
+ // (a) A live remote session holds the seat (active-instance names a
1135
+ // different, non-stale instance) → last-wins: we lost, go quiet
1136
+ // (disconnect if we were connected).
1137
+ // (b) There is NO live owner (active is null/stale — e.g. our own entry
1138
+ // was cleared after a backend-connect failure or a bridge
1139
+ // deactivate/reactivate) this remote session claims the empty seat
1140
+ // and starts the owned runtime. Without this, a remote session could
1141
+ // never (re)acquire ownership once its active entry was cleared.
1142
+ if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
1143
+ if (bridgeRuntimeConnected) {
1144
+ await stopOwnedRuntime("ownership lost (newer remote session)");
1810
1145
  }
1146
+ return;
1811
1147
  }
1812
- if (next.active?.instanceId) {
1813
- logOwnership(`standby under owner ${next.active.instanceId}`);
1148
+ // No live owner — claim the empty seat and start.
1149
+ claimBridgeOwnership("no active owner");
1150
+ if (currentOwnerState().owned) {
1151
+ await startOwnedRuntime(options);
1814
1152
  }
1815
1153
  })();
1816
1154
  try {
@@ -1839,6 +1177,24 @@ async function reloadRuntimeConfig() {
1839
1177
  const shouldRestart = bridgeRuntimeConnected || bridgeRuntimeStarting;
1840
1178
  if (shouldRestart) await stopOwnedRuntime("backend config changed");
1841
1179
  backend = nextBackend;
1180
+ // The persisted routing channelId belongs to the OLD backend (e.g. a
1181
+ // Discord snowflake) and is meaningless for the new one — sending to it
1182
+ // would 400 "chat not found". There is no id mapping between platforms, so
1183
+ // CLEAR the stale binding: drop the forwarder's context + watcher and wipe
1184
+ // status.channelId/transcriptPath. The next inbound from the new backend
1185
+ // rebinds the correct chat via applyTranscriptBinding(). Only done on
1186
+ // backendChanged — same-backend reloads keep their binding untouched.
1187
+ // (active-instance is cleared by stopOwnedRuntime on the restart path; we
1188
+ // don't re-advertise here to avoid resurrecting a just-cleared entry.)
1189
+ try { forwarder.stopWatch(); } catch {}
1190
+ forwarder.channelId = "";
1191
+ forwarder.transcriptPath = "";
1192
+ try {
1193
+ statusState.update((state) => {
1194
+ state.channelId = "";
1195
+ state.transcriptPath = "";
1196
+ });
1197
+ } catch {}
1842
1198
  if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
1843
1199
  } else if (nextBackend !== previousBackend) {
1844
1200
  try { await nextBackend.disconnect?.(); } catch {}
@@ -2013,11 +1369,10 @@ function wireEventQueueHandlers(eventQueue) {
2013
1369
  injectAndRecord(channelId, name, content, options);
2014
1370
  });
2015
1371
  // Defensive ownership probe: the queue tick should only run in the active
2016
- // owner process. Standby / proxy instances see bridgeRuntimeConnected=false
2017
- // or proxyMode=true and will skip the tick even if an errant start() slipped
2018
- // through.
2019
- eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
2020
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
1372
+ // owner process. Non-owner instances see bridgeRuntimeConnected=false and
1373
+ // will skip the tick even if an errant start() slipped through.
1374
+ eventQueue.setOwnerGetter(() => bridgeRuntimeConnected);
1375
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
2021
1376
  }
2022
1377
  function editDiscordMessage(channelId, messageId, label) {
2023
1378
  // Behavior-preserving: route through the backend abstraction (which uses
@@ -2453,10 +1808,9 @@ function createHttpMcpServer() {
2453
1808
  // `createHttpMcpServer()` above. There is no orphan worker-level Server.
2454
1809
  const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
2455
1810
  // ── Backend-tool dispatch helpers ───────────────────────────────────────────
2456
- // Each helper transparently routes through proxyRequest() when this instance
2457
- // is in proxyMode (non-owner), or through the local backend otherwise. The
2458
- // MCP-result formatting (text shape, cache invalidation, isError flag) is
2459
- // shared so both branches produce byte-identical output.
1811
+ // Each helper dispatches through the local backend (this process is always the
1812
+ // owner in opt-in remote mode). The MCP-result formatting (text shape, cache
1813
+ // invalidation, isError flag) is kept here so results stay consistent.
2460
1814
  // schedule_status / schedule_control share their result-formatting between
2461
1815
  // the local (owner) MCP case handlers and the owner-side HTTP routes that
2462
1816
  // serve proxied standby sessions. Keeping the body here makes both paths
@@ -2503,24 +1857,11 @@ async function dispatchReply(args) {
2503
1857
  components: args.components ?? []
2504
1858
  };
2505
1859
  let ids;
2506
- if (proxyMode) {
2507
- const proxyResult = await proxyRequest("/send", "POST", {
2508
- chatId: args.chat_id,
2509
- text: args.text,
2510
- opts: sendOpts
2511
- });
2512
- if (!proxyResult.ok) {
2513
- return { content: [{ type: "text", text: `proxy reply failed: ${proxyResult.error}` }], isError: true };
2514
- }
2515
- ids = proxyResult.data?.sentIds ?? [];
2516
- } else {
2517
- // Pre-send activity bump keeps idle gating consistent during the await.
2518
- scheduler.noteActivity();
2519
- const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
2520
- // Lead-originated reply via proxy-mode MCP — bump activity.
2521
- scheduler.noteActivity();
2522
- ids = sendResult.sentIds;
2523
- }
1860
+ // Pre-send activity bump keeps idle gating consistent during the await.
1861
+ scheduler.noteActivity();
1862
+ const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
1863
+ scheduler.noteActivity();
1864
+ ids = sendResult.sentIds;
2524
1865
  const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
2525
1866
  return { content: [{ type: "text", text }] };
2526
1867
  }
@@ -2528,17 +1869,8 @@ async function dispatchFetch(args) {
2528
1869
  const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
2529
1870
  const limit = args.limit ?? 20;
2530
1871
  let msgs;
2531
- if (proxyMode) {
2532
- const proxyResult = await proxyRequest(`/fetch?channel=${encodeURIComponent(channelId)}&limit=${limit}`, "GET");
2533
- if (!proxyResult.ok) {
2534
- return { content: [{ type: "text", text: `proxy fetch failed: ${proxyResult.error}` }], isError: true };
2535
- }
2536
- msgs = proxyResult.data?.messages ?? [];
2537
- // recordFetchedMessages already ran on the owner side (/fetch route).
2538
- } else {
2539
- msgs = await backend.fetchMessages(channelId, limit);
2540
- recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
2541
- }
1872
+ msgs = await backend.fetchMessages(channelId, limit);
1873
+ recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
2542
1874
  const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
2543
1875
  const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
2544
1876
  return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
@@ -2546,53 +1878,18 @@ async function dispatchFetch(args) {
2546
1878
  return { content: [{ type: "text", text }] };
2547
1879
  }
2548
1880
  async function dispatchReact(args) {
2549
- if (proxyMode) {
2550
- const proxyResult = await proxyRequest("/react", "POST", {
2551
- chatId: args.chat_id,
2552
- messageId: args.message_id,
2553
- emoji: args.emoji
2554
- });
2555
- if (!proxyResult.ok) {
2556
- return { content: [{ type: "text", text: `proxy react failed: ${proxyResult.error}` }], isError: true };
2557
- }
2558
- } else {
2559
- await backend.react(args.chat_id, args.message_id, args.emoji);
2560
- }
1881
+ await backend.react(args.chat_id, args.message_id, args.emoji);
2561
1882
  return { content: [{ type: "text", text: "reacted" }] };
2562
1883
  }
2563
1884
  async function dispatchEditMessage(args) {
2564
1885
  const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
2565
1886
  let id;
2566
- if (proxyMode) {
2567
- const proxyResult = await proxyRequest("/edit", "POST", {
2568
- chatId: args.chat_id,
2569
- messageId: args.message_id,
2570
- text: args.text,
2571
- opts
2572
- });
2573
- if (!proxyResult.ok) {
2574
- return { content: [{ type: "text", text: `proxy edit failed: ${proxyResult.error}` }], isError: true };
2575
- }
2576
- id = proxyResult.data?.id;
2577
- } else {
2578
- id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
2579
- }
1887
+ id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
2580
1888
  return { content: [{ type: "text", text: `edited (id: ${id})` }] };
2581
1889
  }
2582
1890
  async function dispatchDownloadAttachment(args) {
2583
1891
  let files;
2584
- if (proxyMode) {
2585
- const proxyResult = await proxyRequest("/download", "POST", {
2586
- chatId: args.chat_id,
2587
- messageId: args.message_id
2588
- });
2589
- if (!proxyResult.ok) {
2590
- return { content: [{ type: "text", text: `proxy download failed: ${proxyResult.error}` }], isError: true };
2591
- }
2592
- files = proxyResult.data?.files ?? [];
2593
- } else {
2594
- files = await backend.downloadAttachment(args.chat_id, args.message_id);
2595
- }
1892
+ files = await backend.downloadAttachment(args.chat_id, args.message_id);
2596
1893
  if (files.length === 0) {
2597
1894
  return { content: [{ type: "text", text: "message has no attachments" }] };
2598
1895
  }
@@ -2633,97 +1930,35 @@ async function handleToolCall(name, args, _signal) {
2633
1930
  result = await dispatchDownloadAttachment(args);
2634
1931
  break;
2635
1932
  case "schedule_status": {
2636
- if (proxyMode) {
2637
- const proxyResult = await proxyRequest("/schedule-status", "GET");
2638
- if (!proxyResult.ok) {
2639
- result = { content: [{ type: "text", text: `proxy schedule_status failed: ${proxyResult.error}` }], isError: true };
2640
- break;
2641
- }
2642
- result = proxyResult.data?.result ?? { content: [{ type: "text", text: "no schedules configured" }] };
2643
- } else {
2644
- result = scheduleStatusResult();
2645
- }
1933
+ result = scheduleStatusResult();
2646
1934
  break;
2647
1935
  }
2648
1936
  case "trigger_schedule": {
2649
- if (proxyMode) {
2650
- const proxyResult = await proxyRequest("/trigger-schedule", "POST", { name: args.name });
2651
- if (!proxyResult.ok) {
2652
- result = { content: [{ type: "text", text: `proxy trigger_schedule failed: ${proxyResult.error}` }], isError: true };
2653
- break;
2654
- }
2655
- const triggerResult = proxyResult.data?.result;
2656
- result = { content: [{ type: "text", text: triggerResult == null ? "" : String(triggerResult) }] };
2657
- } else {
2658
- const triggerResult = await scheduler.triggerManual(args.name);
2659
- result = { content: [{ type: "text", text: triggerResult }] };
2660
- }
1937
+ const triggerResult = await scheduler.triggerManual(args.name);
1938
+ result = { content: [{ type: "text", text: triggerResult }] };
2661
1939
  break;
2662
1940
  }
2663
1941
  case "schedule_control": {
2664
- if (proxyMode) {
2665
- const proxyResult = await proxyRequest("/schedule-control", "POST", {
2666
- name: args.name,
2667
- action: args.action,
2668
- minutes: args.minutes
2669
- });
2670
- if (!proxyResult.ok) {
2671
- result = { content: [{ type: "text", text: `proxy schedule_control failed: ${proxyResult.error}` }], isError: true };
2672
- break;
2673
- }
2674
- result = proxyResult.data?.result ?? { content: [{ type: "text", text: `unknown action: ${args.action}` }], isError: true };
2675
- } else {
2676
- result = scheduleControlResult(args);
2677
- }
1942
+ result = scheduleControlResult(args);
2678
1943
  break;
2679
1944
  }
2680
1945
  case "activate_channel_bridge": {
2681
- if (proxyMode) {
2682
- const proxyRes = await proxyRequest("/bridge/activate", "POST", { active: args.active === true });
2683
- if (!proxyRes.ok) {
2684
- result = { content: [{ type: "text", text: `proxy bridge activate failed: ${proxyRes.error}` }], isError: true };
2685
- } else {
2686
- channelBridgeActive = Boolean(args.active);
2687
- writeBridgeState(channelBridgeActive);
2688
- // Remote owner just deactivated and is tearing its owner-HTTP
2689
- // server down. Drop our proxy pointer so subsequent direct
2690
- // tool calls don't route through proxyRequest() to a port
2691
- // about to close (ECONNREFUSED) or stripped of auth (401).
2692
- if (!args.active) {
2693
- proxyMode = false;
2694
- ownerHttpPort = 0;
2695
- }
2696
- result = { content: [{ type: "text", text: `channel bridge ${args.active ? "activated" : "deactivated"}` }] };
2697
- }
2698
- } else {
2699
- const active = args.active === true;
2700
- const wasActive = channelBridgeActive;
2701
- channelBridgeActive = active;
2702
- writeBridgeState(active);
2703
- if (active && !wasActive) {
2704
- refreshBridgeOwnershipSafe({ restoreBinding: true });
2705
- }
2706
- if (!active && wasActive) {
2707
- stopServerTyping();
2708
- // Tear down the owner-side runtime so Discord/scheduler/webhook/
2709
- // event-pipeline/owner-HTTP/heartbeat don't keep running on a
2710
- // deactivated bridge (and to prevent this owner from later
2711
- // entering proxyMode against its own port).
2712
- try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
2713
- process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
2714
- }
2715
- // Also clear proxyMode/ownerHttpPort. Without this, a session
2716
- // that was acting as proxy when deactivate landed keeps the
2717
- // stale flag + port set; later direct tool calls then route
2718
- // through proxyRequest() to a port whose owner has just been
2719
- // stopped or stripped of auth, returning ECONNREFUSED/401.
2720
- if (proxyMode) {
2721
- proxyMode = false;
2722
- ownerHttpPort = 0;
2723
- }
1946
+ const active = args.active === true;
1947
+ const wasActive = channelBridgeActive;
1948
+ channelBridgeActive = active;
1949
+ writeBridgeState(active);
1950
+ if (active && !wasActive) {
1951
+ refreshBridgeOwnershipSafe({ restoreBinding: true });
1952
+ }
1953
+ if (!active && wasActive) {
1954
+ stopServerTyping();
1955
+ // Tear down the owner-side runtime so Discord/scheduler/webhook/
1956
+ // event-pipeline don't keep running on a deactivated bridge.
1957
+ try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
1958
+ process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
2724
1959
  }
2725
- result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
2726
1960
  }
1961
+ result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
2727
1962
  break;
2728
1963
  }
2729
1964
  case "reload_config": {
@@ -2746,7 +1981,7 @@ async function handleToolCall(name, args, _signal) {
2746
1981
  }
2747
1982
  case "inject_command": {
2748
1983
  const cmd = String(args?.command || "").trim();
2749
- const ALLOW = new Set(["reload-plugins", "clear"]);
1984
+ const ALLOW = new Set(["clear"]);
2750
1985
  if (!ALLOW.has(cmd)) {
2751
1986
  result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
2752
1987
  break;
@@ -2802,36 +2037,21 @@ async function handleToolCallWithBridgeRetry(toolName, args, signal) {
2802
2037
  _lastForwardMs = now;
2803
2038
  await forwarder.forwardNewText();
2804
2039
  }
2805
- if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected && !proxyMode) {
2806
- if (_isCliOwnedMode) {
2807
- await startCliOwnedRuntime({ restoreBinding: true });
2808
- if (!bridgeRuntimeConnected) {
2809
- return {
2810
- content: [{ type: "text", text: `Channel runtime is not connected. Check token and network.` }],
2811
- isError: true
2812
- };
2813
- }
2814
- } else {
2815
- // Do NOT pre-claim ownership here. claimBridgeOwnership() overwrites the
2816
- // active-instance advert immediately, which kicks a live owner offline if
2817
- // refreshBridgeOwnership() would have otherwise discovered them via
2818
- // pingOwner() and entered proxyMode. Let refreshBridgeOwnership() below
2819
- // ping/proxy the existing owner first and only fall through to a takeover
2820
- // when the live owner is unreachable.
2821
- for (let i = 0; i < 2 && !bridgeRuntimeConnected && !proxyMode; i++) {
2040
+ if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected) {
2041
+ // Remote-owner startup: ensure this owner's backend is connected.
2042
+ for (let i = 0; i < 2 && !bridgeRuntimeConnected; i++) {
2822
2043
  try {
2823
2044
  await refreshBridgeOwnership();
2824
2045
  } catch {
2825
2046
  }
2826
- if (!bridgeRuntimeConnected && !proxyMode) await new Promise((r) => setTimeout(r, 300));
2047
+ if (!bridgeRuntimeConnected) await new Promise((r) => setTimeout(r, 300));
2827
2048
  }
2828
- if (!bridgeRuntimeConnected && !proxyMode) {
2049
+ if (!bridgeRuntimeConnected) {
2829
2050
  return {
2830
2051
  content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
2831
2052
  isError: true
2832
2053
  };
2833
2054
  }
2834
- }
2835
2055
  }
2836
2056
  const result = await handleToolCall(toolName, args, signal);
2837
2057
  const toolLine = OutputForwarder.buildToolLine(toolName, args);
@@ -2967,14 +2187,42 @@ backend.onMessage = (msg) => {
2967
2187
  }).finally(() => forwarder.reset());
2968
2188
  const previousPath = getPersistedTranscriptPath();
2969
2189
  let boundTranscript = null;
2190
+ let stoleSelfTranscript = false;
2970
2191
  let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
2192
+ // Reuse the current binding only while it still points at THIS owner's own
2193
+ // session. discoverSessionBoundTranscript() now ranks the live parent-chain
2194
+ // session (the one that forked this worker and receives injected input)
2195
+ // above a more-recently-touched neighbour, so when a co-located session
2196
+ // owns the stale binding we steal it back here instead of tailing the wrong
2197
+ // transcript for the rest of the process lifetime. Steal whenever the live
2198
+ // parent-chain (self) candidate resolves to a different path — even when its
2199
+ // transcript is not on disk yet: we keep selfBound.exists=false so the
2200
+ // downstream `!boundTranscript?.exists` branch routes through
2201
+ // rebindTranscriptContext()'s pending-bind + re-arm poll, which forwards the
2202
+ // first assistant reply once the self transcript is created. Marking the
2203
+ // stale neighbour path as exists=true here would suppress that rearm and
2204
+ // keep tailing the wrong session for the whole turn.
2971
2205
  if (transcriptPath) {
2972
- boundTranscript = {
2973
- sessionId: sessionIdFromTranscriptPath(transcriptPath),
2974
- sessionCwd: statusState.read().sessionCwd ?? null,
2975
- transcriptPath,
2976
- exists: true
2977
- };
2206
+ const selfBound = discoverSessionBoundTranscript();
2207
+ const shouldStealBoundTranscript = Boolean(
2208
+ selfBound?.transcriptPath &&
2209
+ !sameResolvedPath(selfBound.transcriptPath, transcriptPath) &&
2210
+ selfBound.active === true &&
2211
+ (selfBound.parentChain === true || selfBound.cwdMatches === true)
2212
+ );
2213
+ if (shouldStealBoundTranscript) {
2214
+ process.stderr.write(`mixdog: inbound rebind: stealing transcript ${transcriptPath} -> ${selfBound.transcriptPath} (source=${selfBound.source || "unknown"}, exists=${selfBound.exists})\n`);
2215
+ transcriptPath = selfBound.transcriptPath;
2216
+ boundTranscript = selfBound;
2217
+ stoleSelfTranscript = true;
2218
+ } else {
2219
+ boundTranscript = {
2220
+ sessionId: sessionIdFromTranscriptPath(transcriptPath),
2221
+ sessionCwd: statusState.read().sessionCwd ?? null,
2222
+ transcriptPath,
2223
+ exists: true
2224
+ };
2225
+ }
2978
2226
  } else {
2979
2227
  boundTranscript = discoverSessionBoundTranscript();
2980
2228
  transcriptPath = pickUsableTranscriptPath(boundTranscript, previousPath);
@@ -3013,8 +2261,17 @@ backend.onMessage = (msg) => {
3013
2261
  });
3014
2262
  if (!boundTranscript?.exists) {
3015
2263
  await rebindTranscriptContext(route.targetChatId, {
2264
+ // For a stolen self transcript (not yet on disk) the sync bind above
2265
+ // persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
2266
+ // setContext resume from offset 0 once the file appears — forwarding
2267
+ // the first assistant reply. Relying on replayFromStart instead would
2268
+ // race: the discovery loop only sets replayFromStart when it first saw
2269
+ // the transcript as PENDING, so a file that already exists on the first
2270
+ // loop iteration would bind at EOF and skip the reply. Non-steal keeps
2271
+ // the original catch-up-from-cursor behaviour.
3016
2272
  previousPath: transcriptPath,
3017
2273
  catchUp: true,
2274
+ catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
3018
2275
  persistStatus: true
3019
2276
  });
3020
2277
  }
@@ -3041,24 +2298,29 @@ async function handleInbound(msg, route, options = {}) {
3041
2298
  let text = msg.text;
3042
2299
  const voiceAtts = msg.attachments.filter((a) => isVoiceAttachment(a.contentType));
3043
2300
  if (voiceAtts.length > 0) {
3044
- try {
3045
- const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
3046
- // concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
3047
- for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
3048
- const _t0 = Date.now();
3049
- const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
3050
- const _elapsed = Date.now() - _t0;
3051
- if (transcript) {
3052
- text = transcript;
3053
- process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
3054
- } else {
3055
- process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
3056
- text = text || "[voice message \u2014 transcription failed]";
2301
+ if (config.voice?.enabled === false) {
2302
+ process.stderr.write(`mixdog: voice.transcription skipped voice.enabled=false\n`);
2303
+ text = text || "[voice message]";
2304
+ } else {
2305
+ try {
2306
+ const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
2307
+ // concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
2308
+ for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
2309
+ const _t0 = Date.now();
2310
+ const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
2311
+ const _elapsed = Date.now() - _t0;
2312
+ if (transcript) {
2313
+ text = transcript;
2314
+ process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
2315
+ } else {
2316
+ process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
2317
+ text = text || "[voice message \u2014 transcription failed]";
2318
+ }
3057
2319
  }
2320
+ } catch (err) {
2321
+ process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
2322
+ text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
3058
2323
  }
3059
- } catch (err) {
3060
- process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
3061
- text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
3062
2324
  }
3063
2325
  }
3064
2326
  const hasVoiceAtt = voiceAtts.length > 0;
@@ -3067,7 +2329,6 @@ async function handleInbound(msg, route, options = {}) {
3067
2329
  attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
3068
2330
  } : {};
3069
2331
  const messageBody = route.sourceMode === "monitor" && route.sourceLabel ? `[monitor:${route.sourceLabel}] ${text}` : text;
3070
- const now = (/* @__PURE__ */ new Date()).toLocaleString();
3071
2332
  const notificationMeta = {
3072
2333
  chat_id: route.targetChatId,
3073
2334
  message_id: msg.messageId,
@@ -3082,8 +2343,7 @@ async function handleInbound(msg, route, options = {}) {
3082
2343
  ...attMeta,
3083
2344
  ...msg.imagePath ? { image_path: msg.imagePath } : {}
3084
2345
  };
3085
- const notificationContent = `[${now}]
3086
- ${messageBody}`;
2346
+ const notificationContent = messageBody;
3087
2347
  sendNotifyToParent("notifications/claude/channel", {
3088
2348
  content: notificationContent,
3089
2349
  meta: notificationMeta
@@ -3115,19 +2375,47 @@ async function init(_sharedMcp) {
3115
2375
  });
3116
2376
  }
3117
2377
  async function start() {
3118
- startChannelDaemonIdleMonitor();
3119
2378
  channelBridgeActive = true;
3120
2379
  writeBridgeState(true);
3121
- if (_isCliOwnedMode) {
3122
- await startCliOwnedRuntime({ restoreBinding: true });
3123
- } else {
3124
- await refreshBridgeOwnership({ restoreBinding: true });
2380
+ // Opt-in remote, single-owner, last-wins. Claim the seat immediately so a
2381
+ // later `mixdog --remote` session overwrites us and we drop on our next
2382
+ // refresh tick. Then connect the owned runtime and arm the ownership timer
2383
+ // that keeps checking whether a newer session has taken over.
2384
+ claimBridgeOwnership("remote start");
2385
+ const _bindingReadyStart = Date.now();
2386
+ try {
2387
+ await refreshBridgeOwnership({ restoreBinding: true });
2388
+ bindingReadyStatus = "resolved";
2389
+ dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
2390
+ _bindingReadyResolve(true);
2391
+ } catch (e) {
2392
+ bindingReadyStatus = "rejected";
2393
+ dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
2394
+ _bindingReadyResolve(e);
2395
+ }
2396
+ // Ownership timer: keep checking whether a newer remote session has taken
2397
+ // over (last-wins) so a superseded owner disconnects promptly.
2398
+ if (!bridgeOwnershipTimer) {
2399
+ bridgeOwnershipTimer = setInterval(() => {
2400
+ refreshBridgeOwnershipSafe();
2401
+ }, 3e3);
2402
+ bridgeOwnershipTimer.unref?.();
2403
+ }
2404
+ // Hot-reload config on file change (schedules/webhooks/events).
2405
+ if (!_configWatcher) {
2406
+ try {
2407
+ _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
2408
+ if (_reloadDebounce) clearTimeout(_reloadDebounce);
2409
+ _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
2410
+ });
2411
+ } catch {}
3125
2412
  }
3126
2413
  // Pre-warm the whisper-server manager once at owner startup so the first
3127
2414
  // voice transcription does not pay cold-start cost. Non-blocking: failures
3128
2415
  // (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
3129
2416
  void (async () => {
3130
2417
  try {
2418
+ if (config.voice?.enabled === false) return;
3131
2419
  const runtime = resolveVoiceRuntime(DATA_DIR);
3132
2420
  if (!runtime?.installed) return;
3133
2421
  const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
@@ -3139,7 +2427,6 @@ async function start() {
3139
2427
  })();
3140
2428
  }
3141
2429
  async function stop() {
3142
- stopChannelDaemonIdleMonitor();
3143
2430
  try { await stopVoiceWhisperServer(); } catch {}
3144
2431
  await stopOwnedRuntime("unified server stop");
3145
2432
  cleanupInstanceRuntimeFiles(INSTANCE_ID);
@@ -3147,113 +2434,13 @@ async function stop() {
3147
2434
  clearInterval(bridgeOwnershipTimer);
3148
2435
  bridgeOwnershipTimer = null;
3149
2436
  }
2437
+ if (_reloadDebounce) { clearTimeout(_reloadDebounce); _reloadDebounce = null; }
2438
+ if (_configWatcher) { try { _configWatcher.close(); } catch {} _configWatcher = null; }
3150
2439
  if (turnEndWatcher) {
3151
2440
  try { turnEndWatcher.close(); } catch {}
3152
2441
  turnEndWatcher = null;
3153
2442
  }
3154
2443
  }
3155
- if (process.env.MIXDOG_CHANNELS_AUTO_BOOT !== '0') {
3156
- let detectChannelFlag = function() {
3157
- const isWin = process.platform === "win32";
3158
- const flagRe = /--channels\b|--dangerously-load-development-channels\b/;
3159
- if (process.env.MIXDOG_CHANNEL_FLAG === "1") return true;
3160
- if (process.env.MIXDOG_CHANNEL_FLAG === "0") return false;
3161
- if (isWin) {
3162
- // Single CIM snapshot + in-process chain walk: one powershell.exe spawn
3163
- // instead of up to 12 synchronous wmic/powershell spawns. Snapshots all
3164
- // processes into a map, walks from process.ppid up to 6 ancestors
3165
- // (closest first), and emits each ancestor CommandLine on its own line
3166
- // for the same flagRe test below. Any failure returns false.
3167
- try {
3168
- const ps = [
3169
- '$procs = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine;',
3170
- '$map = @{};',
3171
- 'foreach ($p in $procs) { $map[[int]$p.ProcessId] = $p }',
3172
- `$cur = ${Number(process.ppid)};`,
3173
- 'for ($i = 0; $i -lt 6; $i++) {',
3174
- ' if (-not $cur -or $cur -le 1) { break }',
3175
- ' $p = $map[[int]$cur]; if ($null -eq $p) { break }',
3176
- ' [Console]::WriteLine($p.CommandLine);',
3177
- ' $next = [int]$p.ParentProcessId;',
3178
- ' if ($next -eq [int]$cur -or $next -le 1) { break }',
3179
- ' $cur = $next',
3180
- '}',
3181
- ].join(" ");
3182
- const r = spawnSync("powershell.exe", ["-NoProfile", "-Command", ps], {
3183
- encoding: "utf8",
3184
- timeout: 5e3,
3185
- windowsHide: true,
3186
- });
3187
- const out = String(r.stdout || "");
3188
- for (const line of out.split(/\r?\n/)) {
3189
- if (flagRe.test(line)) return true;
3190
- }
3191
- } catch {}
3192
- return false;
3193
- }
3194
- let pid = process.ppid;
3195
- for (let depth = 0; pid && pid > 1 && depth < 6; depth++) {
3196
- try {
3197
- const cmdLine = execSync(`ps -p ${pid} -o args=`, { encoding: "utf8", timeout: 3e3, windowsHide: true });
3198
- if (flagRe.test(cmdLine)) return true;
3199
- pid = parseInt(execSync(`ps -p ${pid} -o ppid=`, { encoding: "utf8", timeout: 3e3, windowsHide: true }).trim(), 10);
3200
- } catch {
3201
- break;
3202
- }
3203
- }
3204
- return false;
3205
- };
3206
- _channelFlagDetected = detectChannelFlag();
3207
- if (isMixdogDebug()) {
3208
- fs.appendFileSync(_bootLog, `[${localTimestamp()}] channelFlag: ${_channelFlagDetected}\n`);
3209
- if (_channelFlagDetected) {
3210
- fs.appendFileSync(_bootLog, `[${localTimestamp()}] channel mode detected — bridge auto-activated\n`);
3211
- }
3212
- }
3213
- if (_channelFlagDetected) {
3214
- channelBridgeActive = true;
3215
- }
3216
- writeBridgeState(channelBridgeActive);
3217
- const previousOwner = readActiveInstance();
3218
- noteStartupHandoff(previousOwner);
3219
- // Do not claim ownership just because this terminal is channel-capable.
3220
- // refreshBridgeOwnership() below pings/proxies a live owner first and only
3221
- // claims when there is no reachable active owner or the record is stale.
3222
- const _bindingReadyStart = Date.now();
3223
- void refreshBridgeOwnership({ restoreBinding: true }).then(
3224
- (v) => {
3225
- bindingReadyStatus = "resolved";
3226
- dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
3227
- _bindingReadyResolve(v);
3228
- },
3229
- (e) => {
3230
- bindingReadyStatus = "rejected";
3231
- dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
3232
- _bindingReadyResolve(e);
3233
- }
3234
- );
3235
- bridgeOwnershipTimer = setInterval(() => {
3236
- refreshBridgeOwnershipSafe();
3237
- }, 3e3);
3238
- // Hook/statusline IPC is owned by the MCP parent process so it is available
3239
- // before channels finishes bridge ownership and backend startup.
3240
- const configPath = path.join(DATA_DIR, "mixdog-config.json");
3241
- let reloadDebounce = null;
3242
- let configWatcher = null;
3243
- try {
3244
- configWatcher = fs.watch(configPath, () => {
3245
- if (reloadDebounce) clearTimeout(reloadDebounce);
3246
- reloadDebounce = setTimeout(() => {
3247
- reloadRuntimeConfig().catch(() => {});
3248
- }, 500);
3249
- });
3250
- } catch {
3251
- }
3252
- process.on("exit", () => {
3253
- if (configWatcher) { try { configWatcher.close(); } catch {} }
3254
- if (bridgeOwnershipTimer) { clearInterval(bridgeOwnershipTimer); }
3255
- });
3256
- }
3257
2444
  // ── IPC worker mode ──────────────────────────────────────────────
3258
2445
  if (_isWorkerMode && process.send) {
3259
2446
  // SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
@@ -3415,11 +2602,11 @@ if (_isWorkerMode && process.send) {
3415
2602
  try {
3416
2603
  await start()
3417
2604
  bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1) })
3418
- process.send({ type: 'ready', channelFlag: _channelFlagDetected })
2605
+ process.send({ type: 'ready' })
3419
2606
  } catch (e) {
3420
2607
  bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e?.message || String(e) })
3421
2608
  process.stderr.write(`[channels-worker] start() failed: ${e && (e.message || e)}\n`)
3422
- process.send({ type: 'ready', channelFlag: _channelFlagDetected, degraded: true, error: e?.message || String(e) })
2609
+ process.send({ type: 'ready', degraded: true, error: e?.message || String(e) })
3423
2610
  }
3424
2611
  })()
3425
2612
  }