mixdog 0.9.18 → 0.9.19

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 (122) hide show
  1. package/package.json +3 -2
  2. package/scripts/output-style-smoke.mjs +2 -2
  3. package/scripts/recall-bench-cases.json +11 -0
  4. package/scripts/recall-bench.mjs +91 -2
  5. package/scripts/tool-efficiency-diag.mjs +4 -1
  6. package/scripts/tool-smoke.mjs +101 -27
  7. package/src/agents/debugger/AGENT.md +3 -3
  8. package/src/agents/heavy-worker/AGENT.md +7 -10
  9. package/src/agents/maintainer/AGENT.md +1 -2
  10. package/src/agents/reviewer/AGENT.md +1 -2
  11. package/src/agents/worker/AGENT.md +5 -8
  12. package/src/defaults/agents.json +3 -0
  13. package/src/mixdog-session-runtime.mjs +23 -6
  14. package/src/rules/agent/00-core.md +4 -3
  15. package/src/rules/agent/30-explorer.md +53 -22
  16. package/src/rules/lead/lead-tool.md +3 -2
  17. package/src/rules/shared/01-tool.md +24 -29
  18. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  19. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +24 -3
  21. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +3 -3
  22. package/src/runtime/agent/orchestrator/session/context-utils.mjs +2 -2
  23. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +250 -35
  24. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +6 -4
  25. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +198 -6
  26. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +13 -15
  28. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +6 -1
  29. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +45 -3
  30. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -2
  31. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +195 -3
  32. package/src/runtime/agent/orchestrator/tools/builtin.mjs +17 -1
  33. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +38 -0
  34. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -4
  35. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +1 -1
  36. package/src/runtime/channels/backends/discord-gateway.mjs +14 -1
  37. package/src/runtime/channels/backends/discord.mjs +43 -1
  38. package/src/runtime/channels/index.mjs +145 -58
  39. package/src/runtime/channels/lib/inbound-routing.mjs +19 -12
  40. package/src/runtime/channels/lib/memory-client.mjs +32 -14
  41. package/src/runtime/channels/lib/output-forwarder.mjs +38 -7
  42. package/src/runtime/channels/lib/owner-heartbeat.mjs +13 -4
  43. package/src/runtime/channels/lib/runtime-paths.mjs +35 -1
  44. package/src/runtime/channels/lib/tool-dispatch.mjs +17 -7
  45. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +8 -2
  46. package/src/runtime/memory/lib/memory-recall-store.mjs +182 -9
  47. package/src/runtime/memory/lib/query-handlers.mjs +36 -4
  48. package/src/runtime/memory/lib/recall-format.mjs +106 -6
  49. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  50. package/src/runtime/shared/atomic-file.mjs +10 -4
  51. package/src/runtime/shared/background-tasks.mjs +4 -2
  52. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  53. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  54. package/src/runtime/shared/tool-surface.mjs +30 -1
  55. package/src/session-runtime/config-lifecycle.mjs +1 -1
  56. package/src/session-runtime/cwd-plugins.mjs +46 -3
  57. package/src/session-runtime/mcp-glue.mjs +24 -3
  58. package/src/session-runtime/output-styles.mjs +44 -10
  59. package/src/session-runtime/workflow.mjs +16 -1
  60. package/src/standalone/channel-worker.mjs +74 -5
  61. package/src/standalone/explore-tool.mjs +1 -1
  62. package/src/tui/App.jsx +57 -77
  63. package/src/tui/app/channel-pickers.mjs +45 -0
  64. package/src/tui/app/slash-commands.mjs +0 -1
  65. package/src/tui/app/slash-dispatch.mjs +0 -16
  66. package/src/tui/app/transcript-window.mjs +44 -1
  67. package/src/tui/app/use-mouse-input.mjs +9 -2
  68. package/src/tui/app/use-prompt-handlers.mjs +7 -94
  69. package/src/tui/app/use-transcript-scroll.mjs +5 -1
  70. package/src/tui/app/use-transcript-window.mjs +65 -5
  71. package/src/tui/components/PromptInput.jsx +33 -64
  72. package/src/tui/components/ToolExecution.jsx +2 -2
  73. package/src/tui/dist/index.mjs +5330 -5443
  74. package/src/tui/engine.mjs +49 -10
  75. package/src/tui/lib/voice-setup.mjs +166 -0
  76. package/src/tui/paste-attachments.mjs +12 -5
  77. package/src/tui/prompt-history-store.mjs +125 -12
  78. package/scripts/bench/cache-probe-tasks.json +0 -8
  79. package/scripts/bench/lead-review-tasks-r3.json +0 -20
  80. package/scripts/bench/lead-review-tasks.json +0 -20
  81. package/scripts/bench/r4-mixed-tasks.json +0 -20
  82. package/scripts/bench/r5-orchestrated-task.json +0 -7
  83. package/scripts/bench/review-tasks.json +0 -20
  84. package/scripts/bench/round-codex.json +0 -114
  85. package/scripts/bench/round-mixdog-lead-r3.json +0 -269
  86. package/scripts/bench/round-mixdog-lead.json +0 -269
  87. package/scripts/bench/round-mixdog.json +0 -126
  88. package/scripts/bench/round-r10-bigsample.json +0 -679
  89. package/scripts/bench/round-r11-codexalign.json +0 -257
  90. package/scripts/bench/round-r13-clientmeta.json +0 -464
  91. package/scripts/bench/round-r14-betafeatures.json +0 -466
  92. package/scripts/bench/round-r15-fulldefault.json +0 -462
  93. package/scripts/bench/round-r16-sessionid.json +0 -466
  94. package/scripts/bench/round-r17-wirebytes.json +0 -456
  95. package/scripts/bench/round-r18-prewarm.json +0 -468
  96. package/scripts/bench/round-r19-clean.json +0 -472
  97. package/scripts/bench/round-r20-prewarm-clean.json +0 -475
  98. package/scripts/bench/round-r21-delta-retry.json +0 -473
  99. package/scripts/bench/round-r22-full-probe.json +0 -693
  100. package/scripts/bench/round-r23-itemprobe.json +0 -701
  101. package/scripts/bench/round-r24-shapefix.json +0 -677
  102. package/scripts/bench/round-r25-serial.json +0 -464
  103. package/scripts/bench/round-r26-parallel3.json +0 -671
  104. package/scripts/bench/round-r27-parallel10.json +0 -894
  105. package/scripts/bench/round-r28-parallel10-stagger.json +0 -882
  106. package/scripts/bench/round-r29-parallel10-stagger166.json +0 -886
  107. package/scripts/bench/round-r30-instid.json +0 -253
  108. package/scripts/bench/round-r31-upgradeprobe.json +0 -256
  109. package/scripts/bench/round-r32-vs-codex-lead.json +0 -254
  110. package/scripts/bench/round-r33-vs-codex-codex.json +0 -115
  111. package/scripts/bench/round-r34-orchestrated.json +0 -120
  112. package/scripts/bench/round-r35-orchestrated-codex.json +0 -61
  113. package/scripts/bench/round-r36-orchestrated-capped.json +0 -128
  114. package/scripts/bench/round-r4-codex.json +0 -114
  115. package/scripts/bench/round-r4-mixed.json +0 -225
  116. package/scripts/bench/round-r5-gpt-lead.json +0 -259
  117. package/scripts/bench/round-r6-codex.json +0 -114
  118. package/scripts/bench/round-r6-solo.json +0 -257
  119. package/scripts/bench/round-r7-full.json +0 -254
  120. package/scripts/bench/round-r8-fulldefault.json +0 -255
  121. package/src/tui/components/prompt-input/voice-indicator.mjs +0 -39
  122. package/src/tui/lib/voice-recorder.mjs +0 -469
