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
@@ -38,6 +38,24 @@ function parsePositivePid(value) {
38
38
  function getTerminalLeadPid() {
39
39
  return parsePositivePid(process.env.MIXDOG_SUPERVISOR_PID) ?? process.pid;
40
40
  }
41
+ // Daemon pointer-owner override. The machine-global daemon is spawned once, so
42
+ // its intrinsic terminalLeadPid pins to the FIRST spawner. When the routing
43
+ // pointer moves (a TUI claims the bridge / rebinds its transcript) the daemon
44
+ // calls setOwnerContext({leadPid,cwd}) so active-instance.json owner fields
45
+ // track the CURRENT bound TUI — statusline/webhook/memory resolve the pointer
46
+ // TUI (and its session-cwd), not the spawner.
47
+ let _ownerContextOverride = null;
48
+ function setOwnerContext(ctx) {
49
+ const leadPid = parsePositivePid(ctx?.leadPid);
50
+ const next = leadPid ? { leadPid, cwd: ctx?.cwd ?? null } : null;
51
+ const changed = JSON.stringify(next) !== JSON.stringify(_ownerContextOverride);
52
+ _ownerContextOverride = next;
53
+ // Synchronously re-advertise so external readers (statusline/webhook/memory)
54
+ // see the new owner/cwd immediately, not on the next incidental refresh.
55
+ if (changed) {
56
+ try { refreshActiveInstance(makeInstanceId(), {}); } catch {}
57
+ }
58
+ }
41
59
  function getServerPid() {
42
60
  return parsePositivePid(process.env.MIXDOG_SERVER_PID) ?? (process.env.MIXDOG_WORKER_MODE === "1" ? null : process.pid);
43
61
  }
@@ -57,7 +75,6 @@ function isPidAlive(pid) {
57
75
  return e?.code === "EPERM";
58
76
  }
59
77
  }
