mixdog 0.9.22 → 0.9.24

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 (132) hide show
  1. package/README.md +1 -4
  2. package/package.json +1 -1
  3. package/scripts/boot-smoke.mjs +1 -1
  4. package/scripts/channel-daemon-smoke.mjs +483 -0
  5. package/scripts/channel-daemon-stub.mjs +80 -0
  6. package/scripts/debounced-skills-async-save-test.mjs +57 -0
  7. package/scripts/explore-bench-tmp.mjs +17 -0
  8. package/scripts/find-fuzzy-hidden-test.mjs +145 -0
  9. package/scripts/mcp-grace-deferred-test.mjs +149 -0
  10. package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
  11. package/scripts/tool-smoke.mjs +68 -47
  12. package/src/rules/agent/30-explorer.md +6 -0
  13. package/src/rules/shared/01-tool.md +11 -4
  14. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  15. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  17. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
  18. package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
  19. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  20. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  27. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
  28. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
  29. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
  31. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  32. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
  33. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  34. package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
  35. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  36. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  37. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  38. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  39. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  40. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  42. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  43. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
  45. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
  46. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  47. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  48. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  49. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  50. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
  51. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  52. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  53. package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
  54. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
  55. package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
  56. package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
  57. package/src/runtime/channels/lib/session-discovery.mjs +2 -1
  58. package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
  59. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  60. package/src/runtime/channels/lib/worker-main.mjs +42 -30
  61. package/src/runtime/memory/index.mjs +54 -7
  62. package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
  63. package/src/runtime/memory/lib/pg/process.mjs +85 -40
  64. package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
  65. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  66. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  67. package/src/runtime/memory/tool-defs.mjs +1 -1
  68. package/src/runtime/shared/atomic-file.mjs +150 -6
  69. package/src/runtime/shared/background-tasks.mjs +1 -1
  70. package/src/runtime/shared/config.mjs +53 -1
  71. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  72. package/src/runtime/shared/tool-primitives.mjs +31 -1
  73. package/src/runtime/shared/tool-surface.mjs +42 -13
  74. package/src/runtime/shared/update-checker.mjs +3 -0
  75. package/src/runtime/shared/user-data-guard.mjs +66 -0
  76. package/src/session-runtime/config-lifecycle.mjs +221 -20
  77. package/src/session-runtime/lifecycle-api.mjs +9 -0
  78. package/src/session-runtime/mcp-glue.mjs +93 -1
  79. package/src/session-runtime/resource-api.mjs +62 -8
  80. package/src/session-runtime/runtime-core.mjs +118 -4
  81. package/src/session-runtime/session-text.mjs +41 -0
  82. package/src/session-runtime/session-turn-api.mjs +50 -0
  83. package/src/session-runtime/settings-api.mjs +8 -1
  84. package/src/session-runtime/tool-catalog.mjs +350 -38
  85. package/src/session-runtime/tool-defs.mjs +7 -7
  86. package/src/session-runtime/workflow.mjs +2 -1
  87. package/src/standalone/channel-admin.mjs +32 -3
  88. package/src/standalone/channel-daemon-client.mjs +226 -0
  89. package/src/standalone/channel-daemon-transport.mjs +545 -0
  90. package/src/standalone/channel-daemon.mjs +176 -0
  91. package/src/standalone/channel-worker.mjs +224 -4
  92. package/src/standalone/explore-tool.mjs +87 -15
  93. package/src/standalone/hook-bus.mjs +71 -3
  94. package/src/tui/App.jsx +107 -19
  95. package/src/tui/app/clipboard.mjs +39 -19
  96. package/src/tui/app/doctor.mjs +57 -0
  97. package/src/tui/app/extension-pickers.mjs +53 -9
  98. package/src/tui/app/maintenance-pickers.mjs +0 -5
  99. package/src/tui/app/slash-dispatch.mjs +4 -4
  100. package/src/tui/app/text-layout.mjs +11 -0
  101. package/src/tui/app/use-mouse-input.mjs +235 -51
  102. package/src/tui/app/use-prompt-handlers.mjs +49 -30
  103. package/src/tui/app/use-transcript-scroll.mjs +124 -27
  104. package/src/tui/app/use-transcript-window.mjs +55 -1
  105. package/src/tui/components/Message.jsx +1 -1
  106. package/src/tui/components/PromptInput.jsx +3 -1
  107. package/src/tui/components/QueuedCommands.jsx +21 -10
  108. package/src/tui/components/StatusLine.jsx +3 -3
  109. package/src/tui/components/ToolExecution.jsx +16 -4
  110. package/src/tui/components/TranscriptItem.jsx +1 -1
  111. package/src/tui/dist/index.mjs +820 -326
  112. package/src/tui/engine/agent-job-feed.mjs +5 -0
  113. package/src/tui/engine/notification-plan.mjs +5 -0
  114. package/src/tui/engine/session-api.mjs +23 -8
  115. package/src/tui/engine/session-flow.mjs +6 -0
  116. package/src/tui/engine/tool-card-results.mjs +14 -5
  117. package/src/tui/engine/turn.mjs +9 -2
  118. package/src/tui/engine.mjs +32 -5
  119. package/src/tui/index.jsx +62 -18
  120. package/src/tui/paste-attachments.mjs +26 -0
  121. package/src/ui/statusline-agents.mjs +36 -0
  122. package/src/ui/statusline.mjs +60 -10
  123. package/src/ui/tool-card.mjs +8 -1
  124. package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
  125. package/src/workflows/bench/WORKFLOW.md +46 -0
  126. package/src/workflows/default/WORKFLOW.md +6 -0
  127. package/src/workflows/solo/WORKFLOW.md +5 -0
  128. package/vendor/ink/build/ink.js +23 -1
  129. package/vendor/ink/build/output.js +154 -71
  130. package/vendor/ink/build/render-node-to-output.js +44 -2
  131. package/vendor/ink/build/render.js +4 -0
  132. package/vendor/ink/build/renderer.js +4 -1