@@ -42,6 +42,7 @@ import {
42
42
  readActiveInstance,
43
43
  refreshActiveInstance,
44
44
  cleanupStaleRuntimeFiles,
45
+ probeActiveOwner,
45
46
  cleanupInstanceRuntimeFiles,
46
47
  releaseOwnedChannelLocks,
47
48
  clearActiveInstance,
@@ -789,23 +790,54 @@ async function startOwnedRuntime(options = {}) {
789
790
  // backend WE started (not the freshly-swapped one), closing the
790
791
  // both-backends-live window.
791
792
  const startingBackend = backend;
792
- // Advertise active-instance.json BEFORE backend connect so a newer remote
793
- // session's last-wins claim is visible immediately. backendReady=false
794
- // marks the partial state until backend.connect() succeeds.
795
- // onlyIfOwned: startOwnedRuntime is only entered on the owned path, but a
796
- // newer session can claim the seat between that check and this write; CAS
797
- // aborts instead of re-stealing. The write result is checked below so a
798
- // CAS abort stops the start path immediately (heartbeat/backend/scheduler
799
- // never start) instead of relying on the 3s ownership timer to notice.
800
- const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true });
801
- // A successful CAS write always sets instanceId to ours; any other result
802
- // (aborted write returning the stale/foreign/missing prior state) means
803
- // the seat was not ours to claim abort the start path here rather than
804
- // relying on the 3s ownership timer to notice later.
805
- if (casResult?.instanceId !== INSTANCE_ID) {
793
+ const claimAfterReady = options.claimAfterReady === true;
794
+ // Single-holder correctness: the seat is claimed BEFORE backend.connect(),
795
+ // never after. The old make-before-break (claim-after-ready) boot left two
796
+ // gateways connected and contending during the multi-second connect window;
797
+ // claiming first makes the previous owner observe owned=false on its next
798
+ // tick and drain+disconnect, so at most one gateway ever serves.
799
+ // - boot (claimAfterReady): last-wins acquire this new remote session
800
+ // takes the seat outright.
801
+ // - refresh/owned-path: CAS onlyIfOwned abort fast if the seat moved.
802
+ // backendReady=false marks the partial state until backend.connect() succeeds.
803
+ // Wrapped so ANY throw in the pre-connect claim (lock contention/error)
804
+ // resets bridgeRuntimeStartingotherwise a transient lock error would
805
+ // leave it stuck true and permanently block every future ownership attempt.
806
+ try {
807
+ if (claimAfterReady) {
808
+ refreshActiveInstance(INSTANCE_ID, { backendReady: false });
809
+ logOwnership("boot claim (pre-connect, last-wins)");
810
+ } else {
811
+ // Try-once (timeoutMs:0): a refresh-path re-acquire must never block on
812
+ // the active-instance lock. Contention throws → caught below and treated
813
+ // as "seat busy, abort this attempt"; the next 3s tick retries.
814
+ const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true, timeoutMs: 0 });
815
+ // A successful CAS write always sets instanceId to ours; any other result
816
+ // (aborted write returning the stale/foreign/missing prior state) means the
817
+ // seat was not ours to claim — abort here rather than relying on the timer.
818
+ if (casResult?.instanceId !== INSTANCE_ID) {
819
+ bridgeRuntimeStarting = false;
820
+ return;
821
+ }
822
+ }
823
+ // A newer session can claim between our write and this read. If we no longer
824
+ // own the seat, abort fast WITHOUT starting heartbeat/backend/scheduler/
825
+ // webhook/ngrok — ancillary runtime starts only past a confirmed-owned claim.
826
+ if (!currentOwnerState().owned) {
827
+ bridgeRuntimeStarting = false;
828
+ return;
829
+ }
830
+ } catch (e) {
806
831
  bridgeRuntimeStarting = false;
832
+ process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
807
833
  return;
808
834
  }