60
- const UI_HEARTBEAT_STALE_MS = 5 * 60 * 1e3;
61
78
  function activeInstanceStaleReason(state) {
62
79
  const ownerPid = getActiveOwnerPid(state);
63
80
  if (!isPidAlive(ownerPid)) return `owner PID ${ownerPid ?? "unknown"} is dead`;
@@ -67,39 +84,16 @@ function activeInstanceStaleReason(state) {
67
84
  if (workerPid && !isPidAlive(workerPid)) return `worker PID ${workerPid} is dead`;
68
85
  const serverPid = parsePositivePid(state?.server_pid);
69
86
  if (serverPid && !isPidAlive(serverPid)) return `server PID ${serverPid} is dead`;
70
- // Zombie-Lead repro (2026-07-02): a Lead's owner/channels/worker/server
71
- // PIDs can all still be alive (process not killed) while the TUI's render
72
- // loop is dead in the water no signal ever fires, so pid-only staleness
73
- // never trips. If the TUI is heartbeating (field present), treat a stale
74
- // heartbeat as stale ownership too. Backward-compat: state written by an
75
- // older/non-TUI process (or before the first heartbeat tick) simply omits
76
- // ui_heartbeat_at, so this branch is a no-op and pid-only judgment stands.
77
- const uiHeartbeatAt = Number(state?.ui_heartbeat_at);
78
- if (Number.isFinite(uiHeartbeatAt) && uiHeartbeatAt > 0) {
79
- const age = Date.now() - uiHeartbeatAt;
80
- if (age > UI_HEARTBEAT_STALE_MS) {
81
- return `ui heartbeat stale (${Math.round(age / 1000)}s since last tick)`;
82
- }
83
- }
87
+ // NOTE: ownership is no longer decided here — the machine-global channels
88
+ // daemon (singleton-owner lock in src/standalone) is the authority. This
89
+ // PID-death check only marks a metadata advert whose owning process is gone so
90
+ // refreshActiveInstance can avoid preserving its owner fields. The former
91
+ // ui_heartbeat_at staleness branch was removed: its false-stale eviction was
92
+ // the Discord-flapping root cause.
84
93
  return null;
85
94
  }
86
- // Called from src/tui on a 30s timer while the render loop is alive. Only
87
- // touches ui_heartbeat_at (and updatedAt) so it never races/clobbers the
88
- // channels worker's own refreshActiveInstance() writes.
89
- function touchUiHeartbeat(instanceId) {
90
- ensureRuntimeDirs();
91
- try {
92
- updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
93
- if (!curRaw) return undefined;
94
- if (instanceId && curRaw.instanceId !== instanceId) return undefined;
95
- return { ...curRaw, ui_heartbeat_at: Date.now(), updatedAt: Date.now() };
96
- }, { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate', timeoutMs: 0 });
97
- } catch { /* best-effort try-once (timeoutMs:0): on lock contention we skip
98
- this tick without ever blocking the render loop on Atomics.wait;
99
- the next 30s tick catches up. */ }
100
- }
101
95
  function buildRuntimeIdentity() {
102
- const terminalLeadPid = getTerminalLeadPid();
96
+ const terminalLeadPid = _ownerContextOverride?.leadPid ?? getTerminalLeadPid();
103
97
  const serverPid = getServerPid();
104
98
  return {
105
99
  ownerLeadPid: terminalLeadPid,
@@ -108,6 +102,7 @@ function buildRuntimeIdentity() {
108
102
  server_pid: serverPid,
109
103
  worker_pid: process.pid,
110
104
  ...process.env.MIXDOG_WORKER_MODE === "1" ? { channels_pid: process.pid } : {},
105
+ ...(_ownerContextOverride?.cwd ? { sessionCwd: _ownerContextOverride.cwd } : {}),
111
106
  };
112
107
  }
113
108
  function getTurnEndPath(instanceId) {
@@ -140,71 +135,11 @@ function readActiveInstance() {
140
135
  state = readJsonFile(ACTIVE_INSTANCE_FILE, null);
141
136
  if (!state) return null;
142
137
  }
143
- const staleReason = activeInstanceStaleReason(state);
144
- if (staleReason) {
145
- // Owner is dead but the file may carry process-independent runtime
146
- // state (memory_port, pg_*) that next callers want to preserve. Wiping
147
- // the whole file forced the next refresh to rebuild from {} and drop
148
- // memory_port, which broke the scheduler and MCP memory lookup
149
- // until the memory worker re-advertised. Instead, clear only the
150
- // owner-identity fields and keep the rest as a stale-but-useful prev
151
- // for the next refresh. refreshActiveInstance still treats the
152
- // returned state as "no live owner" via getActiveOwnerPid downstream.
153
- const {
154
- pinned: _stalePinned,
155
- instanceId: _staleId,
156
- ownerLeadPid: _staleOwner,
157
- terminalLeadPid: _staleTerm,
158
- supervisor_pid: _staleSup,
159
- server_pid: _staleServer,
160
- worker_pid: _staleWorker,
161
- channels_pid: _staleChannels,
162
- supervisor_started_at: _staleStart,
163
- server_started_at: _staleServerStart,
164
- httpPort: _staleHttpPort,
165
- backendReady: _staleBackendReady,
166
- turnEndFile: _staleTurnEnd,
167
- statusFile: _staleStatus,
168
- } = state ?? {};
169
- const ownerFieldsAlreadyEmpty = _staleId === undefined
170
- && _staleOwner === undefined
171
- && _staleTerm === undefined
172
- && _staleSup === undefined;
173
- if (!ownerFieldsAlreadyEmpty) {
174
- process.stderr.write(`mixdog: stale active-instance.json (${staleReason}), clearing owner fields\n`);
175
- try {
176
- updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
177
- if (!curRaw) return undefined;
178
- const liveReason = activeInstanceStaleReason(curRaw);
179
- if (!liveReason) return undefined;
180
- const {
181
- pinned: _stalePinned2,
182
- instanceId: _staleId2,
183
- ownerLeadPid: _staleOwner2,
184
- terminalLeadPid: _staleTerm2,
185
- supervisor_pid: _staleSup2,
186
- server_pid: _staleServer2,
187
- worker_pid: _staleWorker2,
188
- channels_pid: _staleChannels2,
189
- supervisor_started_at: _staleStart2,
190
- server_started_at: _staleServerStart2,
191
- httpPort: _staleHttpPort2,
192
- backendReady: _staleBackendReady2,
193
- turnEndFile: _staleTurnEnd2,
194
- statusFile: _staleStatus2,
195
- ...stableRestLocked
196
- } = curRaw ?? {};
197
- const ownerEmpty = _staleId2 === undefined
198
- && _staleOwner2 === undefined
199
- && _staleTerm2 === undefined
200
- && _staleSup2 === undefined;
201
- if (ownerEmpty) return undefined;
202
- return { ...stableRestLocked, updatedAt: Date.now() };
203
- }, { compact: true, fsync: false, fsyncDir: false, renameFallback: 'truncate' });
204
- } catch {}
205
- }
206
- return null;
207
- }
138
+ // Pure metadata-advert read: ownership is the OS seat lock now, so this no
139
+ // longer evicts a "stale" owner (the false-stale ui_heartbeat eviction was
140
+ // the Discord-flapping root cause). A crashed holder auto-releases the seat;
141
+ // its leftover advert is harmless and refreshActiveInstance's own
142
+ // preservation logic decides which fields to carry forward.
208
143
  return state;
209
144
  }
