mixdog 0.9.18 → 0.9.20

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 (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -29
  3. package/package.json +5 -3
  4. package/scripts/build-runtime-windows.ps1 +242 -242
  5. package/scripts/build-tui.mjs +6 -0
  6. package/scripts/hook-bus-test.mjs +8 -0
  7. package/scripts/log-writer-guard-smoke.mjs +131 -0
  8. package/scripts/output-style-smoke.mjs +2 -2
  9. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  10. package/scripts/recall-bench-cases.json +10 -0
  11. package/scripts/recall-bench.mjs +91 -2
  12. package/scripts/recall-quality-cases.json +1 -2
  13. package/scripts/recall-usecase-cases.json +1 -1
  14. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  15. package/scripts/smoke-runtime-negative.ps1 +106 -106
  16. package/scripts/tool-efficiency-diag.mjs +5 -2
  17. package/scripts/tool-smoke.mjs +251 -72
  18. package/src/agents/debugger/AGENT.md +3 -3
  19. package/src/agents/heavy-worker/AGENT.md +7 -10
  20. package/src/agents/maintainer/AGENT.md +1 -2
  21. package/src/agents/reviewer/AGENT.md +1 -2
  22. package/src/agents/worker/AGENT.md +5 -8
  23. package/src/defaults/agents.json +3 -0
  24. package/src/help.mjs +2 -5
  25. package/src/lib/mixdog-debug.cjs +13 -0
  26. package/src/mixdog-session-runtime.mjs +7 -3311
  27. package/src/rules/agent/00-core.md +4 -3
  28. package/src/rules/agent/30-explorer.md +53 -22
  29. package/src/rules/lead/02-channels.md +3 -3
  30. package/src/rules/lead/lead-tool.md +3 -2
  31. package/src/rules/shared/01-tool.md +24 -29
  32. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  38. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  39. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  42. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  43. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  44. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  45. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  47. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  48. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  49. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  50. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  51. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  52. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  53. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  54. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  55. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  57. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  58. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  59. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  60. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  61. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  62. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  63. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  64. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  66. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  67. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  68. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  72. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  73. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  74. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  76. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  79. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  80. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  82. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  83. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  84. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  85. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  86. package/src/runtime/channels/backends/discord.mjs +43 -1
  87. package/src/runtime/channels/index.mjs +6 -2096
  88. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  89. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  90. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  91. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  92. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  93. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  94. package/src/runtime/channels/lib/owned-runtime.mjs +547 -0
  95. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  96. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  97. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  98. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  99. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  100. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  101. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  102. package/src/runtime/channels/lib/worker-main.mjs +771 -0
  103. package/src/runtime/memory/index.mjs +73 -1725
  104. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  105. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  106. package/src/runtime/memory/lib/http-router.mjs +772 -0
  107. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  108. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  109. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  110. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  111. package/src/runtime/memory/lib/query-handlers.mjs +38 -6
  112. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  113. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  114. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  115. package/src/runtime/shared/atomic-file.mjs +10 -4
  116. package/src/runtime/shared/background-tasks.mjs +4 -2
  117. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  118. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  119. package/src/runtime/shared/tool-surface.mjs +30 -1
  120. package/src/session-runtime/boot-profile.mjs +36 -0
  121. package/src/session-runtime/channel-config-api.mjs +70 -0
  122. package/src/session-runtime/config-lifecycle.mjs +1 -1
  123. package/src/session-runtime/context-status.mjs +181 -0
  124. package/src/session-runtime/cwd-plugins.mjs +46 -3
  125. package/src/session-runtime/env.mjs +17 -0
  126. package/src/session-runtime/lifecycle-api.mjs +242 -0
  127. package/src/session-runtime/mcp-glue.mjs +24 -3
  128. package/src/session-runtime/model-route-api.mjs +198 -0
  129. package/src/session-runtime/output-styles.mjs +44 -10
  130. package/src/session-runtime/provider-auth-api.mjs +135 -0
  131. package/src/session-runtime/resource-api.mjs +282 -0
  132. package/src/session-runtime/runtime-core.mjs +2046 -0
  133. package/src/session-runtime/session-turn-api.mjs +274 -0
  134. package/src/session-runtime/tool-catalog.mjs +18 -264
  135. package/src/session-runtime/tool-defs.mjs +2 -2
  136. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  137. package/src/session-runtime/workflow.mjs +16 -1
  138. package/src/standalone/agent-tool.mjs +2 -2
  139. package/src/standalone/channel-worker.mjs +78 -5
  140. package/src/standalone/explore-tool.mjs +1 -1
  141. package/src/standalone/memory-runtime-proxy.mjs +56 -6
  142. package/src/tui/App.jsx +88 -97
  143. package/src/tui/app/channel-pickers.mjs +45 -0
  144. package/src/tui/app/core-memory-picker.mjs +1 -1
  145. package/src/tui/app/slash-commands.mjs +0 -1
  146. package/src/tui/app/slash-dispatch.mjs +0 -16
  147. package/src/tui/app/transcript-window.mjs +44 -1
  148. package/src/tui/app/use-mouse-input.mjs +15 -2
  149. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  150. package/src/tui/app/use-transcript-scroll.mjs +82 -4
  151. package/src/tui/app/use-transcript-window.mjs +65 -5
  152. package/src/tui/components/PromptInput.jsx +33 -64
  153. package/src/tui/components/ToolExecution.jsx +2 -2
  154. package/src/tui/dist/index.mjs +7908 -7558
  155. package/src/tui/engine/context-state.mjs +145 -0
  156. package/src/tui/engine/prompt-history.mjs +27 -0
  157. package/src/tui/engine/session-api-ext.mjs +478 -0
  158. package/src/tui/engine/session-api.mjs +545 -0
  159. package/src/tui/engine/session-flow.mjs +485 -0
  160. package/src/tui/engine/turn.mjs +1078 -0
  161. package/src/tui/engine.mjs +69 -2582
  162. package/src/tui/index.jsx +7 -0
  163. package/src/tui/lib/voice-setup.mjs +166 -0
  164. package/src/tui/paste-attachments.mjs +12 -5
  165. package/src/tui/prompt-history-store.mjs +125 -12
  166. package/vendor/ink/build/ink.js +16 -1
  167. package/vendor/ink/build/output.js +30 -4
  168. package/vendor/ink/build/render.js +5 -0
  169. package/scripts/bench/cache-probe-tasks.json +0 -8
  170. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  171. package/scripts/bench/lead-review-tasks.json +0 -20
  172. package/scripts/bench/r4-mixed-tasks.json +0 -20
  173. package/scripts/bench/r5-orchestrated-task.json +0 -7
  174. package/scripts/bench/review-tasks.json +0 -20
  175. package/scripts/bench/round-codex.json +0 -114
  176. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  177. package/scripts/bench/round-mixdog-lead.json +0 -269
  178. package/scripts/bench/round-mixdog.json +0 -126
  179. package/scripts/bench/round-r10-bigsample.json +0 -679
  180. package/scripts/bench/round-r11-codexalign.json +0 -257
  181. package/scripts/bench/round-r13-clientmeta.json +0 -464
  182. package/scripts/bench/round-r14-betafeatures.json +0 -466
  183. package/scripts/bench/round-r15-fulldefault.json +0 -462
  184. package/scripts/bench/round-r16-sessionid.json +0 -466
  185. package/scripts/bench/round-r17-wirebytes.json +0 -456
  186. package/scripts/bench/round-r18-prewarm.json +0 -468
  187. package/scripts/bench/round-r19-clean.json +0 -472
  188. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  189. package/scripts/bench/round-r21-delta-retry.json +0 -473
  190. package/scripts/bench/round-r22-full-probe.json +0 -693
  191. package/scripts/bench/round-r23-itemprobe.json +0 -701
  192. package/scripts/bench/round-r24-shapefix.json +0 -677
  193. package/scripts/bench/round-r25-serial.json +0 -464
  194. package/scripts/bench/round-r26-parallel3.json +0 -671
  195. package/scripts/bench/round-r27-parallel10.json +0 -894
  196. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  197. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  198. package/scripts/bench/round-r30-instid.json +0 -253
  199. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  200. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  201. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  202. package/scripts/bench/round-r34-orchestrated.json +0 -120
  203. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  204. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  205. package/scripts/bench/round-r4-codex.json +0 -114
  206. package/scripts/bench/round-r4-mixed.json +0 -225
  207. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  208. package/scripts/bench/round-r6-codex.json +0 -114
  209. package/scripts/bench/round-r6-solo.json +0 -257
  210. package/scripts/bench/round-r7-full.json +0 -254
  211. package/scripts/bench/round-r8-fulldefault.json +0 -255
  212. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  213. package/src/tui/lib/voice-recorder.mjs +0 -469
  214. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,771 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { performance } from "perf_hooks";
5
+ import { createRequire } from "module";
6
+ const _require = createRequire(import.meta.url);
7
+ import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./config.mjs";
8
+ import { resolveVoiceRuntime } from "./voice-runtime-fetcher.mjs";
9
+ import { ensureReady, stopVoiceWhisperServer } from "./whisper-server.mjs";
10
+ import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
11
+ import { captureOriginalUserCwd, readLastSessionCwd } from "../../shared/user-cwd.mjs";
12
+ import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
13
+ import { Scheduler } from "./scheduler.mjs";
14
+ import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./status-snapshot.mjs";
15
+ import { hasPending as dispatchHasPending } from "../../agent/orchestrator/dispatch-persist.mjs";
16
+ import { setListener as setActivityBusListener } from "../../agent/orchestrator/activity-bus.mjs";
17
+ import { stripSoftWarns } from "../../agent/orchestrator/tool-loop-guard.mjs";
18
+ import { WebhookServer } from "./webhook.mjs";
19
+ import { EventPipeline } from "./event-pipeline.mjs";
20
+ import { startCliWorker } from "./cli-worker-host.mjs";
21
+ import {
22
+ OutputForwarder,
23
+ discoverSessionBoundTranscript,
24
+ findLatestTranscriptByMtime,
25
+ sameResolvedPath
26
+ } from "./output-forwarder.mjs";
27
+ import { controlClaudeSession } from "./session-control.mjs";
28
+ import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./state-file.mjs";
29
+ import {
30
+ buildModalRequestSpec,
31
+ PendingInteractionStore
32
+ } from "./interaction-workflows.mjs";
33
+ import {
34
+ ensureRuntimeDirs,
35
+ makeInstanceId,
36
+ getTurnEndPath,
37
+ getStatusPath,
38
+ getPermissionResultPath,
39
+ getChannelOwnerPath,
40
+ getActiveOwnerPid,
41
+ getTerminalLeadPid,
42
+ readActiveInstance,
43
+ refreshActiveInstance,
44
+ cleanupStaleRuntimeFiles,
45
+ probeActiveOwner,
46
+ cleanupInstanceRuntimeFiles,
47
+ releaseOwnedChannelLocks,
48
+ clearActiveInstance,
49
+ notePreviousServerIfAny,
50
+ writeServerPid,
51
+ clearServerPid,
52
+ RUNTIME_ROOT
53
+ } from "./runtime-paths.mjs";
54
+ import { getDiscordToken } from "./config.mjs";
55
+ import { bootProfile, localTimestamp } from "./boot-profile.mjs";
56
+ import {
57
+ isChannelsDegraded,
58
+ logCrash,
59
+ _isBenignCrash,
60
+ BENIGN_CRASH_FATAL_THRESHOLD,
61
+ BENIGN_CRASH_STREAK_WINDOW_MS,
62
+ } from "./crash-log.mjs";
63
+ import { dropTrace, preview, _dtIdxFlush } from "./index-drop-trace.mjs";
64
+ import { createVoiceTranscription } from "./voice-transcription.mjs";
65
+ import { createBackendDispatch } from "./backend-dispatch.mjs";
66
+ import { createParentBridge } from "./parent-bridge.mjs";
67
+ import { createInboundRouting } from "./inbound-routing.mjs";
68
+ import { createToolDispatch } from "./tool-dispatch.mjs";
69
+ import { createOwnerHeartbeat } from "./owner-heartbeat.mjs";
70
+ import { createTranscriptBinding } from "./transcript-binding.mjs";
71
+ import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
72
+ import { runWorkerIpc } from "./worker-ipc.mjs";
73
+ import { createInteractionHandlers } from "./interaction-handlers.mjs";
74
+ import { createInboundHandler } from "./inbound-handler.mjs";
75
+ import { createOwnedRuntime } from "./owned-runtime.mjs";
76
+ import { runWorkerBootstrap } from "./worker-bootstrap.mjs";
77
+ const memoryClientModulePath = new URL("./memory-client.mjs", import.meta.url).href;
78
+ const {
79
+ appendEntry: memoryAppendEntry,
80
+ ingestTranscript: memoryIngestTranscript,
81
+ drainBuffer: memoryDrainBuffer,
82
+ } = await import(memoryClientModulePath);
83
+ // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
84
+ // after an unhandled rejection whose async state was already corrupted
85
+ // (observed: EPERM on active-instance.json rename retry), so it spun
86
+ // forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
87
+ let _benignCrashStreak = 0;
88
+ let _lastBenignCrashAt = 0;
89
+ function _fatalCrash(label, err) {
90
+ logCrash(label, err);
91
+ const benign = _isBenignCrash(err);
92
+ if (benign) {
93
+ const now = Date.now();
94
+ _benignCrashStreak = (now - _lastBenignCrashAt) <= BENIGN_CRASH_STREAK_WINDOW_MS
95
+ ? _benignCrashStreak + 1
96
+ : 1;
97
+ _lastBenignCrashAt = now;
98
+ if (_benignCrashStreak < BENIGN_CRASH_FATAL_THRESHOLD) return;
99
+ } else {
100
+ _benignCrashStreak = 0;
101
+ }
102
+ Promise.resolve()
103
+ .then(() => (typeof stop === "function" ? stop(`fatal:${label}`) : null))
104
+ .catch(() => {})
105
+ .finally(() => {
106
+ try { process.exitCode = 1; } catch {}
107
+ process.exit(1);
108
+ });
109
+ // Best-effort stop() may itself hang (e.g. IPC to a dead child) — a bare
110
+ // .finally() would then never fire and we're back to a zombie. Force the
111
+ // exit unconditionally after a short grace window regardless of outcome.
112
+ setTimeout(() => { try { process.exit(1); } catch {} }, 3000).unref?.();
113
+ }
114
+ process.on("unhandledRejection", (err) => _fatalCrash("unhandled rejection", err));
115
+ process.on("uncaughtException", (err) => _fatalCrash("uncaught exception", err));
116
+ if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
117
+ process.exit(0);
118
+ }
119
+ const _isWorkerMode = process.env.MIXDOG_WORKER_MODE === '1'
120
+ const _bootLogEarly = path.join(
121
+ DATA_DIR || path.join(os.tmpdir(), "mixdog"),
122
+ "boot.log"
123
+ );
124
+ const {
125
+ isMixdogDebugEnabled: isMixdogDebug,
126
+ pruneStalePluginDataLogSiblings,
127
+ appendSessionStartCriticalLog,
128
+ DEFAULT_STALE_LOG_SIBLING_MAX,
129
+ } = _require("../../../lib/mixdog-debug.cjs");
130
+ // One-shot log rotation at worker boot (10 MB threshold, .1 suffix overwrite).
131
+ if (isMixdogDebug()) {
132
+ try { if (fs.statSync(_bootLogEarly).size > 10 * 1024 * 1024) fs.renameSync(_bootLogEarly, _bootLogEarly + '.1') } catch {}
133
+ fs.appendFileSync(_bootLogEarly, `[${localTimestamp()}] bootstrap start pid=${process.pid}
134
+ `);
135
+ }
136
+ const _bootLog = path.join(DATA_DIR, "boot.log");
137
+ let config = loadConfig();
138
+ let backend = createBackend(config);
139
+ const INSTANCE_ID = makeInstanceId();
140
+ const TERMINAL_LEAD_PID = getTerminalLeadPid();
141
+ runWorkerBootstrap({
142
+ instanceId: INSTANCE_ID,
143
+ isWorkerMode: _isWorkerMode,
144
+ pruneStalePluginDataLogSiblings,
145
+ DEFAULT_STALE_LOG_SIBLING_MAX,
146
+ });
147
+ const INSTRUCTIONS = "";
148
+
149
+ // ── Parent notification helper ───────────────────────────────────────
150
+ // This worker has no MCP transport of its own. All notifications flow
151
+ // through IPC to the parent (server.mjs), which owns the single connected
152
+ // MCP `Server` instance. The parent's IPC message handler translates
153
+ // `{type:'notify', method, params}` into `server.notification({method, params})`.
154
+ //
155
+ // Before v0.6.7 the worker had its own orphan `Server` instance that was
156
+ // never `connect()`ed to any transport, so `.notification()` silently
157
+ // threw 'Not connected' inside the SDK and every call was dropped by an
158
+ // outer `.catch(() => {})`. That regression is what this path replaces.
159
+ const {
160
+ sendNotifyToParent,
161
+ callMemoryAction,
162
+ handleMemoryCallResponse,
163
+ } = createParentBridge({ getInstanceId: () => INSTANCE_ID });
164
+ let channelBridgeActive = false;
165
+ function writeBridgeState(active) {
166
+ try {
167
+ const stateFile = path.join(os.tmpdir(), "mixdog", "bridge-state.json");
168
+ fs.mkdirSync(path.dirname(stateFile), { recursive: true });
169
+ fs.writeFileSync(stateFile, JSON.stringify({ active, ts: Date.now() }));
170
+ } catch {
171
+ }
172
+ }
173
+ function isChannelBridgeActive() {
174
+ return channelBridgeActive;
175
+ }
176
+ let typingChannelId = null;
177
+ const pendingSetup = new PendingInteractionStore();
178
+ function startServerTyping(channelId) {
179
+ if (typingChannelId && typingChannelId !== channelId) {
180
+ backend.stopTyping(typingChannelId);
181
+ }
182
+ typingChannelId = channelId;
183
+ backend.startTyping(channelId);
184
+ }
185
+ function stopServerTyping() {
186
+ if (typingChannelId) {
187
+ backend.stopTyping(typingChannelId);
188
+ typingChannelId = null;
189
+ }
190
+ }
191
+ const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
192
+ const TURN_END_BASENAME = path.basename(TURN_END_FILE);
193
+ const TURN_END_DIR = path.dirname(TURN_END_FILE);
194
+ let turnEndWatcher = null;
195
+ // Config hot-reload watcher (installed by start(); torn down by stop()).
196
+ let _configWatcher = null;
197
+ let _reloadDebounce = null;
198
+ if (!_isWorkerMode) {
199
+ removeFileIfExists(TURN_END_FILE);
200
+ turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
201
+ if (filename !== TURN_END_BASENAME) return;
202
+ try {
203
+ const stat = fs.statSync(TURN_END_FILE);
204
+ if (stat.size > 0) {
205
+ stopServerTyping();
206
+ await forwarder.forwardFinalText();
207
+ removeFileIfExists(TURN_END_FILE);
208
+ }
209
+ } catch {
210
+ }
211
+ });
212
+ }
213
+ const STATUS_FILE = getStatusPath(INSTANCE_ID);
214
+ const statusState = new JsonStateFile(STATUS_FILE, {});
215
+ statusState.ensure();
216
+ const forwarder = new OutputForwarder({
217
+ send: async (ch, text, opts) => {
218
+ if (!channelBridgeActive) {
219
+ throw new Error("send() called while channel bridge is inactive");
220
+ }
221
+ await backend.sendMessage(ch, text, opts);
222
+ },
223
+ formatOutgoing: (text) => backend.formatOutgoing ? backend.formatOutgoing(text) : text,
224
+ recordAssistantTurn: async () => {
225
+ },
226
+ react: (ch, mid, emoji) => {
227
+ if (!channelBridgeActive) return Promise.resolve();
228
+ return backend.react(ch, mid, emoji);
229
+ },
230
+ removeReaction: (ch, mid, emoji) => {
231
+ if (!channelBridgeActive) return Promise.resolve();
232
+ return backend.removeReaction(ch, mid, emoji);
233
+ },
234
+ // Watchdog backstop: force the backend to tear down + rebuild a wedged
235
+ // client so an over-budget send fails fast and the queue releases.
236
+ resetBackend: async () => {
237
+ if (typeof backend?._resetClient === "function") await backend._resetClient();
238
+ }
239
+ }, statusState);
240
+ forwarder.setOnIdle(() => {
241
+ stopServerTyping();
242
+ void forwarder.forwardFinalText();
243
+ });
244
+ // Wire the forwarder ownership probe unconditionally. wireEventQueueHandlers()
245
+ // also sets this, but that path only runs when the event pipeline starts
246
+ // (webhook enabled or event rules present). Without an event pipeline the
247
+ // forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
248
+ // non-owner process forward transcript output (duplicate Discord sends).
249
+ // The closure reads bridgeRuntimeConnected at call time as a fast-path AND;
250
+ // bridgeRuntimeConnected alone can go stale (e.g. this process lost the seat
251
+ // but has not yet observed it), so currentOwnerState().owned is re-read at
252
+ // probe time as the source of truth for ownership.
253
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
254
+ // ── Transcript binding cluster ──────────────────────────────────────────────
255
+ // Extracted → lib/transcript-binding.mjs. Bound to live config/identity/owner
256
+ // getters so file-level reference semantics (runtime reloads, ownership flips)
257
+ // are preserved.
258
+ const {
259
+ sessionIdFromTranscriptPath,
260
+ getPersistedTranscriptPath,
261
+ pickUsableTranscriptPath,
262
+ applyTranscriptBinding,
263
+ cancelPendingTranscriptRearm,
264
+ schedulePendingTranscriptRearm,
265
+ rebindTranscriptContext,
266
+ bindPersistedTranscriptIfAny,
267
+ } = createTranscriptBinding({
268
+ forwarder,
269
+ statusState,
270
+ readActiveInstance,
271
+ refreshActiveInstance,
272
+ instanceId: INSTANCE_ID,
273
+ memoryIngestTranscript,
274
+ memoryDrainBuffer,
275
+ dropTrace,
276
+ discoverSessionBoundTranscript,
277
+ sameResolvedPath,
278
+ getConfig: () => config,
279
+ getChannelBridgeActive: () => channelBridgeActive,
280
+ getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
281
+ RUNTIME_ROOT,
282
+ });
283
+ const scheduler = new Scheduler(
284
+ config.nonInteractive ?? [],
285
+ config.interactive ?? [],
286
+ // Single resolved main-channel id used for the schedule `channel` flag.
287
+ config.channelId
288
+ );
289
+ // Register the pending-dispatch probe so the scheduler treats an in-flight
290
+ // bridge dispatch as "active" regardless of user-inbound silence.
291
+ scheduler.setPendingCheck(() => {
292
+ try {
293
+ return dispatchHasPending(DATA_DIR);
294
+ } catch {
295
+ return false;
296
+ }
297
+ });
298
+ // Bridge the orchestrator-side activity notifier into the scheduler so
299
+ // events like `addPending` can bump lastActivity without importing the
300
+ // scheduler instance directly (avoids module cycles).
301
+ setActivityBusListener(() => scheduler.noteActivity());
302
+ let webhookServer = null;
303
+ let eventPipeline = null;
304
+ let bridgeRuntimeConnected = false;
305
+ // Stop-requested signal: set by stopOwnedRuntime() when it runs during the
306
+ // startOwnedRuntime() in-flight window (bridgeRuntimeStarting=true). Checked
307
+ // by startOwnedRuntime() right after backend.connect() resolves so the
308
+ // in-flight start does not revive owner state after the stop already tore
309
+ // the partial-start state down.
310
+ const ACTIVE_OWNER_STALE_MS = 1e4;
311
+ // Owner gating here is multi-process runtime coordination: only the active
312
+ // bindingReady gates all send paths until the boot-time refreshBridgeOwnership
313
+ // ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
314
+ // emissions fired within the first ~few hundred ms after restart drop because
315
+ // the Discord backend binding has not yet been established.
316
+ let bindingReadyStatus = "pending";
317
+ let _bindingReadyResolve;
318
+ const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
319
+ dropTrace("bindingReady.create", { status: bindingReadyStatus });
320
+ // ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
321
+ // Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
322
+ // dedup; bound to live identity + active-instance primitives.
323
+ const {
324
+ logOwnership,
325
+ currentOwnerState,
326
+ getBridgeOwnershipSnapshot,
327
+ claimBridgeOwnership,
328
+ startOwnerHeartbeat,
329
+ stopOwnerHeartbeat,
330
+ } = createOwnerHeartbeat({
331
+ getInstanceId: () => INSTANCE_ID,
332
+ readActiveInstance,
333
+ refreshActiveInstance,
334
+ });
335
+ // ── Owned-runtime lifecycle ─────────────────────────────────────────────────
336
+ // Extracted -> lib/owned-runtime.mjs. Owns its own start/stop/refresh in-flight
337
+ // flags + ownership timer + memory-drain timer; shares config/backend/
338
+ // bridgeRuntimeConnected/webhookServer/eventPipeline with the worker via get/set.
339
+ const {
340
+ startOwnedRuntime,
341
+ stopOwnedRuntime,
342
+ refreshBridgeOwnership,
343
+ refreshBridgeOwnershipSafe,
344
+ reloadRuntimeConfig,
345
+ armBridgeOwnershipTimer,
346
+ clearBridgeOwnershipTimer,
347
+ notifyRemoteAcquired,
348
+ } = createOwnedRuntime({
349
+ getConfig: () => config,
350
+ setConfig: (v) => { config = v; },
351
+ getBackend: () => backend,
352
+ setBackend: (v) => { backend = v; },
353
+ getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
354
+ setBridgeRuntimeConnected: (v) => { bridgeRuntimeConnected = v; },
355
+ getWebhookServer: () => webhookServer,
356
+ setWebhookServer: (v) => { webhookServer = v; },
357
+ getEventPipeline: () => eventPipeline,
358
+ setEventPipeline: (v) => { eventPipeline = v; },
359
+ getChannelBridgeActive: () => channelBridgeActive,
360
+ instanceId: INSTANCE_ID,
361
+ TERMINAL_LEAD_PID,
362
+ forwarder,
363
+ scheduler,
364
+ statusState,
365
+ logOwnership,
366
+ currentOwnerState,
367
+ claimBridgeOwnership,
368
+ startOwnerHeartbeat,
369
+ stopOwnerHeartbeat,
370
+ bindPersistedTranscriptIfAny,
371
+ cancelPendingTranscriptRearm,
372
+ schedulePendingTranscriptRearm,
373
+ stopServerTyping,
374
+ wireWebhookHandlers,
375
+ wireEventQueueHandlers,
376
+ memoryDrainBuffer,
377
+ });
378
+ function injectAndRecord(channelId, name, content, options) {
379
+ // Strip soft-warn marker blocks (Tool-loop / Repeated-input / legacy
380
+ // Repeated-tool / Mixed-tool / Tool-budget / Same-file multi-chunk /
381
+ // Bash file-lookup / Iteration / 0-match advisory) from anywhere in the
382
+ // outbound body. Markers are
383
+ // intentionally prepended onto tool RESULTS upstream (tool-loop-guard.mjs
384
+ // build*Warn) so the model
385
+ // self-corrects, but agent roles commonly echo them and we don't want them
386
+ // surfacing in Discord / Lead channel push.
387
+ if (typeof content === 'string') content = stripSoftWarns(content);
388
+ // Skip-protocol guard: agents (webhook-handler / scheduler-task)
389
+ // prefix `[meta:silent]` on the first line to opt out
390
+ // of Lead inject for genuine no-op results (label-only events, dedup,
391
+ // "nothing to report"). The body still goes to Discord for audit; only
392
+ // the Lead-context inject is suppressed. See rules/agent/20-skip-protocol.md.
393
+ if (typeof content === 'string') {
394
+ const m = content.match(/^\s*\[meta:silent\][^\n]*\n?([\s\S]*)$/);
395
+ if (m) {
396
+ content = m[1];
397
+ options = { ...(options || {}), silent_to_agent: true };
398
+ }
399
+ }
400
+ const ts = new Date().toISOString();
401
+ const now = new Date();
402
+ const timeLabel = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")} `;
403
+ const sourceLabel = options?.type ? `${timeLabel}: ${options.type}` : timeLabel;
404
+ const meta = { chat_id: channelId, user: sourceLabel, user_id: "system", ts };
405
+ if (options?.instruction) meta.instruction = options.instruction;
406
+ if (options?.type) meta.type = options.type;
407
+ // `silent_to_agent` — lifecycle status pings (worker/iter/started echoes)
408
+ // surface on Discord but should NOT land in Lead's context window. When
409
+ // set, skip the parent-notify hop but keep the Discord-forward + event-log
410
+ // record. The meta flag is also propagated downstream so consumers that
411
+ // still see the notification (e.g. Lead itself if emission changes later)
412
+ // can recognise and drop it. Default is false → legacy behaviour preserved.
413
+ if (options?.silent_to_agent) meta.silent_to_agent = true;
414
+ const silent = options?.silent_to_agent === true;
415
+ if (!silent) {
416
+ sendNotifyToParent("notifications/claude/channel", { content, meta });
417
+ } else {
418
+ forwardLifecycleToDiscord(channelId, content);
419
+ }
420
+ }
421
+
422
+ // Best-effort direct Discord emission for silent-to-agent lifecycle pings.
423
+ // Only used when the parent-notify hop is skipped, so the user still sees
424
+ // the status on Discord even though Lead will never echo it through the
425
+ // transcript-tail forwarder. Falls back to a no-op when no channel is
426
+ // resolvable — lifecycle pings are non-critical.
427
+ function forwardLifecycleToDiscord(channelId, content) {
428
+ try {
429
+ // Skip rather than guess: lifecycle callers pass the channelId they own;
430
+ // falling back to statusState.channelId can route to a stale/unrelated
431
+ // channel when the caller did not supply one intentionally.
432
+ const target = channelId || null;
433
+ dropTrace("send.lifecycle.entry", { channelId: target || "(none)", bindingReadyStatus, backendPresent: !!backend?.sendMessage, preview: preview(content) });
434
+ if (!target || !backend?.sendMessage) return;
435
+ void bindingReady.then(() =>
436
+ backend.sendMessage(target, content)
437
+ .then(() => dropTrace("send.lifecycle.ok", { channelId: target }))
438
+ .catch((err) => dropTrace("send.lifecycle.err", { channelId: target, err: String(err) }))
439
+ ).catch(() => {});
440
+ } catch { /* best-effort */ }
441
+ }
442
+ scheduler.setInjectHandler((channelId, name, content, options) => {
443
+ injectAndRecord(channelId, name, content, options);
444
+ });
445
+ scheduler.setSendHandler(async (channelId, text) => {
446
+ // Skip protocol: a scheduler-task emitting `[meta:silent]` has nothing to
447
+ // report — suppress the channel send entirely (no noise). Mirrors the
448
+ // webhook delegate drop and injectAndRecord's silent handling.
449
+ if (typeof text === "string" && /^\s*\[meta:silent\]/.test(text)) {
450
+ dropTrace("send.scheduler.silent", { channelId });
451
+ return;
452
+ }
453
+ dropTrace("send.scheduler.entry", { channelId, preview: preview(text) });
454
+ await bindingReady;
455
+ dropTrace("send.scheduler.ready", { channelId });
456
+ try {
457
+ await backend.sendMessage(channelId, text);
458
+ dropTrace("send.scheduler.ok", { channelId });
459
+ } catch (err) {
460
+ dropTrace("send.scheduler.err", { channelId, err: String(err) });
461
+ throw err;
462
+ }
463
+ });
464
+ function wireWebhookHandlers() {
465
+ if (!webhookServer) return;
466
+ webhookServer.setEventPipeline(eventPipeline);
467
+ webhookServer.setBridgeDispatch(async ({ role, preset, prompt, cwd, context }) => {
468
+ // Delegate-mode webhook → bridge orchestrator. Each bridge progress /
469
+ // final event is forwarded to the Lead via the same channel-notify
470
+ // path used by schedule & event-queue (injectAndRecord). Silent
471
+ // lifecycle pings keep routing only to Discord.
472
+ const agentMod = await import("../../agent/index.mjs");
473
+ const channelId = resolveWebhookChannelId(context?.channel);
474
+ const endpoint = context?.endpoint || "unknown";
475
+ const event = context?.event || null;
476
+ const deliveryId = context?.deliveryId || null;
477
+ const label = `webhook:${endpoint}`;
478
+ const instruction = `Webhook review from role=${role} on endpoint "${endpoint}"`
479
+ + (event ? ` (event=${event})` : "")
480
+ + (deliveryId ? ` (delivery=${deliveryId})` : "")
481
+ + ". Relay the finding to the user naturally — summarize clearly, call out any issues, and note what needs a decision.";
482
+ const notifyFn = (text, meta = {}) => {
483
+ if (!text) return;
484
+ // Webhook skip protocol: when the agent worker emits a `[meta:silent]`
485
+ // marker (optionally behind model/role tag prefixes), the event is a
486
+ // no-op (label-only, dedup, "nothing to report"). Drop the message
487
+ // entirely — neither Lead inject nor Discord forward — instead of the
488
+ // partial `silent_to_agent` semantics that still audit to Discord.
489
+ const raw = String(text);
490
+ if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
491
+ // Deterministic findings-count drop. Code-review handlers emit a
492
+ // structured `[[findings:N]]` token (N = number of issues). The RELAY —
493
+ // not the worker's prose — decides: N==0 => clean review, drop entirely
494
+ // (no Lead inject, no Discord forward). Token absent => fail-safe forward
495
+ // so a real finding is never silently dropped if the worker omits it.
496
+ const fc = raw.match(/\[\[findings:(\d+)\]\]/i);
497
+ if (fc && Number(fc[1]) === 0) return;
498
+ // Lifecycle pings (started / iter echoes, marked silent_to_agent) are
499
+ // channel noise for an automated webhook review — drop them entirely so
500
+ // a skip stays fully silent and only the final answer reaches the
501
+ // channel. The final [meta:silent] skip result is already dropped above.
502
+ if (meta?.silent_to_agent === true) return;
503
+ // Strip the verdict token before surfacing (findings present, N>0).
504
+ const surfaced = raw.replace(/\[\[findings:\d+\]\]/gi, "").replace(/[^\S\n]{2,}/g, " ").trim();
505
+ injectAndRecord(channelId, label, surfaced || raw, {
506
+ type: "webhook",
507
+ instruction,
508
+ });
509
+ };
510
+ // Per-terminal cwd under the daemon's single channels worker. A webhook
511
+ // result is injected to ownerConn() — the connection whose session.leadPid
512
+ // equals active-instance ownerLeadPid — so the worker must run in THAT
513
+ // owner terminal's cwd. Read the sentinel keyed by ownerLeadPid; cwd-tool
514
+ // writes session-cwd-<leadPid>.txt per connection, so write and read meet
515
+ // on the same leadPid key no matter which terminal holds the owner seat.
516
+ // Falls back to the session entry position; never the plugin CACHE root.
517
+ const ownerPid = getActiveOwnerPid(readActiveInstance());
518
+ const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
519
+ return agentMod.handleToolCall(
520
+ "bridge",
521
+ { role, preset, prompt, cwd: cwd || ownerCwd },
522
+ { notifyFn },
523
+ );
524
+ });
525
+ }
526
+ function resolveWebhookChannelId(channelFlag) {
527
+ // Single main channel: the endpoint's `channel` frontmatter is a boolean-ish
528
+ // flag (any non-empty value except "false" → post to the main channel). A raw
529
+ // channel id is still honored verbatim for owner-authored overrides.
530
+ const main = config?.channelId || "";
531
+ if (channelFlag == null) return main;
532
+ const v = String(channelFlag).trim();
533
+ if (v === "" || v.toLowerCase() === "false") return "";
534
+ // A pure-digit / snowflake-looking value is treated as an explicit id;
535
+ // anything else (legacy labels like "main") maps to the main channel.
536
+ return /^-?\d+$/.test(v) ? v : main;
537
+ }
538
+ function wireEventQueueHandlers(eventQueue) {
539
+ if (!eventQueue) return;
540
+ eventQueue.setInjectHandler((channelId, name, content, options) => {
541
+ injectAndRecord(channelId, name, content, options);
542
+ });
543
+ // Defensive ownership probe: the queue tick should only run in the active
544
+ // owner process. Non-owner instances see bridgeRuntimeConnected=false and
545
+ // will skip the tick even if an errant start() slipped through.
546
+ // bridgeRuntimeConnected is a fast-path AND; currentOwnerState().owned is
547
+ // re-read at probe time so a stale-connected flag cannot mask a lost seat.
548
+ eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
549
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
550
+ }
551
+ const {
552
+ pendingPermRequests,
553
+ refreshToolExecConsumerMarker,
554
+ } = createInteractionHandlers({
555
+ getBackend: () => backend,
556
+ getConfig: () => config,
557
+ getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
558
+ instanceId: INSTANCE_ID,
559
+ getBridgeOwnershipSnapshot,
560
+ refreshBridgeOwnershipSafe,
561
+ pendingSetup,
562
+ buildModalRequestSpec,
563
+ loadProfileConfig,
564
+ getDiscordToken,
565
+ sendNotifyToParent,
566
+ scheduler,
567
+ controlClaudeSession,
568
+ writeTextFile,
569
+ TURN_END_FILE,
570
+ getPermissionResultPath,
571
+ TERMINAL_LEAD_PID,
572
+ localTimestamp,
573
+ isMixdogDebug,
574
+ appendSessionStartCriticalLog,
575
+ DATA_DIR,
576
+ _bootLog,
577
+ RUNTIME_ROOT,
578
+ });
579
+ const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
580
+ getConfig: () => config,
581
+ dataDir: DATA_DIR,
582
+ });
583
+ import { TOOL_DEFS } from '../tool-defs.mjs';
584
+ // Tool dispatch in worker mode goes through the IPC `call` handler at the
585
+ // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
586
+ // orphan worker-level MCP Server: the parent (server.mjs) owns the single
587
+ // connected transport and routes CallTool through the IPC `call` path.
588
+ const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
589
+ // ── Inbound routing / dedup / ownership helpers ─────────────────────────────
590
+ // Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
591
+ const {
592
+ writeChannelOwner,
593
+ shouldDropDuplicateInbound,
594
+ resolveInboundRoute,
595
+ } = createInboundRouting({
596
+ getConfig: () => config,
597
+ getInstanceId: () => INSTANCE_ID,
598
+ getChannelOwnerPath,
599
+ });
600
+ // ── Backend-tool dispatch helpers ───────────────────────────────────────────
601
+ // Each helper dispatches through the local backend (this process is always the
602
+ // owner in opt-in remote mode). Extracted → lib/backend-dispatch.mjs. Bound to
603
+ // live config/backend getters so runtime reloads keep the original file-level
604
+ // reference semantics.
605
+ const {
606
+ dispatchReply,
607
+ dispatchFetch,
608
+ } = createBackendDispatch({
609
+ getConfig: () => config,
610
+ getBackend: () => backend,
611
+ scheduler,
612
+ });
613
+ // ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
614
+ // handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
615
+ // lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
616
+ // functions plus mutable owner state (channelBridgeActive/bridgeRuntimeConnected)
617
+ // and the forwarder; those are threaded as a lifecycle bag of lazy getters so
618
+ // the module reads live file-level references at call time (original closure
619
+ // semantics preserved). Used by the HTTP MCP CallTool path and the worker IPC
620
+ // `call` handler at the bottom of this file.
621
+ const {
622
+ handleToolCall,
623
+ handleToolCallWithBridgeRetry,
624
+ } = createToolDispatch({
625
+ getForwarder: () => forwarder,
626
+ BACKEND_TOOLS,
627
+ isChannelsDegraded,
628
+ dispatchReply,
629
+ dispatchFetch,
630
+ lifecycle: {
631
+ getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
632
+ getChannelBridgeActive: () => channelBridgeActive,
633
+ getOwned: () => getBridgeOwnershipSnapshot().owned,
634
+ setChannelBridgeActive: (v) => { channelBridgeActive = v; },
635
+ writeBridgeState,
636
+ stopServerTyping,
637
+ claimBridgeOwnership,
638
+ notifyRemoteAcquired,
639
+ refreshBridgeOwnership,
640
+ bindPersistedTranscriptIfAny,
641
+ stopOwnedRuntime,
642
+ reloadRuntimeConfig,
643
+ },
644
+ });
645
+ createInboundHandler({
646
+ getBackend: () => backend,
647
+ getConfig: () => config,
648
+ getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
649
+ getChannelBridgeActive: () => channelBridgeActive,
650
+ instanceId: INSTANCE_ID,
651
+ forwarder,
652
+ scheduler,
653
+ statusState,
654
+ getBridgeOwnershipSnapshot,
655
+ refreshBridgeOwnershipSafe,
656
+ writeChannelOwner,
657
+ shouldDropDuplicateInbound,
658
+ resolveInboundRoute,
659
+ isVoiceAttachment,
660
+ transcribeVoice,
661
+ getPersistedTranscriptPath,
662
+ sessionIdFromTranscriptPath,
663
+ pickUsableTranscriptPath,
664
+ applyTranscriptBinding,
665
+ rebindTranscriptContext,
666
+ sendNotifyToParent,
667
+ memoryAppendEntry,
668
+ startServerTyping,
669
+ stopServerTyping,
670
+ });
671
+ async function init(_sharedMcp) {
672
+ // _sharedMcp is no longer used. Notifications now flow via IPC to the parent
673
+ // (sendNotifyToParent above). The parameter is retained for backward
674
+ // compatibility with any caller that still passes a Server reference.
675
+ scheduler.setInjectHandler((channelId, name, content, options) => {
676
+ injectAndRecord(channelId, name, content, options);
677
+ });
678
+ }
679
+ async function start() {
680
+ channelBridgeActive = true;
681
+ writeBridgeState(true);
682
+ // Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
683
+ // seat up front: connect our own gateway first and claim only after it reports
684
+ // READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
685
+ // owner keeps serving, so a boot causes no send/receive gap. This is also the
686
+ // SINGLE boot claim — the old "remote start" pre-claim + "re-activate
687
+ // takeover" second claim (double reconnect) is gone.
688
+ const _bindingReadyStart = Date.now();
689
+ try {
690
+ await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true });
691
+ bindingReadyStatus = "resolved";
692
+ dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
693
+ _bindingReadyResolve(true);
694
+ } catch (e) {
695
+ bindingReadyStatus = "rejected";
696
+ dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
697
+ _bindingReadyResolve(e);
698
+ }
699
+ // Ownership timer: keep checking whether a newer remote session has taken
700
+ // over (last-wins) so a superseded owner disconnects promptly. Normally
701
+ // already armed by startOwnedRuntime before connect(); this is the fallback
702
+ // for a start path that aborted pre-connect (idempotent).
703
+ armBridgeOwnershipTimer();
704
+ // Hot-reload config on file change (schedules/webhooks/events).
705
+ if (!_configWatcher) {
706
+ try {
707
+ _configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
708
+ if (_reloadDebounce) clearTimeout(_reloadDebounce);
709
+ _reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
710
+ });
711
+ } catch {}
712
+ }
713
+ // Pre-warm the whisper-server manager once at owner startup so the first
714
+ // voice transcription does not pay cold-start cost. Non-blocking: failures
715
+ // (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
716
+ void (async () => {
717
+ try {
718
+ if (config.voice?.enabled === false) return;
719
+ const runtime = resolveVoiceRuntime(DATA_DIR);
720
+ if (!runtime?.installed) return;
721
+ const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
722
+ const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
723
+ await ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
724
+ } catch (err) {
725
+ try { process.stderr.write(`mixdog: voice.transcription pre-warm skipped: ${err}\n`); } catch {}
726
+ }
727
+ })();
728
+ }
729
+ async function stop() {
730
+ try { await stopVoiceWhisperServer(); } catch {}
731
+ await stopOwnedRuntime("unified server stop");
732
+ cleanupInstanceRuntimeFiles(INSTANCE_ID);
733
+ clearBridgeOwnershipTimer();
734
+ if (_reloadDebounce) { clearTimeout(_reloadDebounce); _reloadDebounce = null; }
735
+ if (_configWatcher) { try { _configWatcher.close(); } catch {} _configWatcher = null; }
736
+ if (turnEndWatcher) {
737
+ try { turnEndWatcher.close(); } catch {}
738
+ turnEndWatcher = null;
739
+ }
740
+ }
741
+ // ── IPC worker mode ──────────────────────────────────────────────
742
+ if (_isWorkerMode && process.send) {
743
+ runWorkerIpc({
744
+ start,
745
+ stop,
746
+ stopVoiceWhisperServer,
747
+ cleanupInstanceRuntimeFiles,
748
+ clearServerPid,
749
+ instanceId: INSTANCE_ID,
750
+ statusState,
751
+ getBackend: () => backend,
752
+ getConfig: () => config,
753
+ pendingPermRequests,
754
+ refreshToolExecConsumerMarker,
755
+ handleMemoryCallResponse,
756
+ handleToolCallWithBridgeRetry,
757
+ bootProfile,
758
+ });
759
+ }
760
+
761
+ export {
762
+ TOOL_DEFS,
763
+ handleToolCall,
764
+ handleToolCallWithBridgeRetry,
765
+ init,
766
+ INSTRUCTIONS as instructions,
767
+ isChannelBridgeActive,
768
+ isChannelsDegraded,
769
+ start,
770
+ stop
771
+ };