835
+ // Arm ownership-loss detection BEFORE backend.connect() so a session that is
836
+ // superseded during the (multi-second) connect window is torn down: the 3s
837
+ // tick observes a newer owner and flips _ownedRuntimeStopRequested, and
838
+ // bailIfStopRequested below disconnects the backend WE connected.
839
+ armBridgeOwnershipTimer();
840
+ // Heartbeat is ownership-gated; safe to arm now that we hold the seat.
809
841
  startOwnerHeartbeat();
810
842
  // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
811
843
  // memory service publishes its port. Idempotent + reentrancy-guarded inside
@@ -849,12 +881,34 @@ async function startOwnedRuntime(options = {}) {
849
881
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
850
882
  return;
851
883
  }
884
+ // Post-connect ownership confirm (CAS). The seat was claimed BEFORE
885
+ // connect; a newer session may have superseded us during the connect
886
+ // window (the 3s tick would already be tearing us down). onlyIfOwned:
887
+ // never re-steal here. If the CAS aborts we LOST the seat — disconnect the
888
+ // backend WE connected and mark the bridge runtime not connected (do NOT
889
+ // clear active-instance; the newer owner holds it).
890
+ let ownConfirm;
891
+ try {
892
+ ownConfirm = refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
893
+ } catch { ownConfirm = null; }
894
+ if (ownConfirm?.instanceId !== INSTANCE_ID) {
895
+ try { await startingBackend.disconnect(); } catch {}
896
+ try { stopOwnerHeartbeat(); } catch {}
897
+ try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
898
+ if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
899
+ bridgeRuntimeConnected = false;
900
+ cancelPendingTranscriptRearm();
901
+ try { forwarder.stopWatch(); } catch {}
902
+ if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
903
+ notifyRemoteSuperseded();
904
+ return;
905
+ }
852
906
  bridgeRuntimeConnected = true;
853
- // onlyIfOwned: backend.connect() above can take seconds — a newer owner
854
- // claiming the seat during that await must not be overwritten here. On
855
- // CAS abort the next refreshBridgeOwnership tick observes owned=false
856
- // and stops this runtime; backendReady stays unset for the lost seat.
857
- refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
907
+ if (claimAfterReady) {
908
+ // First confirmed ownership at boot tell the parent it holds the seat
909
+ // exactly once (heartbeat/refresh re-pin ownership but never re-enter).
910
+ notifyRemoteAcquired();
911
+ }
858
912
  // initProviders must complete before scheduler.start() — otherwise the
859
913
  // scheduler's first fire can land before the registry is populated and
860
914
  // return `Provider "<name>" not found or not enabled`. The previous