@@ -9,8 +9,6 @@ import {
9
9
  refreshActiveInstance,
10
10
  releaseOwnedChannelLocks,
11
11
  clearActiveInstance,
12
- probeActiveOwner,
13
- RUNTIME_ROOT,
14
12
  } from "./runtime-paths.mjs";
15
13
  // Owned-runtime lifecycle extracted from channels/index.mjs (behavior-
16
14
  // preserving): bridge-ownership claim/refresh/loss, backend connect/disconnect,
@@ -33,13 +31,11 @@ export function createOwnedRuntime({
33
31
  instanceId,
34
32
  TERMINAL_LEAD_PID,
35
33
  forwarder,
34
+ sendNotifyToParent,
36
35
  scheduler,
37
36
  statusState,
38
37
  logOwnership,
39
38
  currentOwnerState,
40
- claimBridgeOwnership,
41
- startOwnerHeartbeat,
42
- stopOwnerHeartbeat,
43
39
  bindPersistedTranscriptIfAny,
44
40
  cancelPendingTranscriptRearm,
45
41
  schedulePendingTranscriptRearm,
@@ -51,51 +47,19 @@ export function createOwnedRuntime({
51
47
  let bridgeRuntimeStarting = false;
52
48
  let _ownedRuntimeStopRequested = false;
53
49
  let bridgeOwnershipRefreshInFlight = null;
54
- let bridgeOwnershipTimer = null;
55
50
  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
- }
51
+ // Promise that resolves when the current startOwnedRuntime() run fully
52
+ // settles (bridgeRuntimeStarting -> false). reloadRuntimeConfig awaits this
53
+ // before issuing a restart so a backend swap that lands mid-start is not
54
+ // dropped by startOwnedRuntime's in-flight guard (lost-restart race).
55
+ let _inFlightStart = null;
56
+ // Daemon model: the machine-global channels daemon (singleton-owner lock in
57
+ // src/standalone) guarantees exactly one runtime per machine, so this process
58
+ // is the unconditional bridge owner. The OS seat lock is retired — no takeover
59
+ // handler, no vacant-reacquire poll, no cross-process ownership-loss detection.
93
60
  function clearBridgeOwnershipTimer() {
94
- if (bridgeOwnershipTimer) {
95
- clearInterval(bridgeOwnershipTimer);
96
- bridgeOwnershipTimer = null;
97
- }
98
- clearActiveInstanceWatcher();
61
+ // No ownership timer under the daemon model; kept as a no-op so the worker
62
+ // teardown call site stays unchanged.
99
63
  }
100
64
  function shouldStartEventPipelineRuntime() {
101
65
  return getConfig().webhook?.enabled === true || (Array.isArray(getConfig().events?.rules) && getConfig().events.rules.length > 0);
@@ -207,6 +171,15 @@ async function startOwnedRuntime(options = {}) {
207
171
  if (!getChannelBridgeActive()) return;
208
172
  bridgeRuntimeStarting = true;
209
173
  _ownedRuntimeStopRequested = false;
174
+ let _settleStart;
175
+ _inFlightStart = new Promise((r) => { _settleStart = r; });
176
+ const settleInFlightStart = () => {
177
+ bridgeRuntimeStarting = false;
178
+ _inFlightStart = null;
179
+ const done = _settleStart;
180
+ _settleStart = null;
181
+ done?.();
182
+ };
210
183
  // Capture the getBackend() instance that THIS start operation will connect. A
211
184
  // reloadRuntimeConfig() hot-swap can replace the global `getBackend()` while this
212
185
  // start is still awaiting connect(); using the captured instance for both
@@ -214,79 +187,19 @@ async function startOwnedRuntime(options = {}) {
214
187
  // getBackend() WE started (not the freshly-swapped one), closing the
215
188
  // both-backends-live window.
216
189
  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.
190
+ // Daemon model: this runtime is the singleton bridge owner (enforced by the
191
+ // standalone daemon's singleton-owner lock), so there is no seat to claim and
192
+ // no cross-process contender to back off from. Just advertise metadata
193
+ // (memory_port/pg_*/channelId/... preserved inside refreshActiveInstance);
194
+ // active-instance.json is a pure advert. Wrapped so any throw resets
195
+ // bridgeRuntimeStarting.
233
196
  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
- }
197
+ refreshActiveInstance(instanceId, { backendReady: false });
278
198
  } catch (e) {
279
- bridgeRuntimeStarting = false;
280
- process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
199
+ settleInFlightStart();
200
+ process.stderr.write(`mixdog: pre-connect metadata advert aborted (${e instanceof Error ? e.message : String(e)})\n`);
281
201
  return;
282
202
  }
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
203
  // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
291
204
  // memory service publishes its port. Idempotent + reentrancy-guarded inside
292
205
  // drainBuffer(); unref'd so it never holds the worker open.
@@ -304,7 +217,6 @@ async function startOwnedRuntime(options = {}) {
304
217
  const bailIfStopRequested = async () => {
305
218
  if (!_ownedRuntimeStopRequested) return false;
306
219
  try { await startingBackend.disconnect(); } catch {}
307
- try { stopOwnerHeartbeat(); } catch {}
308
220
  try { releaseOwnedChannelLocks(instanceId); } catch {}
309
221
  try { clearActiveInstance(instanceId); } catch {}
310
222
  setBridgeRuntimeConnected(false);
@@ -329,36 +241,13 @@ async function startOwnedRuntime(options = {}) {
329
241
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
330
242
  return;
331
243
  }
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
- }
244
+ // Advertise backend readiness (metadata advert).
245
+ try { refreshActiveInstance(instanceId, { backendReady: true }); } catch {}
354
246
  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).
247
+ // Fresh confirmed connection — tell the parent to flip remote ON. Reached
248
+ // ONLY on a not-connected -> connected transition (the top-of-fn
249
+ // early-return skips already-connected re-ticks), so this fires exactly once
250
+ // per connect and covers EVERY start path (boot + reload restart + activate).
362
251
  // The parent's acquired handler is idempotent (no-op when already remote),
363
252
  // so notifying post-connect — never pre-connect — means a connect FAILURE
364
253
  // below leaves the parent non-remote instead of stuck remote-with-no-bridge
@@ -404,13 +293,15 @@ async function startOwnedRuntime(options = {}) {
404
293
  try { forwarder.stopWatch(); } catch {}
405
294
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
406
295
  // Roll back partial owner-side state advertised before connect() ran:
407
- // heartbeat and active-instance entry.
408
- try { stopOwnerHeartbeat(); } catch {}
296
+ // disconnect the backend WE started (a post-connect startup step may have
297
+ // thrown while the gateway is live), then release the channel locks + clear
298
+ // the active-instance advert.
299
+ try { await startingBackend.disconnect(); } catch {}
409
300
  try { releaseOwnedChannelLocks(instanceId); } catch {}
410
301
  try { clearActiveInstance(instanceId); } catch {}
411
302
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
412
303
  } finally {
413
- bridgeRuntimeStarting = false;
304
+ settleInFlightStart();
414
305
  }
415
306
  }
416
307
  async function stopOwnedRuntime(reason) {
@@ -437,7 +328,6 @@ async function stopOwnedRuntime(reason) {
437
328
  // and the drain/retry timers stay live after ownership is dropped, leaking a
438
329
  // file handle + timers for the rest of the process lifetime.
439
330
  try { forwarder.stopWatch(); } catch {}
440
- stopOwnerHeartbeat();
441
331
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
442
332
  scheduler.stop();
443
333
  stopSnapshotWriter();
@@ -463,18 +353,9 @@ async function stopOwnedRuntime(reason) {
463
353
  function refreshBridgeOwnershipSafe(options = {}) {
464
354
  refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
465
355
  }
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
- }
356
+ // Daemon model: no ownership timer or takeover handler. Kept as a no-op so the
357
+ // worker start() call site stays unchanged.
358
+ function armBridgeOwnershipTimer() {}
478
359
  // Guarded IPC send to the parent: no-ops when there is no channel or it is
479
360
  // already disconnected, and swallows both the synchronous throw and the async
480
361
  // error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
@@ -489,20 +370,16 @@ function sendToParent(message) {
489
370
  process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
490
371
  }
491
372
  }
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.
373
+ // Tell the parent session this worker ACQUIRED the bridge so it flips remote
374
+ // mode ON (badge/transcript writer). Callers fire this only on a genuine
375
+ // not-connected -> connected transition never on a refresh — so the parent's
376
+ // idempotent handler sees it once. Cross-process supersede is a transport-level
377
+ // concern (daemon), not emitted here.
504
378
  function notifyRemoteAcquired() {
505
- sendToParent({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
379
+ // Sink-aware path so the daemon replays this to every TUI (the transport
380
+ // sticky-caches 'acquired'). Raw sendToParent would reach only the node-IPC
381
+ // spawner and be ignored.
382
+ sendNotifyToParent('notifications/mixdog/remote', { state: 'acquired' });
506
383
  }
507
384
  async function refreshBridgeOwnership(options = {}) {
508
385
  // Coalesce concurrent callers onto the in-flight refresh so getBackend() tool
@@ -510,56 +387,15 @@ async function refreshBridgeOwnership(options = {}) {
510
387
  // instead of returning early and observing spurious auto-connect failure.
511
388
  if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
512
389
  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.
390
+ // Daemon model: this runtime is the unconditional bridge owner, so refresh
391
+ // just keeps the owned runtime in sync with channelBridgeActive.
519
392
  if (!getChannelBridgeActive()) {
520
393
  if (getBridgeRuntimeConnected()) await stopOwnedRuntime("bridge inactive");
521
394
  return;
522
395
  }
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
- }
396
+ // Active -> ensure the owned runtime is up (idempotent; early-returns when
397
+ // already connected).
398
+ await startOwnedRuntime(options);
563
399
  })();
564
400
  try {
565
401
  return await bridgeOwnershipRefreshInFlight;
@@ -584,6 +420,12 @@ async function reloadRuntimeConfig() {
584
420
  if (backendChanged) {
585
421
  const shouldRestart = getBridgeRuntimeConnected() || bridgeRuntimeStarting;
586
422
  if (shouldRestart) await stopOwnedRuntime("getBackend() getConfig() changed");
423
+ // A start that was in flight when stopOwnedRuntime landed was signalled to
424
+ // bail (and disconnects the OLD backend it was connecting). Wait for it to
425
+ // FULLY settle before issuing the fresh start below — otherwise
426
+ // startOwnedRuntime's `if (bridgeRuntimeStarting) return` guard would drop
427
+ // the restart and the NEW backend would never connect (lost-restart race).
428
+ if (_inFlightStart) { try { await _inFlightStart; } catch {} }
587
429
  setBackend(nextBackend);
588
430
  // The persisted routing channelId belongs to the OLD getBackend() (e.g. a
589
431
  // Discord snowflake) and is meaningless for the new one — sending to it
@@ -603,6 +445,9 @@ async function reloadRuntimeConfig() {
603
445
  state.transcriptPath = "";
604
446
  });
605
447
  } catch {}
448
+ // stopOwnedRuntime above tore the owned runtime down; a same-session reload
449
+ // reconnects the NEW backend here. The in-flight start (if any) has already
450
+ // settled above, so this start is not dropped by the in-flight guard.
606
451
  if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
607
452
  } else if (nextBackend !== previousBackend) {
608
453
  try { await nextBackend.disconnect?.(); } catch {}
@@ -622,6 +467,5 @@ async function reloadRuntimeConfig() {
622
467
  armBridgeOwnershipTimer,
623
468
  clearBridgeOwnershipTimer,
624
469
  notifyRemoteAcquired,
625
- notifyRemoteSuperseded,
626
470
  };
627
471
  }
@@ -1,16 +1,11 @@
1
- // Bridge ownership snapshot + owner heartbeat cluster. Extracted verbatim from
2
- // channels/index.mjs (behavior-preserving). Self-contained: owns its own
3
- // heartbeat timer + last-note dedup state, and depends only on the
4
- // active-instance read/refresh primitives + identity, threaded as injected
5
- // deps so the module reads live file-level references at call time.
6
- function createOwnerHeartbeat({
7
- getInstanceId,
8
- readActiveInstance,
9
- refreshActiveInstance,
10
- OWNER_HEARTBEAT_INTERVAL_MS = 5e3,
11
- }) {
1
+ // Bridge ownership snapshot + ownership logging. Under the machine-global
2
+ // channels daemon (singleton-owner lock in src/standalone) there is exactly one
3
+ // runtime per machine, so this process is the unconditional bridge owner — the
4
+ // OS seat lock and its file heartbeat / last-wins CAS are retired.
5
+ // active-instance.json is now a pure metadata advert; it is no longer read to
6
+ // decide ownership.
7
+ function createOwnerHeartbeat() {
12
8
  let lastOwnershipNote = "";
13
- let ownerHeartbeatTimer = null;
14
9
 
15
10
  function logOwnership(note) {
16
11
  if (lastOwnershipNote === note) return;
@@ -19,65 +14,17 @@ function createOwnerHeartbeat({
19
14
  `);
20
15
  }
21
16
  function currentOwnerState() {
22
- const active = readActiveInstance();
23
- return {
24
- active,
25
- // Strict last-wins: this process owns the bridge ONLY when active-instance
26
- // names exactly this INSTANCE_ID. A newer remote session that claims the
27
- // seat overwrites instanceId, so the old owner immediately reads owned=false
28
- // and disconnects on its next refresh tick. No PID/terminal fallback —
29
- // that used to let a co-terminal worker wrongly self-claim.
30
- owned: active?.instanceId === getInstanceId()
31
- };
17
+ // Daemon singleton: this runtime always owns the bridge.
18
+ return { owned: true };
32
19
  }
33
20
  function getBridgeOwnershipSnapshot() {
34
21
  return currentOwnerState();
35
22
  }
36
- function claimBridgeOwnership(reason, options = {}) {
37
- // Returns true only when THIS instance actually holds the seat after the
38
- // write. With options.timeoutMs:0 (try-once) a contended lock throws
39
- // ELOCKCONTENDED — the caller catches it and treats the seat as busy.
40
- const refreshOpts = Number.isFinite(options.timeoutMs) ? { timeoutMs: options.timeoutMs } : undefined;
41
- const res = refreshActiveInstance(getInstanceId(), undefined, refreshOpts);
42
- const claimed = res?.instanceId === getInstanceId();
43
- if (claimed) logOwnership(`claimed owner (${reason})`);
44
- return claimed;
45
- }
46
- function startOwnerHeartbeat() {
47
- if (ownerHeartbeatTimer) return;
48
- ownerHeartbeatTimer = setInterval(() => {
49
- try {
50
- // Last-wins guard: only refresh the seat if we STILL own it. If a newer
51
- // remote session claimed active-instance.json since our last tick, do
52
- // NOT overwrite it back — that would re-steal ownership and cause
53
- // ping-pong / double backend connections. The bridgeOwnershipTimer's
54
- // refreshBridgeOwnership() will observe owned=false and disconnect us.
55
- // onlyIfOwned: re-checks ownership INSIDE the file lock so a newer
56
- // owner claiming the seat between currentOwnerState() and the locked
57
- // write is never overwritten (see refreshActiveInstance CAS guard).
58
- // Try-once (timeoutMs:0): the heartbeat must never block on the
59
- // active-instance lock. On contention refreshActiveInstance throws and
60
- // we simply skip this tick — the next tick catches up.
61
- if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true, timeoutMs: 0 });
62
- } catch (e) {
63
- process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
64
- }
65
- }, OWNER_HEARTBEAT_INTERVAL_MS);
66
- ownerHeartbeatTimer.unref?.();
67
- }
68
- function stopOwnerHeartbeat() {
69
- if (!ownerHeartbeatTimer) return;
70
- clearInterval(ownerHeartbeatTimer);
71
- ownerHeartbeatTimer = null;
72
- }
73
23
 
74
24
  return {
75
25
  logOwnership,
76
26
  currentOwnerState,
77
27
  getBridgeOwnershipSnapshot,
78
- claimBridgeOwnership,
79
- startOwnerHeartbeat,
80
- stopOwnerHeartbeat,
81
28
  };
82
29
  }
83
30
 
@@ -14,6 +14,16 @@ function normalizeChannelNotifyParams(method, params) {
14
14
  return params;
15
15
  }
16
16
 
17
+ // Pluggable notify sink. Under the per-TUI fork model notifies flow over
18
+ // node-IPC to the parent (process.send). Under the machine-global daemon there
19
+ // is no IPC parent: the daemon entry installs a sink that routes each notify to
20
+ // the CORRECT attached TUI over the transport's SSE fan-out (targeted, never
21
+ // broadcast). When a sink is installed it fully replaces the process.send path.
22
+ let _notifySink = null;
23
+ function setChannelNotifySink(fn) {
24
+ _notifySink = typeof fn === 'function' ? fn : null;
25
+ }
26
+
17
27
  function createParentBridge({ getInstanceId }) {
18
28
  function sendNotifyToParent(method, params) {
19
29
  // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
@@ -22,6 +32,11 @@ function createParentBridge({ getInstanceId }) {
22
32
  // silent_to_agent stays boolean — an internal routing flag the daemon
23
33
  // router / agentNotify consume (=== true) before the CC zod boundary.
24
34
  const outParams = normalizeChannelNotifyParams(method, params);
35
+ if (_notifySink) {
36
+ try { _notifySink(method, outParams); }
37
+ catch (err) { try { process.stderr.write(`mixdog channels: notify sink failed: ${err && err.message || err}\n`); } catch {} }
38
+ return;
39
+ }
25
40
  if (!process.send) {
26
41
  try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
27
42
  return;
@@ -85,4 +100,4 @@ function createParentBridge({ getInstanceId }) {
85
100
  };
86
101
  }
87
102
 
88
- export { createParentBridge, normalizeChannelNotifyParams };
103
+ export { createParentBridge, normalizeChannelNotifyParams, setChannelNotifySink };