210
145
  function writeActiveInstance(state) {
@@ -401,22 +336,6 @@ function refreshActiveInstance(instanceId, meta, options) {
401
336
  }
402
337
  }
403
338
  }
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.
415
- const workerOwnsSeat = process.env.MIXDOG_WORKER_MODE === "1"
416
- && identity.ownerLeadPid === process.pid;
417
- if (workerOwnsSeat) {
418
- delete next.ui_heartbeat_at;
419
- }
420
339
  return { ...preservedExtra, ...next };
421
340
  }, writeOpts);
422
341
  }
@@ -572,6 +491,6 @@ export {
572
491
  probeActiveOwner,
573
492
  refreshActiveInstance,
574
493
  releaseOwnedChannelLocks,
575
- touchUiHeartbeat,
494
+ setOwnerContext,
576
495
  writeServerPid
577
496
  };
@@ -44,10 +44,11 @@ function getParentPid(pid) {
44
44
  // which fires per transcript watchDebounce tick, so without these flags users
45
45
  // see a powershell.exe console window pop in/out repeatedly during
46
46
  // any chat activity — including while the config UI is loading.
47
+ // NO `-WindowStyle Hidden` CLI switch: windowsHide already covers it,
48
+ // and the token triggers Defender's PowhidSubExec false positive.
47
49
  const out2 = execFileSync("powershell.exe", [
48
50
  "-NoProfile",
49
51
  "-NonInteractive",
50
- "-WindowStyle", "Hidden",
51
52
  "-Command",
52
53
  `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").ParentProcessId`
53
54
  ], {
@@ -23,10 +23,10 @@ function createToolDispatch({
23
23
  setChannelBridgeActive,
24
24
  writeBridgeState,
25
25
  stopServerTyping,
26
- claimBridgeOwnership,
27
26
  notifyRemoteAcquired,
28
27
  refreshBridgeOwnership,
29
28
  bindPersistedTranscriptIfAny,
29
+ rebindCurrentTranscript,
30
30
  stopOwnedRuntime,
31
31
  reloadRuntimeConfig,
32
32
  } = lifecycle;
@@ -50,19 +50,13 @@ function createToolDispatch({
50
50
  setChannelBridgeActive(active);
51
51
  writeBridgeState(active);
52
52
  if (active) {
53
- // Claim the seat only when we are NOT already the owner — a
54
- // genuine /remote takeover or re-occupy after being superseded.
55
- // When this session already owns the seat (e.g. the boot-time
56
- // activate that fires right after start() claimed after READY),
57
- // skip the redundant claim so boot performs ONE claim, not a
58
- // remote-start + re-activate double claim (the latter forced a
59
- // double reconnect). refreshBridgeOwnership below still re-pins
60
- // the forwarder binding onto THIS session either way.
53
+ // Daemon model: this runtime is the unconditional bridge owner
54
+ // (getOwned() is always true), so activate never needs to claim a
55
+ // seat or pre-notify the not-connected -> connected transition
56
+ // inside startOwnedRuntime fires notifyRemoteAcquired exactly once.
57
+ // refreshBridgeOwnership still re-pins the forwarder binding onto
58
+ // THIS session.
61
59
  if (getOwned?.() !== true) {
62
- claimBridgeOwnership(wasActive ? "re-activate takeover" : "bridge activated");
63
- // Genuine acquire transition (we were NOT the owner) — tell the
64
- // parent to flip remote ON. Not fired when we already own the
65
- // seat, so an idempotent re-activate never re-notifies.
66
60
  notifyRemoteAcquired?.();
67
61
  }
68
62
  try {
@@ -104,6 +98,16 @@ function createToolDispatch({
104
98
  result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
105
99
  break;
106
100
  }
101
+ case "rebind_current_transcript": {
102
+ // Lead-pushed repoint to the transcript it just created/rebound.
103
+ // Best-effort + idempotent: absent channelId or path => no-op; a
104
+ // bind failure is swallowed by the outer try so lead paths never
105
+ // throw. Same binding path as the inbound steal.
106
+ const transcriptPath = typeof args.transcriptPath === "string" ? args.transcriptPath.trim() : "";
107
+ if (transcriptPath) await rebindCurrentTranscript(transcriptPath);
108
+ result = { content: [{ type: "text", text: `transcript rebind ${transcriptPath ? "pushed" : "skipped (no path)"}` }] };
109
+ break;
110
+ }
107
111
  // memory — handled by memory-service.mjs MCP
108
112
  default:
109
113
  result = {
@@ -132,7 +136,10 @@ function createToolDispatch({
132
136
  // Debounce: only forward when ≥250 ms have elapsed since the last forward,
133
137
  // to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
134
138
  const now = Date.now();
135
- if (now - _lastForwardMs >= 250) {
139
+ // The transcript rebind op must repoint the forwarder BEFORE any flush;
140
+ // running the pre-flush first would send stale-transcript text ahead of the
141
+ // rebind. Skip the pre-flush for it so rebinding always precedes forwarding.
142
+ if (toolName !== 'rebind_current_transcript' && now - _lastForwardMs >= 250) {
136
143
  _lastForwardMs = now;
137
144
  await forwarder.forwardNewText();
138
145
  }
@@ -140,6 +147,7 @@ function createToolDispatch({
140
147
  // Remote-owner startup: ensure this owner's backend is connected.
141
148
  for (let i = 0; i < 2 && !getBridgeRuntimeConnected(); i++) {
142
149
  try {
150
+ // Auto-connect this owner's backend (daemon singleton — no seat claim).
143
151
  await refreshBridgeOwnership();
144
152
  } catch {
145
153
  }
@@ -5,6 +5,7 @@ import {
5
5
  isExplorerSurface,
6
6
  isMemorySurface,
7
7
  normalizeToolName,
8
+ stripToolPrefix,
8
9
  } from "../../shared/tool-surface.mjs";
9
10
 
10
11
  // Texts that should never be forwarded to Discord (Claude's internal status lines)
@@ -23,7 +24,11 @@ const HIDDEN_TOOLS = /* @__PURE__ */ new Set([
23
24
  "TaskCreate",
24
25
  "TaskUpdate",
25
26
  "TaskList",
26
- "TaskGet"
27
+ "TaskGet",
28
+ // Channel-plumbing tools — internal bridge/transcript wiring; never
29
+ // meaningful to channel readers ("Rebind Current Transcript", etc).
30
+ "activate_channel_bridge",
31
+ "rebind_current_transcript"
27
32
  ]);
28
33
 
29
34
  /** Check if a tool name is recall_memory */
@@ -40,7 +45,7 @@ function isMemoryFile(filePath) {
40
45
  }
41
46
  /** Check if a tool should be hidden */
42
47
  function isHidden(name) {
43
- if (HIDDEN_TOOLS.has(name)) return true;
48
+ if (HIDDEN_TOOLS.has(name) || HIDDEN_TOOLS.has(stripToolPrefix(name))) return true;
44
49
  if (formatToolSurface(name, {}).label === "Memory") return false;
45
50
  if (name === "reply" || name === "fetch") return true;
46
51
  return false;
@@ -324,14 +324,7 @@ const {
324
324
  logOwnership,
325
325
  currentOwnerState,
326
326
  getBridgeOwnershipSnapshot,
327
- claimBridgeOwnership,
328
- startOwnerHeartbeat,
329
- stopOwnerHeartbeat,
330
- } = createOwnerHeartbeat({
331
- getInstanceId: () => INSTANCE_ID,
332
- readActiveInstance,
333
- refreshActiveInstance,
334
- });
327
+ } = createOwnerHeartbeat();
335
328
  // ── Owned-runtime lifecycle ─────────────────────────────────────────────────
336
329
  // Extracted -> lib/owned-runtime.mjs. Owns its own start/stop/refresh in-flight
337
330
  // flags + ownership timer + memory-drain timer; shares config/backend/
@@ -360,13 +353,11 @@ const {
360
353
  instanceId: INSTANCE_ID,
361
354
  TERMINAL_LEAD_PID,
362
355
  forwarder,
356
+ sendNotifyToParent,
363
357
  scheduler,
364
358
  statusState,
365
359
  logOwnership,
366
360
  currentOwnerState,
367
- claimBridgeOwnership,
368
- startOwnerHeartbeat,
369
- stopOwnerHeartbeat,
370
361
  bindPersistedTranscriptIfAny,
371
362
  cancelPendingTranscriptRearm,
372
363
  schedulePendingTranscriptRearm,
@@ -634,10 +625,38 @@ const {
634
625
  setChannelBridgeActive: (v) => { channelBridgeActive = v; },
635
626
  writeBridgeState,
636
627
  stopServerTyping,
637
- claimBridgeOwnership,
638
628
  notifyRemoteAcquired,
639
629
  refreshBridgeOwnership,
640
630
  bindPersistedTranscriptIfAny,
631
+ // Lead-pushed repoint: bind the exact transcript the lead just created
632
+ // (auto-acquire / newSession / resume / clear) instead of waiting for the
633
+ // next inbound parent-chain steal. Idempotent + best-effort: same binding
634
+ // path as the inbound steal (rebindTranscriptContext -> applyTranscriptBinding).
635
+ rebindCurrentTranscript: async (transcriptPath) => {
636
+ const cleanPath = typeof transcriptPath === "string" ? transcriptPath.trim() : "";
637
+ if (!cleanPath) return;
638
+ const channelId = statusState.read().channelId || config.channelId;
639
+ if (!channelId) return;
640
+ // Fail-closed: a malformed / not-yet-on-disk path must NEVER fall through
641
+ // to rebindTranscriptContext's discovery loop (which would mutate the
642
+ // binding onto a different session). Only an existing regular file binds;
643
+ // otherwise log + return so the current binding is left untouched.
644
+ let exists = false;
645
+ try { exists = fs.statSync(cleanPath).isFile(); } catch { exists = false; }
646
+ if (!exists) {
647
+ process.stderr.write(`mixdog: rebind_current_transcript: ignoring non-existent path ${cleanPath}\n`);
648
+ return;
649
+ }
650
+ // Idempotent: already bound to this exact transcript => no-op. In
651
+ // particular do NOT re-run recoverUnsyncedTail, which is only meaningful
652
+ // when the binding actually changes.
653
+ if (forwarder.hasBinding() && sameResolvedPath(forwarder.transcriptPath, cleanPath)) return;
654
+ // Binding changed: same path as the inbound steal's applyTranscriptBinding.
655
+ applyTranscriptBinding(channelId, cleanPath, {
656
+ persistStatus: true,
657
+ recoverUnsyncedTail: true,
658
+ });
659
+ },
641
660
  stopOwnedRuntime,
642
661
  reloadRuntimeConfig,
643
662
  },
@@ -679,21 +698,13 @@ async function init(_sharedMcp) {
679
698
  async function start() {
680
699
  channelBridgeActive = true;
681
700
  writeBridgeState(true);
682
- // Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
683
- // seat up front: connect our own gateway first and claim only after it reports
684
- // READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
685
- // owner keeps serving, so a boot causes no send/receive gap. This is also the
686
- // SINGLE boot claim — the old "remote start" pre-claim + "re-activate
687
- // takeover" second claim (double reconnect) is gone.
701
+ // Daemon model: this runtime is the machine-global singleton bridge owner
702
+ // (enforced by the standalone daemon's singleton-owner lock), so there is no
703
+ // seat to claim and no contender to make-before-break against. Just connect
704
+ // the owned runtime and bind the persisted transcript.
688
705
  const _bindingReadyStart = Date.now();
689
706
  try {
690
- // Boot claim intent (published by the parent via env before this fork):
691
- // explicit (/remote, --remote) -> last-wins force-takeover.
692
- // auto (config/delayed autoStart) -> claim-if-vacant: take the seat only
693
- // when no live owner holds it, otherwise back off silently (no claim, no
694
- // acquire notify) so a live owner is never stolen.
695
- const autoStartClaim = process.env.MIXDOG_REMOTE_INTENT === 'auto';
696
- await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true, claimIfVacant: autoStartClaim });
707
+ await startOwnedRuntime({ restoreBinding: true });
697
708
  bindingReadyStatus = "resolved";
698
709
  dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
699
710
  _bindingReadyResolve(true);
@@ -702,10 +713,8 @@ async function start() {
702
713
  dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
703
714
  _bindingReadyResolve(e);
704
715
  }
705
- // Ownership timer: keep checking whether a newer remote session has taken
706
- // over (last-wins) so a superseded owner disconnects promptly. Normally
707
- // already armed by startOwnedRuntime before connect(); this is the fallback
708
- // for a start path that aborted pre-connect (idempotent).
716
+ // No-op under the daemon model (kept for call-site stability): there is no
717
+ // ownership timer the singleton daemon guarantees exactly one owner.
709
718
  armBridgeOwnershipTimer();
710
719
  // Hot-reload config on file change (schedules/webhooks/events).
711
720
  if (!_configWatcher) {
@@ -745,7 +754,10 @@ async function stop() {
745
754
  }
746
755
  }
747
756
  // ── IPC worker mode ──────────────────────────────────────────────
748
- if (_isWorkerMode && process.send) {
757
+ // Skipped under the machine-global daemon (MIXDOG_CHANNEL_DAEMON=1): the
758
+ // daemon entry (src/standalone/channel-daemon.mjs) drives start()/stop() and
759
+ // its own HTTP+SSE transport instead of the parent node-IPC call/notify loop.
760
+ if (_isWorkerMode && process.send && process.env.MIXDOG_CHANNEL_DAEMON !== '1') {
749
761
  runWorkerIpc({
750
762
  start,
751
763
  stop,
@@ -134,6 +134,10 @@ const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
134
134
 
135
135
  let _periodicAdvertiseInstalled = false
136
136
  let _periodicAdvertiseTimer = null
137
+ // Single module-level advertise retry chain. A newer advertiseMemoryPort call
138
+ // cancels the older chain so a delayed retry never replays a stale boundPort.
139
+ let _advertiseRetryTimer = null
140
+ let _advertiseGeneration = 0
137
141
  // Track the most recently advertised port so the periodic tick re-reads it
138
142
  // every interval. Without this the setInterval closure binds the FIRST port
139
143
  // (the upstream we proxied to) and keeps re-advertising the dead upstream
@@ -248,7 +252,14 @@ function deregisterClient(clientPid) {
248
252
 
249
253
  function advertiseMemoryPort(boundPort, attempt = 0) {
250
254
  if (!Number.isFinite(boundPort) || boundPort <= 0) return
251
- _currentAdvertisedPort = boundPort
255
+ // A fresh top-level advertise (attempt 0) supersedes any pending retry chain:
256
+ // last write wins, so a delayed retry never clobbers a newer boundPort.
257
+ if (attempt === 0) {
258
+ _currentAdvertisedPort = boundPort
259
+ _advertiseGeneration++
260
+ if (_advertiseRetryTimer) { try { clearTimeout(_advertiseRetryTimer) } catch {} ; _advertiseRetryTimer = null }
261
+ }
262
+ const generation = _advertiseGeneration
252
263
  if (!_periodicAdvertiseInstalled) {
253
264
  _periodicAdvertiseInstalled = true
254
265
  _periodicAdvertiseTimer = setInterval(() => {
@@ -284,12 +295,24 @@ function advertiseMemoryPort(boundPort, attempt = 0) {
284
295
  updatedAt: Date.now(),
285
296
  }
286
297
  return next
287
- }, { compact: true, fsyncDir: true, renameFallback: 'truncate' })
298
+ }, { compact: true, fsyncDir: true, renameFallback: 'truncate', timeoutMs: 1000 })
299
+ if (generation === _advertiseGeneration) _advertiseRetryTimer = null
288
300
  } catch (e) {
289
- const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
290
- if (transient && attempt < 3) {
291
- const retryTimer = setTimeout(() => advertiseMemoryPort(boundPort, attempt + 1), 50 * (attempt + 1))
292
- retryTimer.unref?.()
301
+ // Boot path must not serially block on the default 8s lock wait: use a short
302
+ // lock timeout and treat lock contention/timeout as transient so pg_port /
303
+ // memory_port still eventually publish via unref'd, backed-off bg retries.
304
+ const transient =
305
+ e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES' ||
306
+ e?.code === 'ELOCKTIMEOUT' || e?.code === 'ELOCKCONTENDED'
307
+ if (transient && attempt < 5 && generation === _advertiseGeneration) {
308
+ const delay = Math.min(2000, 50 * 2 ** attempt)
309
+ // Fire-time generation re-check: even if clearTimeout was missed, a
310
+ // retry from a superseded chain must never republish an old boundPort.
311
+ _advertiseRetryTimer = setTimeout(() => {
312
+ if (generation !== _advertiseGeneration) return
313
+ advertiseMemoryPort(boundPort, attempt + 1)
314
+ }, delay)
315
+ _advertiseRetryTimer.unref?.()
293
316
  return
294
317
  }
295
318
  __mixdogMemoryLog(`[memory-service] active-instance memory_port advertise failed: ${e?.message || e}\n`)
@@ -454,10 +477,23 @@ async function _initStore() {
454
477
  if (dimsResolved) {
455
478
  primeEmbeddingDims(dimsResolved)
456
479
  assertSecondaryPgAttachable()
480
+ // Start embedding + kiwi warmup NOW so they run on their worker threads
481
+ // during the PG ensure I/O-wait window below: openDatabase spawns/awaits
482
+ // Postgres (I/O-bound, not CPU), so overlapping warmup there is safe and
483
+ // lands a cold boot's first recall dense instead of lexical-fallback.
484
+ // Both are fire-and-forget; the boot-complete fireDeferred/initKoMorph edge
485
+ // stays an idempotent no-op fallback. schedule() respects canStart
486
+ // (secondary/env-disabled skip → fireDeferred no-op); kiwi skips secondary.
487
+ _embeddingWarmup.schedule(EMBEDDING_META_PATH, metaKey)
488
+ memoryProfile('embedding:warmup:fire')
489
+ _embeddingWarmup.fireDeferred()
490
+ if (!memorySecondaryMode()) {
491
+ memoryProfile('ko-morph:warmup:fire')
492
+ initKoMorph(DATA_DIR, __mixdogMemoryLog).catch(() => {})
493
+ }
457
494
  const openStartedAt = performance.now()
458
495
  db = await openDatabase(DATA_DIR, dimsResolved)
459
496
  memoryProfile('open-db:done', { ms: (performance.now() - openStartedAt).toFixed(1), dims: dimsResolved })
460
- _embeddingWarmup.schedule(EMBEDDING_META_PATH, metaKey)
461
497
  } else {
462
498
  if (!embeddingWarmupCanStart()) {
463
499
  throw new Error('memory-service: embedding dims unavailable while warmup is disabled')
@@ -469,6 +505,12 @@ async function _initStore() {
469
505
  memoryProfile('embedding:cold-warmup:done', { ms: (performance.now() - warmupStartedAt).toFixed(1) })
470
506
  dimsResolved = Number(getEmbeddingDims())
471
507
  assertSecondaryPgAttachable()
508
+ // Embedding is already warm (awaited above); still fire kiwi during the PG
509
+ // ensure I/O-wait so the first recall's FTS path is morph-aware too.
510
+ if (!memorySecondaryMode()) {
511
+ memoryProfile('ko-morph:warmup:fire')
512
+ initKoMorph(DATA_DIR, __mixdogMemoryLog).catch(() => {})
513
+ }
472
514
  const openStartedAt = performance.now()
473
515
  db = await openDatabase(DATA_DIR, dimsResolved)
474
516
  memoryProfile('open-db:done', { ms: (performance.now() - openStartedAt).toFixed(1), dims: dimsResolved })
@@ -798,6 +840,11 @@ export async function stop() {
798
840
  try { clearInterval(_periodicAdvertiseTimer) } catch {}
799
841
  _periodicAdvertiseTimer = null
800
842
  }
843
+ if (_advertiseRetryTimer) {
844
+ try { clearTimeout(_advertiseRetryTimer) } catch {}
845
+ _advertiseRetryTimer = null
846
+ }
847
+ _advertiseGeneration++
801
848
  _periodicAdvertiseInstalled = false
802
849
  _currentAdvertisedPort = null
803
850
  _embeddingWarmup.reset()
@@ -16,11 +16,16 @@ export function createEmbeddingWarmup({
16
16
  log = () => {},
17
17
  }) {
18
18
  let _pending = null
19
+ // Fired latch: an init() retry after the early (PG-wait-edge) fire must not
20
+ // re-schedule and run the warmup twice (double ONNX session create +
21
+ // double embedding-meta persist). reset() clears it for true re-inits.
22
+ let _fired = false
19
23
 
20
24
  // Queue the warmup; fireDeferred() runs it once boot completes. Re-queuing
21
25
  // overwrites any prior pending closure (matches the original single-slot
22
26
  // module var). MIXDOG_EMBED_WARMUP=0 (checked via canStart) disables it.
23
27
  function schedule(metaPath, metaKey) {
28
+ if (_fired) return
24
29
  if (!canStart()) {
25
30
  // Silent skip here previously made "warmup never ran" boots
26
31
  // indistinguishable from failed warmups. One line, boot-edge only.
@@ -50,11 +55,13 @@ export function createEmbeddingWarmup({
50
55
  const fire = _pending
51
56
  if (!fire) return
52
57
  _pending = null
58
+ _fired = true
53
59
  fire()
54
60
  }
55
61
 
56
62
  function reset() {
57
63
  _pending = null
64
+ _fired = false
58
65
  }
59
66
 
60
67
  return { schedule, fireDeferred, reset }