@@ -938,7 +992,14 @@ async function stopOwnedRuntime(reason) {
938
992
  try {
939
993
  // Only disconnect the backend when connect() actually completed; calling
940
994
  // disconnect() mid-connect races the connect promise.
941
- if (wasConnected) await backend.disconnect();
995
+ if (wasConnected) {
996
+ // Drain in-flight outbound sends before disconnecting so a handoff
997
+ // (owned=false observed → ownership lost) never cuts off a reply
998
+ // mid-delivery. Bounded inside drainPendingSends so a wedged send can
999
+ // not stall teardown — we still disconnect promptly.
1000
+ try { await backend.drainPendingSends?.(); } catch {}
1001
+ await backend.disconnect();
1002
+ }
942
1003
  } finally {
943
1004
  bridgeRuntimeConnected = false;
944
1005
  logOwnership(`standby: ${reason}`);
@@ -947,6 +1008,16 @@ async function stopOwnedRuntime(reason) {
947
1008
  function refreshBridgeOwnershipSafe(options = {}) {
948
1009
  refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
949
1010
  }
1011
+ // Ownership-loss / re-acquire detection timer. Armed BEFORE backend.connect()
1012
+ // (from startOwnedRuntime) so a session superseded during the connect window is
1013
+ // torn down promptly, and re-armed idempotently on every start path.
1014
+ function armBridgeOwnershipTimer() {
1015
+ if (bridgeOwnershipTimer) return;
1016
+ bridgeOwnershipTimer = setInterval(() => {
1017
+ refreshBridgeOwnershipSafe();
1018
+ }, 3e3);
1019
+ bridgeOwnershipTimer.unref?.();
1020
+ }
950
1021
  // Tell the parent session that this worker LOST the bridge seat to a newer
951
1022
  // remote session (last-wins). The parent flips its remote mode OFF entirely —
952
1023
  // exactly one session holds remote; losers fully release, no handover.
@@ -956,6 +1027,18 @@ function notifyRemoteSuperseded() {
956
1027
  process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
957
1028
  } catch {}
958
1029
  }
1030
+ // Symmetric to notifyRemoteSuperseded: tell the parent session this worker
1031
+ // ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
1032
+ // Guarded by process.send so a manually-forked worker with no IPC parent is a
1033
+ // no-op instead of crashing. Callers fire this only on a genuine claim
1034
+ // transition (boot make-before-break, activate when not already owned) — never
1035
+ // on a heartbeat refresh — so the parent's idempotent handler sees it once.
1036
+ function notifyRemoteAcquired() {
1037
+ if (!process.send) return;
1038
+ try {
1039
+ process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
1040
+ } catch {}
1041
+ }
959
1042
  async function refreshBridgeOwnership(options = {}) {
960
1043
  // Coalesce concurrent callers onto the in-flight refresh so backend tool
961
1044
  // calls landing during normal login wait for the same connect attempt
@@ -972,40 +1055,44 @@ async function refreshBridgeOwnership(options = {}) {
972
1055
  if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
973
1056
  return;
974
1057
  }
975
- const { active, owned } = currentOwnerState();
1058
+ // Non-blocking ownership probe (read-only, no lock): distinguishes a live
1059
+ // owner from a genuinely empty/stale seat from an INDETERMINATE read (a
1060
+ // concurrent atomic rename yields partial content). "Locked/unreadable =
1061
+ // busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
1062
+ // never claim a seat we merely failed to read (double-owner guard).
1063
+ const probe = probeActiveOwner();
1064
+ if (probe.status === 'unknown') return;
1065
+ const owned = probe.status === 'live' && probe.state?.instanceId === INSTANCE_ID;
976
1066
  if (owned) {
977
- // onlyIfOwned: ownership was checked outside the file lock; CAS mode
978
- // prevents this periodic tick from overwriting a newer owner that
979
- // claimed the seat in between (re-steal / ping-pong guard).
980
- refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true });
1067
+ // Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
1068
+ // and skip the metadata touch never block the tick. We still own, so
1069
+ // ensure/keep the owned runtime up.
1070
+ try { refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
981
1071
  await startOwnedRuntime(options);
982
1072
  return;
983
1073
  }
984
- // Not the owner. Two sub-cases:
985
- // (a) A live remote session holds the seat (active-instance names a
986
- // different, non-stale instance) last-wins: we lost, go quiet
987
- // (disconnect if we were connected) and tell the parent session to
988
- // drop remote mode entirely (single-holder, no handover).
989
- // (b) There is NO live owner (active is null/stale — e.g. our own entry
990
- // was cleared after a backend-connect failure or a bridge
991
- // deactivate/reactivate) this remote session claims the empty seat
992
- // and starts the owned runtime. Without this, a remote session could
993
- // never (re)acquire ownership once its active entry was cleared.
994
- if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
995
- if (bridgeRuntimeConnected) {
1074
+ if (probe.status === 'live' && probe.state.instanceId && probe.state.instanceId !== INSTANCE_ID) {
1075
+ // A different live remote session holds the seat (last-wins: we lost).
1076
+ // Go quiet (disconnect if connected) and tell the parent to drop remote
1077
+ // mode entirely (single-holder, no handover). Notify UNCONDITIONALLY
1078
+ // a loser whose backend never connected must still drop its Remote
1079
+ // indicator; the parent handler is idempotent.
1080
+ // Also cover the STARTING phase: a worker stuck in backend.connect() must
1081
+ // get _ownedRuntimeStopRequested set (stopOwnedRuntime does this while
1082
+ // bridgeRuntimeStarting) so bailIfStopRequested tears it down promptly
1083
+ // once connect() resolves otherwise the superseded connect lingers.
1084
+ if (bridgeRuntimeConnected || bridgeRuntimeStarting) {
996
1085
  await stopOwnedRuntime("ownership lost (newer remote session)");
997
1086
  }
998
- // Notify the parent UNCONDITIONALLY (not only when we were connected):
999
- // a loser whose backend never connected (seat stolen mid-connect,
1000
- // connect failed, or already in standby) must still drop its Remote
1001
- // indicator. The parent handler is idempotent — it only acts while
1002
- // remoteEnabled is still true — so repeat ticks are harmless.
1003
1087
  notifyRemoteSuperseded();
1004
1088
  return;
1005
1089
  }
1006
- // No live owner claim the empty seat and start.
1007
- claimBridgeOwnership("no active owner");
1008
- if (currentOwnerState().owned) {
1090
+ // Empty (absent) or stale (dead owner) seat claim it. Try-once
1091
+ // (timeoutMs:0): a contended lock means a concurrent claimant is mid-write,
1092
+ // so treat as busy and skip this tick rather than block.
1093
+ let claimed = false;
1094
+ try { claimed = claimBridgeOwnership("no active owner", { timeoutMs: 0 }); } catch { claimed = false; }
1095
+ if (claimed) {
1009
1096
  await startOwnedRuntime(options);
1010
1097
  }
1011
1098
  })();
@@ -1515,10 +1602,12 @@ const {
1515
1602
  lifecycle: {
1516
1603
  getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
1517
1604
  getChannelBridgeActive: () => channelBridgeActive,
1605
+ getOwned: () => getBridgeOwnershipSnapshot().owned,
1518
1606
  setChannelBridgeActive: (v) => { channelBridgeActive = v; },
1519
1607
  writeBridgeState,
1520
1608
  stopServerTyping,
1521
1609
  claimBridgeOwnership,
1610
+ notifyRemoteAcquired,
1522
1611
  refreshBridgeOwnership,
1523
1612
  bindPersistedTranscriptIfAny,
1524
1613
  stopOwnedRuntime,
@@ -1845,14 +1934,15 @@ async function init(_sharedMcp) {
1845
1934
  async function start() {
1846
1935
  channelBridgeActive = true;
1847
1936
  writeBridgeState(true);
1848
- // Opt-in remote, single-owner, last-wins. Claim the seat immediately so a
1849
- // later `mixdog --remote` session overwrites us and we drop on our next
1850
- // refresh tick. Then connect the owned runtime and arm the ownership timer
1851
- // that keeps checking whether a newer session has taken over.
1852
- claimBridgeOwnership("remote start");
1937
+ // Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
1938
+ // seat up front: connect our own gateway first and claim only after it reports
1939
+ // READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
1940
+ // owner keeps serving, so a boot causes no send/receive gap. This is also the
1941
+ // SINGLE boot claim — the old "remote start" pre-claim + "re-activate
1942
+ // takeover" second claim (double reconnect) is gone.
1853
1943
  const _bindingReadyStart = Date.now();
1854
1944
  try {
1855
- await refreshBridgeOwnership({ restoreBinding: true });
1945
+ await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true });
1856
1946
  bindingReadyStatus = "resolved";
1857
1947
  dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
1858
1948
  _bindingReadyResolve(true);
@@ -1862,13 +1952,10 @@ async function start() {
1862
1952
  _bindingReadyResolve(e);
1863
1953
  }
1864
1954
  // Ownership timer: keep checking whether a newer remote session has taken
1865
- // over (last-wins) so a superseded owner disconnects promptly.
1866
- if (!bridgeOwnershipTimer) {
1867
- bridgeOwnershipTimer = setInterval(() => {
1868
- refreshBridgeOwnershipSafe();
1869
- }, 3e3);
1870
- bridgeOwnershipTimer.unref?.();
1871
- }
1955
+ // over (last-wins) so a superseded owner disconnects promptly. Normally
1956
+ // already armed by startOwnedRuntime before connect(); this is the fallback
1957
+ // for a start path that aborted pre-connect (idempotent).
1958
+ armBridgeOwnershipTimer();
1872
1959
  // Hot-reload config on file change (schedules/webhooks/events).
1873
1960
  if (!_configWatcher) {
1874
1961
  try {
@@ -28,6 +28,10 @@ function createInboundRouting({ getConfig, getInstanceId, getChannelOwnerPath })
28
28
  const now = Date.now();
29
29
  if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
30
30
  inboundSeen.set(key, now);
31
+ // Cross-process marker write MUST stay synchronous: a fire-and-forget wx
32
+ // write opens a duplicate-delivery window where two processes both return
33
+ // false before either callback observes EEXIST. Single small write+stat is
34
+ // cheap. Only the periodic sweep is moved off the hot path (async).
31
35
  const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
32
36
  try {
33
37
  fs.writeFileSync(marker, String(now), { flag: "wx" });
@@ -39,24 +43,27 @@ function createInboundRouting({ getConfig, getInstanceId, getChannelOwnerPath })
39
43
  } catch {}
40
44
  }
41
45
  }
42
- if (Math.random() < 0.1) {
43
- try {
44
- for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
45
- const fp = path.join(INBOUND_DEDUP_DIR, f);
46
- try {
47
- if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
48
- } catch {
49
- }
50
- }
51
- } catch {
52
- }
53
- }
46
+ scheduleSweep(now);
54
47
  for (const [k, t] of inboundSeen) {
55
48
  if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
56
49
  }
57
50
  return false;
58
51
  }
59
52
 
53
+ function scheduleSweep(now) {
54
+ if (Math.random() < 0.1) {
55
+ fs.readdir(INBOUND_DEDUP_DIR, (e, list) => {
56
+ if (e) return;
57
+ for (const f of list) {
58
+ const fp = path.join(INBOUND_DEDUP_DIR, f);
59
+ fs.stat(fp, (se, stat) => {
60
+ if (!se && now - stat.mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
61
+ });
62
+ }
63
+ });
64
+ }
65
+ }
66
+
60
67
  function resolveInboundRoute(chatId, parentChatId) {
61
68
  const config = getConfig();
62
69
  // Single main channel: there is no per-channel label/mode map anymore.
@@ -190,25 +190,42 @@ function atomicWrite(targetPath, contents) {
190
190
  catch (e) { try { fs.unlinkSync(tmp) } catch {}; throw e }
191
191
  }
192
192
 
193
+ // In-memory dedupeKey(filePath) -> absolute buffer path index. Replaces the
194
+ // per-event O(N) readdir+read+parse dedupe scan in bufferToDisk. Seeded once
195
+ // (lazily) from disk so cross-restart dedupe (one buffered ingest per
196
+ // transcriptPath) still holds; maintained incrementally on write. A stale
197
+ // entry (file drained/renamed) is caught by an existsSync guard at the call
198
+ // site, so no drain/replay format or TTL semantics change.
199
+ const _dedupeIndex = new Map()
200
+ let _dedupeIndexSeeded = false
201
+ function seedDedupeIndex(kind) {
202
+ if (_dedupeIndexSeeded) return
203
+ _dedupeIndexSeeded = true
204
+ let existing = []
205
+ try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
206
+ for (const name of existing) {
207
+ if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
208
+ try {
209
+ const prev = JSON.parse(fs.readFileSync(path.join(BUFFER_DIR, name), 'utf8'))
210
+ if (prev && prev.filePath != null) _dedupeIndex.set(prev.filePath, path.join(BUFFER_DIR, name))
211
+ } catch {}
212
+ }
213
+ }
214
+
193
215
  function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
194
216
  try {
195
217
  fs.mkdirSync(BUFFER_DIR, { recursive: true })
196
218
  if (dedupeKey != null) {
197
- let existing = []
198
- try { existing = fs.readdirSync(BUFFER_DIR) } catch {}
199
- for (const name of existing) {
200
- if (!name.startsWith(`${kind}-`) || !name.endsWith('.json')) continue
201
- const full = path.join(BUFFER_DIR, name)
202
- try {
203
- const prev = JSON.parse(fs.readFileSync(full, 'utf8'))
204
- if (prev && prev.filePath === dedupeKey) {
205
- // Atomic overwrite (tmp+rename); preserves the original (oldest)
206
- // ordering slot without exposing a half-written file to a drainer.
207
- atomicWrite(full, JSON.stringify(payload, null, 2))
208
- return full
209
- }
210
- } catch {}
219
+ // In-memory index (seeded once from disk) replaces the per-event O(N)
220
+ // readdir+read+parse scan. Overwrite the existing buffered file for this
221
+ // dedupeKey in place (atomic tmp+rename) to keep its oldest ordering slot.
222
+ seedDedupeIndex(kind)
223
+ const idx = _dedupeIndex.get(dedupeKey)
224
+ if (idx && fs.existsSync(idx)) {
225
+ atomicWrite(idx, JSON.stringify(payload, null, 2))
226
+ return idx
211
227
  }
228
+ if (idx) _dedupeIndex.delete(dedupeKey)
212
229
  }
213
230
  enforceBufferCap()
214
231
  const random = Math.random().toString(36).slice(2, 10)
@@ -216,6 +233,7 @@ function bufferToDisk(kind, payload, { dedupeKey = null } = {}) {
216
233
  // ordering under a lexicographic sort.
217
234
  const bufferPath = path.join(BUFFER_DIR, `${kind}-${Date.now()}-${random}.json`)
218
235
  atomicWrite(bufferPath, JSON.stringify(payload, null, 2))
236
+ if (dedupeKey != null) _dedupeIndex.set(dedupeKey, bufferPath)
219
237
  return bufferPath
220
238
  } catch (bufErr) {
221
239
  process.stderr.write(`[memory-client] Failed to buffer ${kind}: ${bufErr.message}\n`)
@@ -76,6 +76,9 @@ class OutputForwarder {
76
76
  lastFileSize = 0;
77
77
  readFileSize = 0;
78
78
  watchingPath = "";
79
+ // Set by readNewLines when a capped tick left more complete bytes on disk,
80
+ // so the poll callback re-arms a flush without waiting on a fresh fs event.
81
+ _pendingReadMore = false;
79
82
  watcher = null;
80
83
  idleTimer = null;
81
84
  onIdleCallback = null;
@@ -96,6 +99,11 @@ class OutputForwarder {
96
99
  // into the next pending text payload so we don't grow unbounded but
97
100
  // also don't lose content.
98
101
  static SEND_QUEUE_MAX = 200;
102
+ // Cap the bytes read per streaming tick so a large burst (e.g. a big tool
103
+ // result written at once) is amortized across ticks instead of allocating a
104
+ // multi-MB buffer and blocking the loop on one readSync. The cursor only
105
+ // advances by bytes actually consumed; the remainder is picked up next tick.
106
+ static MAX_TICK_READ_BYTES = 2 * 1024 * 1024;
99
107
  // Persisted final-flush ledger so a forwarder restart can resume final
100
108
  // forwarding instead of giving up after 5 short retries.
101
109
  pendingFinalFlush = false;
@@ -217,12 +225,13 @@ class OutputForwarder {
217
225
  }
218
226
  }
219
227
  /** Read new bytes from transcript file since readFileSize */
220
- readNewLines() {
228
+ readNewLines(uncapped = false) {
221
229
  if (!this.transcriptPath || !existsSync(this.transcriptPath)) {
222
230
  return { lines: [], nextFileSize: this.readFileSize };
223
231
  }
224
232
  let fd = null;
225
233
  try {
234
+ this._pendingReadMore = false;
226
235
  const stat = this._pendingStat ?? statSync(this.transcriptPath);
227
236
  this._pendingStat = null;
228
237
  // File shrank below our cursor: the transcript was rotated out from
@@ -237,16 +246,33 @@ class OutputForwarder {
237
246
  return { lines: [], nextFileSize: this.readFileSize };
238
247
  }
239
248
  const startOffset = this.readFileSize;
249
+ const available = stat.size - startOffset;
250
+ // Cap the per-tick read (streaming path). The final-flush path passes
251
+ // uncapped=true to drain the whole tail in one shot.
252
+ let readLen = uncapped ? available : Math.min(available, OutputForwarder.MAX_TICK_READ_BYTES);
240
253
  fd = openSync(this.transcriptPath, "r");
241
- const buf = Buffer.alloc(stat.size - startOffset);
242
- readSync(fd, buf, 0, buf.length, startOffset);
254
+ let buf = Buffer.alloc(readLen);
255
+ readSync(fd, buf, 0, readLen, startOffset);
243
256
  // Only advance readFileSize to the last newline boundary. A trailing
244
257
  // partial line (writer still appending) must be re-read next tick;
245
258
  // otherwise the parse-failed slice is silently consumed forever.
246
- const lastNl = buf.lastIndexOf(0x0a);
259
+ let lastNl = buf.lastIndexOf(0x0a);
260
+ // The capped window split a single JSONL line larger than the cap (no
261
+ // newline inside it). Fall back to reading the full available range this
262
+ // tick so the line boundary is found — matches the pre-cap behavior and
263
+ // avoids getting stuck forever on an oversize line.
264
+ if (lastNl < 0 && readLen < available) {
265
+ readLen = available;
266
+ buf = Buffer.alloc(readLen);
267
+ readSync(fd, buf, 0, readLen, startOffset);
268
+ lastNl = buf.lastIndexOf(0x0a);
269
+ }
247
270
  const consumed = lastNl >= 0 ? lastNl + 1 : 0;
248
271
  const nextFileSize = startOffset + consumed;
249
272
  this.readFileSize = nextFileSize;
273
+ // Capped read left more complete bytes on disk: signal the poll callback
274
+ // to re-tick so a burst drains without waiting on another fs event.
275
+ this._pendingReadMore = readLen < available && nextFileSize > startOffset;
250
276
  const text = consumed > 0 ? buf.slice(0, consumed).toString("utf8") : "";
251
277
  return {
252
278
  lines: text ? text.split("\n").filter((l) => l.trim()) : [],
@@ -264,8 +290,8 @@ class OutputForwarder {
264
290
  lastToolName = "";
265
291
  lastToolFilePath = "";
266
292
  /** Extract new assistant text + tool logs from transcript since readFileSize */
267
- extractNewText() {
268
- const { lines: newLines, nextFileSize } = this.readNewLines();
293
+ extractNewText(uncapped = false) {
294
+ const { lines: newLines, nextFileSize } = this.readNewLines(uncapped);
269
295
  let newText = "";
270
296
  for (const l of newLines) {
271
297
  try {
@@ -635,7 +661,9 @@ ${_bt.trim()}` : _bt.trim();
635
661
  await this.cb.removeReaction(channelId, this.userMessageId, this.emoji);
636
662
  } catch {} // best-effort: remove reaction is non-critical
637
663
  }
638
- const { text: newText, nextFileSize } = this.extractNewText();
664
+ // Final flush drains the whole remaining tail (uncapped) so a turn's
665
+ // last write can't be split across ticks with nothing left to re-tick it.
666
+ const { text: newText, nextFileSize } = this.extractNewText(true);
639
667
  if (newText) {
640
668
  const finalItem = { type: "text", text: newText, nextFileSize, bufferText: newText, channelId };
641
669
  try {
@@ -836,6 +864,9 @@ ${_bt.trim()}` : _bt.trim();
836
864
  if (hadText) {
837
865
  this.resetIdleTimer();
838
866
  }
867
+ // A capped tick left more complete bytes on disk: re-arm a flush so the
868
+ // burst drains even if the writer has gone quiet (no further fs event).
869
+ if (this._pendingReadMore) this.scheduleWatchFlush();
839
870
  }).catch(err => process.stderr.write(`[output-forwarder] forwardNewText rejected: ${err?.message || err}\n`));
840
871
  }, 100);
841
872
  }
@@ -33,9 +33,15 @@ function createOwnerHeartbeat({
33
33
  function getBridgeOwnershipSnapshot() {
34
34
  return currentOwnerState();
35
35
  }
36
- function claimBridgeOwnership(reason) {
37
- refreshActiveInstance(getInstanceId());
38
- logOwnership(`claimed owner (${reason})`);
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;
39
45
  }
40
46
  function startOwnerHeartbeat() {
41
47
  if (ownerHeartbeatTimer) return;
@@ -49,7 +55,10 @@ function createOwnerHeartbeat({
49
55
  // onlyIfOwned: re-checks ownership INSIDE the file lock so a newer
50
56
  // owner claiming the seat between currentOwnerState() and the locked
51
57
  // write is never overwritten (see refreshActiveInstance CAS guard).
52
- if (currentOwnerState().owned) refreshActiveInstance(getInstanceId(), undefined, { onlyIfOwned: true });
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 });
53
62
  } catch (e) {
54
63
  process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
55
64
  }
@@ -211,6 +211,34 @@ function writeActiveInstance(state) {
211
211
  ensureRuntimeDirs();
212
212
  writeJsonFile(ACTIVE_INSTANCE_FILE, state);
213
213
  }
214
+ // Non-blocking ownership probe for the periodic refresh/heartbeat tick. Reads
215
+ // active-instance WITHOUT taking the lock (never blocks). Distinguishes:
216
+ // 'absent' — file does not exist → seat is claimable
217
+ // 'stale' — parseable but owner PID is dead → seat is claimable
218
+ // 'live' — parseable, owner PID alive → seat is held (state.instanceId)
219
+ // 'unknown' — file present but unreadable/partial (concurrent atomic rename)
220
+ // → INDETERMINATE; callers must treat as busy and NEVER claim.
221
+ // This is the read-side guard for "locked/unreadable = busy/unknown owner,
222
+ // never claimable/no-owner": a torn read during another writer's rename must
223
+ // not be mistaken for an empty seat.
224
+ function probeActiveOwner() {
225
+ try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
226
+ let raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
227
+ if (!raw) {
228
+ // Transient partial content during an atomic rename — retry once.
229
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
230
+ raw = readJsonFile(ACTIVE_INSTANCE_FILE, null);
231
+ if (!raw) {
232
+ // Re-check existence to disambiguate a completed delete (absent) from a
233
+ // still-unreadable file (unknown/busy).
234
+ try { statSync(ACTIVE_INSTANCE_FILE); } catch { return { status: 'absent', state: null }; }
235
+ return { status: 'unknown', state: null };
236
+ }
237
+ }
238
+ const staleReason = activeInstanceStaleReason(raw);
239
+ if (staleReason) return { status: 'stale', state: raw, staleReason };
240
+ return { status: 'live', state: raw };
241
+ }
214
242
  function buildActiveInstanceState(instanceId, meta) {
215
243
  const gatewayMeta = Object.fromEntries(
216
244
  Object.entries(meta || {}).filter(([k]) => k.startsWith('gateway_'))
@@ -233,6 +261,11 @@ function buildActiveInstanceState(instanceId, meta) {
233
261
  }
234
262
  function refreshActiveInstance(instanceId, meta, options) {
235
263
  ensureRuntimeDirs();
264
+ // Periodic refresh/heartbeat callers pass options.timeoutMs:0 (try-once) so
265
+ // the ownership tick never blocks on the active-instance lock — contention
266
+ // throws ELOCKCONTENDED, which the caller treats as "busy, skip this tick".
267
+ const writeOpts = { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' };
268
+ if (options && Number.isFinite(options.timeoutMs)) writeOpts.timeoutMs = options.timeoutMs;
236
269
  return updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
237
270
  const prevForPreserve = curRaw;
238
271
  const prev = activeInstanceStaleReason(curRaw) ? null : curRaw;
@@ -374,7 +407,7 @@ function refreshActiveInstance(instanceId, meta, options) {
374
407
  next.ui_heartbeat_at = Date.now();
375
408
  }
376
409
  return { ...preservedExtra, ...next };
377
- }, { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' });
410
+ }, writeOpts);
378
411
  }
379
412
  const SERVER_PID_FILE = join(
380
413
  RUNTIME_ROOT,
@@ -525,6 +558,7 @@ export {
525
558
  notePreviousServerIfAny,
526
559
  makeInstanceId,
527
560
  readActiveInstance,
561
+ probeActiveOwner,
528
562
  refreshActiveInstance,
529
563
  releaseOwnedChannelLocks,
530
564
  touchUiHeartbeat,