mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,627 @@
1
+ import * as fs from "fs";
2
+ import { loadConfig, createBackend } from "./config.mjs";
3
+ import { WebhookServer } from "./webhook.mjs";
4
+ import { EventPipeline } from "./event-pipeline.mjs";
5
+ import { startSnapshotWriter, stopSnapshotWriter } from "./status-snapshot.mjs";
6
+ import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
7
+ import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
8
+ import {
9
+ refreshActiveInstance,
10
+ releaseOwnedChannelLocks,
11
+ clearActiveInstance,
12
+ probeActiveOwner,
13
+ RUNTIME_ROOT,
14
+ } from "./runtime-paths.mjs";
15
+ // Owned-runtime lifecycle extracted from channels/index.mjs (behavior-
16
+ // preserving): bridge-ownership claim/refresh/loss, backend connect/disconnect,
17
+ // scheduler + webhook/event runtime, owner heartbeat gating, and config
18
+ // hot-reload. Owns its own in-flight flags + timers; shares config / backend /
19
+ // bridgeRuntimeConnected / webhookServer / eventPipeline with the worker via
20
+ // get/set so file-level reference semantics are preserved.
21
+ export function createOwnedRuntime({
22
+ getConfig,
23
+ setConfig,
24
+ getBackend,
25
+ setBackend,
26
+ getBridgeRuntimeConnected,
27
+ setBridgeRuntimeConnected,
28
+ getWebhookServer,
29
+ setWebhookServer,
30
+ getEventPipeline,
31
+ setEventPipeline,
32
+ getChannelBridgeActive,
33
+ instanceId,
34
+ TERMINAL_LEAD_PID,
35
+ forwarder,
36
+ scheduler,
37
+ statusState,
38
+ logOwnership,
39
+ currentOwnerState,
40
+ claimBridgeOwnership,
41
+ startOwnerHeartbeat,
42
+ stopOwnerHeartbeat,
43
+ bindPersistedTranscriptIfAny,
44
+ cancelPendingTranscriptRearm,
45
+ schedulePendingTranscriptRearm,
46
+ stopServerTyping,
47
+ wireWebhookHandlers,
48
+ wireEventQueueHandlers,
49
+ memoryDrainBuffer,
50
+ }) {
51
+ let bridgeRuntimeStarting = false;
52
+ let _ownedRuntimeStopRequested = false;
53
+ let bridgeOwnershipRefreshInFlight = null;
54
+ let bridgeOwnershipTimer = null;
55
+ let _memoryDrainTimer = null;
56
+ // Event-driven ownership signal: an fs.watch on the runtime dir fires the
57
+ // ownership refresh the instant active-instance.json changes (a newer owner
58
+ // claims, or the owner releases/clears it), instead of waiting up to 3s for
59
+ // the poll tick. This shrinks the double-owner window on takeover (the old
60
+ // owner observes owned=false and tears down in ms) and signals ownership
61
+ // loss to contenders immediately on release. The 3s timer stays as fallback.
62
+ let activeInstanceWatcher = null;
63
+ let _activeInstanceWatchDebounce = null;
64
+ function armActiveInstanceWatcher() {
65
+ if (activeInstanceWatcher) return;
66
+ try {
67
+ activeInstanceWatcher = fs.watch(RUNTIME_ROOT, { persistent: false }, (_event, filename) => {
68
+ if (filename && filename !== 'active-instance.json') return;
69
+ // Coalesce the burst of events an atomic rename/truncate emits.
70
+ if (_activeInstanceWatchDebounce) return;
71
+ _activeInstanceWatchDebounce = setTimeout(() => {
72
+ _activeInstanceWatchDebounce = null;
73
+ refreshBridgeOwnershipSafe();
74
+ }, 50);
75
+ _activeInstanceWatchDebounce.unref?.();
76
+ });
77
+ // fs.watch emits 'error' (and an unhandled one CRASHES the worker) when
78
+ // the watched dir is removed or the handle is invalidated (common on
79
+ // Windows). Close the dead handle and fall back to the 3s poll, which is
80
+ // still armed — the event signal is a latency optimization, never the
81
+ // sole ownership mechanism.
82
+ activeInstanceWatcher.on('error', (err) => {
83
+ process.stderr.write(`[ownership] active-instance watch error, falling back to poll: ${err?.message || err}\n`);
84
+ clearActiveInstanceWatcher();
85
+ });
86
+ activeInstanceWatcher.unref?.();
87
+ } catch { activeInstanceWatcher = null; }
88
+ }
89
+ function clearActiveInstanceWatcher() {
90
+ if (_activeInstanceWatchDebounce) { clearTimeout(_activeInstanceWatchDebounce); _activeInstanceWatchDebounce = null; }
91
+ if (activeInstanceWatcher) { try { activeInstanceWatcher.close(); } catch {} activeInstanceWatcher = null; }
92
+ }
93
+ function clearBridgeOwnershipTimer() {
94
+ if (bridgeOwnershipTimer) {
95
+ clearInterval(bridgeOwnershipTimer);
96
+ bridgeOwnershipTimer = null;
97
+ }
98
+ clearActiveInstanceWatcher();
99
+ }
100
+ function shouldStartEventPipelineRuntime() {
101
+ return getConfig().webhook?.enabled === true || (Array.isArray(getConfig().events?.rules) && getConfig().events.rules.length > 0);
102
+ }
103
+ function ensureEventPipelineRuntime() {
104
+ if (!getEventPipeline()) {
105
+ setEventPipeline(new EventPipeline(getConfig().events, getConfig().channelId));
106
+ wireEventQueueHandlers(getEventPipeline().getQueue());
107
+ }
108
+ return getEventPipeline();
109
+ }
110
+ function ensureWebhookServerRuntime() {
111
+ if (!getWebhookServer()) {
112
+ setWebhookServer(new WebhookServer(getConfig().webhook));
113
+ }
114
+ wireWebhookHandlers();
115
+ return getWebhookServer();
116
+ }
117
+ async function stopWebhookAndEventRuntime() {
118
+ if (getWebhookServer()) {
119
+ await getWebhookServer().stop();
120
+ setWebhookServer(null);
121
+ }
122
+ if (getEventPipeline()) {
123
+ getEventPipeline().stop();
124
+ setEventPipeline(null);
125
+ }
126
+ }
127
+ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
128
+ if (shouldStartEventPipelineRuntime()) {
129
+ const pipeline = ensureEventPipelineRuntime();
130
+ if (reload) {
131
+ pipeline.reloadConfig(getConfig().events, getConfig().channelId);
132
+ wireEventQueueHandlers(pipeline.getQueue());
133
+ }
134
+ pipeline.start();
135
+ } else if (getEventPipeline()) {
136
+ getEventPipeline().stop();
137
+ setEventPipeline(null);
138
+ }
139
+
140
+ if (getConfig().webhook?.enabled === true) {
141
+ const server = ensureWebhookServerRuntime();
142
+ if (reload) {
143
+ // server.reloadConfig is async (it awaits the current server's
144
+ // close() before re-listening). Chain start() onto its resolution
145
+ // so we don't race the bound port — calling start() synchronously
146
+ // here would re-listen before close() finishes and surface
147
+ // EADDRINUSE on the same port.
148
+ server.reloadConfig(getConfig().webhook, {
149
+ autoStart: false
150
+ }).then(() => {
151
+ // A stopWebhookAndEventRuntime() / deactivate landing during the async
152
+ // close()+reload window nulls out getWebhookServer() (and webhook.enabled may
153
+ // have flipped off). Without this guard the resolved continuation would
154
+ // re-listen and resurrect an orphan listener that no teardown tracks.
155
+ if (getWebhookServer() !== server || getConfig().webhook?.enabled !== true) {
156
+ try { server.stop(); } catch {}
157
+ return;
158
+ }
159
+ wireWebhookHandlers();
160
+ server.start();
161
+ }).catch((err) => {
162
+ process.stderr.write(`mixdog channels: webhook reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
163
+ });
164
+ } else {
165
+ server.start();
166
+ }
167
+ } else if (getWebhookServer()) {
168
+ getWebhookServer().stop();
169
+ setWebhookServer(null);
170
+ }
171
+ }
172
+ let _ownedRuntimeSelfHealing = false;
173
+ // Explicit restore path for an already-connected owner. The 3s ownership tick
174
+ // must not run this implicitly: re-binding status on every tick can move the
175
+ // transcript cursor to EOF, and probing the gateway during Discord's own
176
+ // reconnect window can reset a healthy reconnect loop. Keep this for explicit
177
+ // activation/reload recovery only.
178
+ async function selfHealOwnedRuntime(options = {}) {
179
+ const shouldRestoreBinding = options.restoreBinding === true;
180
+ const shouldResetBackend = options.resetBackend === true;
181
+ if (!shouldRestoreBinding && !shouldResetBackend) return;
182
+ if (_ownedRuntimeSelfHealing) return;
183
+ _ownedRuntimeSelfHealing = true;
184
+ try {
185
+ if (shouldRestoreBinding) {
186
+ await bindPersistedTranscriptIfAny().catch((e) => {
187
+ process.stderr.write(`mixdog: self-heal bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
188
+ });
189
+ }
190
+ if (shouldResetBackend && typeof getBackend()?._resetClient === "function") {
191
+ await getBackend()._resetClient().catch((e) => {
192
+ process.stderr.write(`mixdog: self-heal getBackend() reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
193
+ });
194
+ }
195
+ // Nudge the forwarder to drain anything the rebind/reconnect surfaced.
196
+ void forwarder.forwardNewText().catch(() => {});
197
+ } finally {
198
+ _ownedRuntimeSelfHealing = false;
199
+ }
200
+ }
201
+ async function startOwnedRuntime(options = {}) {
202
+ if (getBridgeRuntimeConnected()) {
203
+ if (!bridgeRuntimeStarting) await selfHealOwnedRuntime(options);
204
+ return;
205
+ }
206
+ if (bridgeRuntimeStarting) return;
207
+ if (!getChannelBridgeActive()) return;
208
+ bridgeRuntimeStarting = true;
209
+ _ownedRuntimeStopRequested = false;
210
+ // Capture the getBackend() instance that THIS start operation will connect. A
211
+ // reloadRuntimeConfig() hot-swap can replace the global `getBackend()` while this
212
+ // start is still awaiting connect(); using the captured instance for both
213
+ // connect() and the bail-path disconnect() guarantees we tear down the
214
+ // getBackend() WE started (not the freshly-swapped one), closing the
215
+ // both-backends-live window.
216
+ const startingBackend = getBackend();
217
+ const claimAfterReady = options.claimAfterReady === true;
218
+ // Auto-start intent: claim the seat ONLY if it is vacant/stale (never steal a
219
+ // live owner). Threaded from worker start() (MIXDOG_REMOTE_INTENT=auto).
220
+ const claimIfVacant = options.claimIfVacant === true;
221
+ // Single-holder correctness: the seat is claimed BEFORE getBackend().connect(),
222
+ // never after. The old make-before-break (claim-after-ready) boot left two
223
+ // gateways connected and contending during the multi-second connect window;
224
+ // claiming first makes the previous owner observe owned=false on its next
225
+ // tick and drain+disconnect, so at most one gateway ever serves.
226
+ // - boot (claimAfterReady): last-wins acquire — this new remote session
227
+ // takes the seat outright.
228
+ // - refresh/owned-path: CAS onlyIfOwned — abort fast if the seat moved.
229
+ // backendReady=false marks the partial state until getBackend().connect() succeeds.
230
+ // Wrapped so ANY throw in the pre-connect claim (lock contention/error)
231
+ // resets bridgeRuntimeStarting — otherwise a transient lock error would
232
+ // leave it stuck true and permanently block every future ownership attempt.
233
+ try {
234
+ if (claimAfterReady) {
235
+ if (claimIfVacant) {
236
+ // Auto-start claim-if-vacant. Probe first: a live owner other than us
237
+ // holds the seat -> back off SILENTLY (no claim, no acquire notify) so
238
+ // this session stays non-remote. Then claim atomically via the
239
+ // onlyIfVacant CAS (guards the probe->write TOCTOU: a live owner landing
240
+ // in that gap aborts the write, leaving the seat untouched).
241
+ const probe = probeActiveOwner();
242
+ if (probe.status === 'live' && probe.state?.instanceId && probe.state.instanceId !== instanceId) {
243
+ bridgeRuntimeStarting = false;
244
+ logOwnership("autostart backoff (live owner holds seat)");
245
+ return;
246
+ }
247
+ const casResult = refreshActiveInstance(instanceId, { backendReady: false }, { onlyIfVacant: true, timeoutMs: 0 });
248
+ if (casResult?.instanceId !== instanceId) {
249
+ bridgeRuntimeStarting = false;
250
+ logOwnership("autostart backoff (seat claimed by newer owner)");
251
+ return;
252
+ }
253
+ logOwnership("boot claim (pre-connect, claim-if-vacant)");
254
+ } else {
255
+ refreshActiveInstance(instanceId, { backendReady: false });
256
+ logOwnership("boot claim (pre-connect, last-wins)");
257
+ }
258
+ } else {
259
+ // Try-once (timeoutMs:0): a refresh-path re-acquire must never block on
260
+ // the active-instance lock. Contention throws → caught below and treated
261
+ // as "seat busy, abort this attempt"; the next 3s tick retries.
262
+ const casResult = refreshActiveInstance(instanceId, { backendReady: false }, { onlyIfOwned: true, timeoutMs: 0 });
263
+ // A successful CAS write always sets instanceId to ours; any other result
264
+ // (aborted write returning the stale/foreign/missing prior state) means the
265
+ // seat was not ours to claim — abort here rather than relying on the timer.
266
+ if (casResult?.instanceId !== instanceId) {
267
+ bridgeRuntimeStarting = false;
268
+ return;
269
+ }
270
+ }
271
+ // A newer session can claim between our write and this read. If we no longer
272
+ // own the seat, abort fast WITHOUT starting heartbeat/getBackend()/scheduler/
273
+ // webhook/ngrok — ancillary runtime starts only past a confirmed-owned claim.
274
+ if (!currentOwnerState().owned) {
275
+ bridgeRuntimeStarting = false;
276
+ return;
277
+ }
278
+ } catch (e) {
279
+ bridgeRuntimeStarting = false;
280
+ process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
281
+ return;
282
+ }
283
+ // Arm ownership-loss detection BEFORE getBackend().connect() so a session that is
284
+ // superseded during the (multi-second) connect window is torn down: the 3s
285
+ // tick observes a newer owner and flips _ownedRuntimeStopRequested, and
286
+ // bailIfStopRequested below disconnects the getBackend() WE connected.
287
+ armBridgeOwnershipTimer();
288
+ // Heartbeat is ownership-gated; safe to arm now that we hold the seat.
289
+ startOwnerHeartbeat();
290
+ // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
291
+ // memory service publishes its port. Idempotent + reentrancy-guarded inside
292
+ // drainBuffer(); unref'd so it never holds the worker open.
293
+ if (!_memoryDrainTimer) {
294
+ _memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
295
+ _memoryDrainTimer.unref?.();
296
+ }
297
+ // Re-check after each post-connect await so a stopOwnedRuntime() landing
298
+ // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
299
+ // webhook/binding launches below would revive owner state after stop).
300
+ // Idempotent: stop's sync teardown already ran; re-running disconnect +
301
+ // teardown is safe and covers both the pre-connected window (stop could
302
+ // not disconnect an in-flight getBackend()) and the post-connected window
303
+ // (stop did disconnect; redo to be defensive).
304
+ const bailIfStopRequested = async () => {
305
+ if (!_ownedRuntimeStopRequested) return false;
306
+ try { await startingBackend.disconnect(); } catch {}
307
+ try { stopOwnerHeartbeat(); } catch {}
308
+ try { releaseOwnedChannelLocks(instanceId); } catch {}
309
+ try { clearActiveInstance(instanceId); } catch {}
310
+ setBridgeRuntimeConnected(false);
311
+ _ownedRuntimeStopRequested = false;
312
+ return true;
313
+ };
314
+ const restoreBinding = options.restoreBinding !== false;
315
+ const bindPersistedTranscriptTask = restoreBinding
316
+ ? bindPersistedTranscriptIfAny().catch((e) => {
317
+ process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
318
+ })
319
+ : null;
320
+ // Await getBackend().connect() so callers (and bindingReady) only resolve after
321
+ // the Discord binding is real. Previously this was fire-and-forget and
322
+ // refreshBridgeOwnership returned immediately, letting bindingReady fire
323
+ // before getBackend() listeners were attached.
324
+ try {
325
+ await startingBackend.connect();
326
+ if (await bailIfStopRequested()) {
327
+ cancelPendingTranscriptRearm();
328
+ try { forwarder.stopWatch(); } catch {}
329
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
330
+ return;
331
+ }
332
+ // Post-connect ownership confirm (CAS). The seat was claimed BEFORE
333
+ // connect; a newer session may have superseded us during the connect
334
+ // window (the 3s tick would already be tearing us down). onlyIfOwned:
335
+ // never re-steal here. If the CAS aborts we LOST the seat — disconnect the
336
+ // getBackend() WE connected and mark the bridge runtime not connected (do NOT
337
+ // clear active-instance; the newer owner holds it).
338
+ let ownConfirm;
339
+ try {
340
+ ownConfirm = refreshActiveInstance(instanceId, { backendReady: true }, { onlyIfOwned: true });
341
+ } catch { ownConfirm = null; }
342
+ if (ownConfirm?.instanceId !== instanceId) {
343
+ try { await startingBackend.disconnect(); } catch {}
344
+ try { stopOwnerHeartbeat(); } catch {}
345
+ try { releaseOwnedChannelLocks(instanceId); } catch {}
346
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
347
+ setBridgeRuntimeConnected(false);
348
+ cancelPendingTranscriptRearm();
349
+ try { forwarder.stopWatch(); } catch {}
350
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
351
+ notifyRemoteSuperseded();
352
+ return;
353
+ }
354
+ setBridgeRuntimeConnected(true);
355
+ // Fresh confirmed ownership — tell the parent it holds the seat so it flips
356
+ // remote ON. Reached ONLY on a not-connected -> connected transition (the
357
+ // top-of-fn early-return skips already-connected re-ticks), so this fires
358
+ // exactly once per acquire and covers EVERY win path, not just boot:
359
+ // - explicit/auto boot claim (claimAfterReady),
360
+ // - the deferred claim when a bridge timer's refreshBridgeOwnership()
361
+ // claims a seat vacated by a departing owner (finding 1).
362
+ // The parent's acquired handler is idempotent (no-op when already remote),
363
+ // so notifying post-connect — never pre-connect — means a connect FAILURE
364
+ // below leaves the parent non-remote instead of stuck remote-with-no-bridge
365
+ // (finding 2).
366
+ notifyRemoteAcquired();
367
+ // initProviders must complete before scheduler.start() — otherwise the
368
+ // scheduler's first fire can land before the registry is populated and
369
+ // return `Provider "<name>" not found or not enabled`. The previous
370
+ // fire-and-forget call let scheduler.start() race ahead of init.
371
+ try {
372
+ const agentCfg = loadAgentConfig();
373
+ await initProviders(agentCfg.providers || {});
374
+ } catch (e) {
375
+ process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
376
+ }
377
+ if (await bailIfStopRequested()) {
378
+ cancelPendingTranscriptRearm();
379
+ try { forwarder.stopWatch(); } catch {}
380
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
381
+ return;
382
+ }
383
+ scheduler.start();
384
+ startSnapshotWriter(scheduler);
385
+ syncOwnedWebhookAndEventRuntime();
386
+ if (restoreBinding) {
387
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
388
+ const pendingTranscriptPath = forwarder.transcriptPath;
389
+ if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
390
+ // Pre-connect bind may have armed rearm while !getBridgeRuntimeConnected();
391
+ // the first tick then exits without rescheduling. Re-arm now that we own.
392
+ schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
393
+ } else {
394
+ void forwarder.forwardNewText().catch((err) => {
395
+ process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
396
+ });
397
+ }
398
+ }
399
+ process.stderr.write(`mixdog: running with ${getBackend().name} getBackend()\n`);
400
+ logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
401
+ } catch (e) {
402
+ process.stderr.write(`mixdog: getBackend() connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
403
+ cancelPendingTranscriptRearm();
404
+ try { forwarder.stopWatch(); } catch {}
405
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
406
+ // Roll back partial owner-side state advertised before connect() ran:
407
+ // heartbeat and active-instance entry.
408
+ try { stopOwnerHeartbeat(); } catch {}
409
+ try { releaseOwnedChannelLocks(instanceId); } catch {}
410
+ try { clearActiveInstance(instanceId); } catch {}
411
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
412
+ } finally {
413
+ bridgeRuntimeStarting = false;
414
+ }
415
+ }
416
+ async function stopOwnedRuntime(reason) {
417
+ // Cancel any pending transcript re-arm poll BEFORE the connected/starting
418
+ // early-return below. Otherwise a poll armed against a not-yet-existing
419
+ // transcript could fire after teardown and reinstall the fs.watch handle
420
+ // (startWatch is not owner-gated), leaking a live watcher past shutdown.
421
+ cancelPendingTranscriptRearm();
422
+ // startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
423
+ // claims channel locks BEFORE awaiting getBackend().connect(). If shutdown lands
424
+ // during that window (bridgeRuntimeStarting=true, getBridgeRuntimeConnected()
425
+ // still false) we still need to tear that partial state down — otherwise
426
+ // the port stays bound and active-instance.json stays stale.
427
+ if (!getBridgeRuntimeConnected() && !bridgeRuntimeStarting) return;
428
+ // If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
429
+ // startOwnedRuntime() to abort right after its getBackend().connect() resolves.
430
+ // Without this the in-flight start re-marks connected and re-launches
431
+ // scheduler/webhook/heartbeat after we tear them down here.
432
+ if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
433
+ const wasConnected = getBridgeRuntimeConnected();
434
+ stopServerTyping();
435
+ // Release the transcript fs.watch handle plus the forwarder's debounce/retry
436
+ // timers on standby. Without this the watcher keeps firing scheduleWatchFlush
437
+ // and the drain/retry timers stay live after ownership is dropped, leaking a
438
+ // file handle + timers for the rest of the process lifetime.
439
+ try { forwarder.stopWatch(); } catch {}
440
+ stopOwnerHeartbeat();
441
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
442
+ scheduler.stop();
443
+ stopSnapshotWriter();
444
+ await stopWebhookAndEventRuntime();
445
+ releaseOwnedChannelLocks(instanceId);
446
+ clearActiveInstance(instanceId);
447
+ try {
448
+ // Only disconnect the getBackend() when connect() actually completed; calling
449
+ // disconnect() mid-connect races the connect promise.
450
+ if (wasConnected) {
451
+ // Drain in-flight outbound sends before disconnecting so a handoff
452
+ // (owned=false observed → ownership lost) never cuts off a reply
453
+ // mid-delivery. Bounded inside drainPendingSends so a wedged send can
454
+ // not stall teardown — we still disconnect promptly.
455
+ try { await getBackend().drainPendingSends?.(); } catch {}
456
+ await getBackend().disconnect();
457
+ }
458
+ } finally {
459
+ setBridgeRuntimeConnected(false);
460
+ logOwnership(`standby: ${reason}`);
461
+ }
462
+ }
463
+ function refreshBridgeOwnershipSafe(options = {}) {
464
+ refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
465
+ }
466
+ // Ownership-loss / re-acquire detection timer. Armed BEFORE getBackend().connect()
467
+ // (from startOwnedRuntime) so a session superseded during the connect window is
468
+ // torn down promptly, and re-armed idempotently on every start path.
469
+ function armBridgeOwnershipTimer() {
470
+ if (bridgeOwnershipTimer) return;
471
+ bridgeOwnershipTimer = setInterval(() => {
472
+ refreshBridgeOwnershipSafe();
473
+ }, 3e3);
474
+ bridgeOwnershipTimer.unref?.();
475
+ // Arm the event-driven signal alongside the poll fallback.
476
+ armActiveInstanceWatcher();
477
+ }
478
+ // Guarded IPC send to the parent: no-ops when there is no channel or it is
479
+ // already disconnected, and swallows both the synchronous throw and the async
480
+ // error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
481
+ // connected check and delivery). Log-and-continue — never crash the worker.
482
+ function sendToParent(message) {
483
+ if (!process.send || process.connected === false) return;
484
+ try {
485
+ process.send(message, undefined, undefined, err => {
486
+ if (err) process.stderr.write(`[channels] parent IPC send failed: ${err?.message || err}\n`);
487
+ });
488
+ } catch (err) {
489
+ process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
490
+ }
491
+ }
492
+ // Tell the parent session that this worker LOST the bridge seat to a newer
493
+ // remote session (last-wins). The parent flips its remote mode OFF entirely —
494
+ // exactly one session holds remote; losers fully release, no handover.
495
+ function notifyRemoteSuperseded() {
496
+ sendToParent({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
497
+ }
498
+ // Symmetric to notifyRemoteSuperseded: tell the parent session this worker
499
+ // ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
500
+ // Guarded by process.send so a manually-forked worker with no IPC parent is a
501
+ // no-op instead of crashing. Callers fire this only on a genuine claim
502
+ // transition (boot make-before-break, activate when not already owned) — never
503
+ // on a heartbeat refresh — so the parent's idempotent handler sees it once.
504
+ function notifyRemoteAcquired() {
505
+ sendToParent({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
506
+ }
507
+ async function refreshBridgeOwnership(options = {}) {
508
+ // Coalesce concurrent callers onto the in-flight refresh so getBackend() tool
509
+ // calls landing during normal login wait for the same connect attempt
510
+ // instead of returning early and observing spurious auto-connect failure.
511
+ if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
512
+ bridgeOwnershipRefreshInFlight = (async () => {
513
+ // Opt-in remote, single-owner, last-wins. Only a remote session with an
514
+ // active bridge participates. If this instance is the active owner (its
515
+ // instanceId is the one advertised in active-instance.json) it ensures
516
+ // the owned runtime is up. If a newer remote session has since claimed
517
+ // ownership (last-wins overwrite), this instance is no longer owner and
518
+ // quietly tears its getBackend() down on the next tick. No proxy, no steal.
519
+ if (!getChannelBridgeActive()) {
520
+ if (getBridgeRuntimeConnected()) await stopOwnedRuntime("bridge inactive");
521
+ return;
522
+ }
523
+ // Non-blocking ownership probe (read-only, no lock): distinguishes a live
524
+ // owner from a genuinely empty/stale seat from an INDETERMINATE read (a
525
+ // concurrent atomic rename yields partial content). "Locked/unreadable =
526
+ // busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
527
+ // never claim a seat we merely failed to read (double-owner guard).
528
+ const probe = probeActiveOwner();
529
+ if (probe.status === 'unknown') return;
530
+ const owned = probe.status === 'live' && probe.state?.instanceId === instanceId;
531
+ if (owned) {
532
+ // Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
533
+ // and skip the metadata touch — never block the tick. We still own, so
534
+ // ensure/keep the owned runtime up.
535
+ try { refreshActiveInstance(instanceId, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
536
+ await startOwnedRuntime(options);
537
+ return;
538
+ }
539
+ if (probe.status === 'live' && probe.state.instanceId && probe.state.instanceId !== instanceId) {
540
+ // A different live remote session holds the seat (last-wins: we lost).
541
+ // Go quiet (disconnect if connected) and tell the parent to drop remote
542
+ // mode entirely (single-holder, no handover). Notify UNCONDITIONALLY —
543
+ // a loser whose getBackend() never connected must still drop its Remote
544
+ // indicator; the parent handler is idempotent.
545
+ // Also cover the STARTING phase: a worker stuck in getBackend().connect() must
546
+ // get _ownedRuntimeStopRequested set (stopOwnedRuntime does this while
547
+ // bridgeRuntimeStarting) so bailIfStopRequested tears it down promptly
548
+ // once connect() resolves — otherwise the superseded connect lingers.
549
+ if (getBridgeRuntimeConnected() || bridgeRuntimeStarting) {
550
+ await stopOwnedRuntime("ownership lost (newer remote session)");
551
+ }
552
+ notifyRemoteSuperseded();
553
+ return;
554
+ }
555
+ // Empty (absent) or stale (dead owner) seat — claim it. Try-once
556
+ // (timeoutMs:0): a contended lock means a concurrent claimant is mid-write,
557
+ // so treat as busy and skip this tick rather than block.
558
+ let claimed = false;
559
+ try { claimed = claimBridgeOwnership("no active owner", { timeoutMs: 0 }); } catch { claimed = false; }
560
+ if (claimed) {
561
+ await startOwnedRuntime(options);
562
+ }
563
+ })();
564
+ try {
565
+ return await bridgeOwnershipRefreshInFlight;
566
+ } finally {
567
+ bridgeOwnershipRefreshInFlight = null;
568
+ }
569
+ }
570
+
571
+ async function reloadRuntimeConfig() {
572
+ const previousBackend = getBackend();
573
+ const previousBackendName = previousBackend?.name || "";
574
+ setConfig(loadConfig());
575
+ scheduler.reloadConfig(
576
+ getConfig().nonInteractive ?? [],
577
+ getConfig().interactive ?? [],
578
+ // Single resolved main-channel id used for the schedule `channel` flag.
579
+ getConfig().channelId,
580
+ { restart: getBridgeRuntimeConnected() }
581
+ );
582
+ const nextBackend = createBackend(getConfig());
583
+ const backendChanged = (nextBackend?.name || "") !== previousBackendName;
584
+ if (backendChanged) {
585
+ const shouldRestart = getBridgeRuntimeConnected() || bridgeRuntimeStarting;
586
+ if (shouldRestart) await stopOwnedRuntime("getBackend() getConfig() changed");
587
+ setBackend(nextBackend);
588
+ // The persisted routing channelId belongs to the OLD getBackend() (e.g. a
589
+ // Discord snowflake) and is meaningless for the new one — sending to it
590
+ // would 400 "chat not found". There is no id mapping between platforms, so
591
+ // CLEAR the stale binding: drop the forwarder's context + watcher and wipe
592
+ // status.channelId/transcriptPath. The next inbound from the new getBackend()
593
+ // rebinds the correct chat via applyTranscriptBinding(). Only done on
594
+ // backendChanged — same-getBackend() reloads keep their binding untouched.
595
+ // (active-instance is cleared by stopOwnedRuntime on the restart path; we
596
+ // don't re-advertise here to avoid resurrecting a just-cleared entry.)
597
+ try { forwarder.stopWatch(); } catch {}
598
+ forwarder.channelId = "";
599
+ forwarder.transcriptPath = "";
600
+ try {
601
+ statusState.update((state) => {
602
+ state.channelId = "";
603
+ state.transcriptPath = "";
604
+ });
605
+ } catch {}
606
+ if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
607
+ } else if (nextBackend !== previousBackend) {
608
+ try { await nextBackend.disconnect?.(); } catch {}
609
+ }
610
+ if (getBridgeRuntimeConnected()) {
611
+ syncOwnedWebhookAndEventRuntime({ reload: true });
612
+ } else {
613
+ await stopWebhookAndEventRuntime();
614
+ }
615
+ }
616
+ return {
617
+ startOwnedRuntime,
618
+ stopOwnedRuntime,
619
+ refreshBridgeOwnership,
620
+ refreshBridgeOwnershipSafe,
621
+ reloadRuntimeConfig,
622
+ armBridgeOwnershipTimer,
623
+ clearBridgeOwnershipTimer,
624
+ notifyRemoteAcquired,
625
+ notifyRemoteSuperseded,
626
+ };
627
+ }
@@ -279,6 +279,14 @@ function refreshActiveInstance(instanceId, meta, options) {
279
279
  if (options?.onlyIfOwned && (!prev?.instanceId || prev.instanceId !== instanceId)) {
280
280
  return undefined;
281
281
  }
282
+ // CAS guard (opt-in via options.onlyIfVacant): auto-start claim-if-vacant.
283
+ // Abort the write when a live (non-stale) owner OTHER than us already holds
284
+ // the seat — an auto-start must never steal from a live owner. A stale/dead
285
+ // prior owner leaves prev=null above, so it does NOT block the claim; an
286
+ // absent seat (prev=null) is claimable too.
287
+ if (options?.onlyIfVacant && prev?.instanceId && prev.instanceId !== instanceId) {
288
+ return undefined;
289
+ }
282
290
  // Drop stale fields (pid/startedAt) written by older server versions.
283
291
  const { pid: _legacyPid, startedAt: _legacyStartedAt, ...prevRest } = prev ?? {};
284
292
  const identity = buildRuntimeIdentity();
@@ -393,18 +401,21 @@ function refreshActiveInstance(instanceId, meta, options) {
393
401
  }
394
402
  }
395
403
  }
396
- // Worker-owned seat: when THIS process is the channels worker and it is
397
- // the seat owner (no distinct TUI/supervisor Lead — ownerLeadPid resolves
398
- // to our own pid), keep ui_heartbeat_at fresh. A headless worker never
399
- // runs the TUI render loop, so an inherited heartbeat (written by a prior
400
- // TUI session and carried forward in prevRest) would otherwise go stale at
401
- // UI_HEARTBEAT_STALE_MS and falsely flag a live worker-owned seat. A
402
- // TUI-owned seat has a distinct supervisor (ownerLeadPid !== our pid), so
403
- // this branch is a no-op there and TUI staleness is preserved.
404
+ // Headless worker-owned seat: THIS process is the channels worker AND no
405
+ // distinct terminal lead owns the seat (ownerLeadPid resolves to our own
406
+ // pid). A worker never runs the TUI render loop, so it owns no heartbeat
407
+ // it must NOT keep refreshing a ui_heartbeat_at it does not own. An
408
+ // inherited value (written by a prior TUI session, carried forward in
409
+ // prevRest) would otherwise be perpetually renewed here and falsely mask a
410
+ // dead render loop, so DROP it and let pid-only judgment stand.
411
+ // When a distinct terminal lead owns the seat (ownerLeadPid !== our pid),
412
+ // this branch is a no-op: the TUI's own heartbeat is carried forward
413
+ // untouched, so a stale (zombie) render loop still evicts the seat even
414
+ // while the worker pid is alive.
404
415
  const workerOwnsSeat = process.env.MIXDOG_WORKER_MODE === "1"
405
416
  && identity.ownerLeadPid === process.pid;
406
417
  if (workerOwnsSeat) {
407
- next.ui_heartbeat_at = Date.now();
418
+ delete next.ui_heartbeat_at;
408
419
  }
409
420
  return { ...preservedExtra, ...next };
410
421
  }, writeOpts);