mixdog 0.9.23 → 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 (84) hide show
  1. package/package.json +1 -1
  2. package/scripts/boot-smoke.mjs +1 -1
  3. package/scripts/build-runtime-windows.ps1 +242 -242
  4. package/scripts/channel-daemon-smoke.mjs +327 -9
  5. package/scripts/channel-daemon-stub.mjs +12 -1
  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/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/tool-efficiency-diag.mjs +1 -1
  13. package/scripts/tool-smoke.mjs +38 -30
  14. package/src/rules/agent/30-explorer.md +6 -0
  15. package/src/rules/lead/02-channels.md +3 -3
  16. package/src/rules/shared/01-tool.md +11 -4
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
  18. package/src/runtime/agent/orchestrator/config.mjs +33 -7
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
  20. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
  21. package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
  25. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
  26. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
  27. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
  28. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
  29. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
  30. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
  31. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
  32. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
  33. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
  34. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
  35. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
  36. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
  37. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
  38. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
  39. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
  40. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
  41. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
  42. package/src/runtime/channels/lib/crash-log.mjs +4 -2
  43. package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
  44. package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
  45. package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
  46. package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
  47. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
  48. package/src/runtime/channels/lib/tool-format.mjs +7 -2
  49. package/src/runtime/channels/lib/worker-main.mjs +9 -28
  50. package/src/runtime/memory/lib/query-handlers.mjs +4 -1
  51. package/src/runtime/memory/lib/recall-format.mjs +7 -3
  52. package/src/runtime/memory/tool-defs.mjs +1 -1
  53. package/src/runtime/shared/atomic-file.mjs +130 -2
  54. package/src/runtime/shared/background-tasks.mjs +1 -1
  55. package/src/runtime/shared/config.mjs +53 -1
  56. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  57. package/src/runtime/shared/tool-surface.mjs +19 -0
  58. package/src/runtime/shared/update-checker.mjs +3 -0
  59. package/src/runtime/shared/user-data-guard.mjs +66 -0
  60. package/src/session-runtime/config-lifecycle.mjs +175 -15
  61. package/src/session-runtime/mcp-glue.mjs +30 -0
  62. package/src/session-runtime/runtime-core.mjs +91 -7
  63. package/src/session-runtime/session-turn-api.mjs +42 -16
  64. package/src/session-runtime/tool-catalog.mjs +44 -0
  65. package/src/standalone/channel-admin.mjs +32 -3
  66. package/src/standalone/channel-daemon-client.mjs +3 -1
  67. package/src/standalone/channel-daemon-transport.mjs +202 -8
  68. package/src/standalone/channel-daemon.mjs +54 -17
  69. package/src/standalone/channel-worker.mjs +18 -7
  70. package/src/standalone/explore-tool.mjs +87 -15
  71. package/src/tui/App.jsx +2 -2
  72. package/src/tui/components/StatusLine.jsx +3 -3
  73. package/src/tui/components/ToolExecution.jsx +14 -2
  74. package/src/tui/components/TranscriptItem.jsx +1 -1
  75. package/src/tui/dist/index.mjs +209 -44
  76. package/src/tui/engine/agent-job-feed.mjs +5 -0
  77. package/src/tui/engine/notification-plan.mjs +5 -0
  78. package/src/tui/engine/session-api.mjs +6 -1
  79. package/src/tui/engine/tool-card-results.mjs +14 -5
  80. package/src/tui/engine/turn.mjs +9 -2
  81. package/src/tui/engine.mjs +31 -12
  82. package/src/ui/statusline-agents.mjs +36 -0
  83. package/src/ui/statusline.mjs +15 -5
  84. package/src/runtime/channels/lib/seat-lock.mjs +0 -196
@@ -31,14 +31,11 @@ export function createOwnedRuntime({
31
31
  instanceId,
32
32
  TERMINAL_LEAD_PID,
33
33
  forwarder,
34
+ sendNotifyToParent,
34
35
  scheduler,
35
36
  statusState,
36
37
  logOwnership,
37
38
  currentOwnerState,
38
- acquireSeat,
39
- closeSeatServer,
40
- isSeatHeld,
41
- onTakeover,
42
39
  bindPersistedTranscriptIfAny,
43
40
  cancelPendingTranscriptRearm,
44
41
  schedulePendingTranscriptRearm,
@@ -51,51 +48,18 @@ export function createOwnedRuntime({
51
48
  let _ownedRuntimeStopRequested = false;
52
49
  let bridgeOwnershipRefreshInFlight = null;
53
50
  let _memoryDrainTimer = null;
54
- let _seatTakeoverRegistered = false;
55
- let _vacantReacquireTimer = null;
56
- // Ownership loss is push-based now: the seat lock invokes our onTakeover
57
- // handler when a contender sends an explicit takeover message, and the OS
58
- // auto-releases the pipe/socket on crash. No fs.watch on active-instance.json
59
- // and no 3s ownership poll both retired with the file-heuristic model.
60
- function registerSeatTakeoverOnce() {
61
- if (_seatTakeoverRegistered) return;
62
- _seatTakeoverRegistered = true;
63
- onTakeover(async () => {
64
- // A newer session explicitly took the seat. Tear the owned runtime down
65
- // (the seat lock closes the listener afterwards) and drop remote mode.
66
- if (getBridgeRuntimeConnected() || bridgeRuntimeStarting) {
67
- await stopOwnedRuntime("ownership lost (seat taken over)");
68
- }
69
- notifyRemoteSuperseded();
70
- });
71
- }
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.
72
60
  function clearBridgeOwnershipTimer() {
73
- // Only the standby reacquire poll survives under the seat-lock model (the 3s
74
- // ownership poll + active-instance fs.watch are gone). Stop it on shutdown.
75
- stopVacantReacquirePoll();
76
- }
77
- function stopVacantReacquirePoll() {
78
- if (_vacantReacquireTimer) { clearInterval(_vacantReacquireTimer); _vacantReacquireTimer = null; }
79
- }
80
- // Standby reacquire: after an auto-start (claim-if-vacant) backed off from a
81
- // LIVE holder, poll modestly so a LATER holder crash is reclaimed without
82
- // explicit user action. Never steals a live holder (force:false, try-once),
83
- // and stops the instant it wins. Unref'd so it never keeps the worker alive.
84
- function armVacantReacquirePoll() {
85
- if (_vacantReacquireTimer) return;
86
- _vacantReacquireTimer = setInterval(() => {
87
- void (async () => {
88
- if (!getChannelBridgeActive() || isSeatHeld() || getBridgeRuntimeConnected() || bridgeRuntimeStarting) return;
89
- let got = false;
90
- try { got = await acquireSeat({ force: false, timeoutMs: 0 }); } catch { got = false; }
91
- if (!got) return;
92
- stopVacantReacquirePoll();
93
- registerSeatTakeoverOnce();
94
- logOwnership("standby reclaim (vacant seat acquired)");
95
- await startOwnedRuntime({ restoreBinding: true });
96
- })();
97
- }, 15e3);
98
- _vacantReacquireTimer.unref?.();
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,62 +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
- // Seat acquisition IS the ownership claim (OS-enforced singleton). Acquiring
222
- // == successfully listen()ing on the pipe/socket, so at most one process ever
223
- // holds it. This replaces the old pre-connect active-instance CAS dance:
224
- // - boot (claimAfterReady) / reload (reclaim): acquire the seat here.
225
- // claimIfVacant (auto-start) backs off silently when a LIVE holder is
226
- // present (force:false); explicit /remote + last-wins force-take it via
227
- // the seat lock's takeover message (force:true).
228
- // - refresh/owned path: only proceed if we ALREADY hold the seat (never
229
- // re-steal — the seat lock, not a file re-read, is the authority).
230
- // Wrapped so ANY throw in the pre-connect claim resets bridgeRuntimeStarting.
231
- const reclaim = options.reclaim === true;
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.
232
196
  try {
233
- if (claimAfterReady || reclaim) {
234
- const acquired = await acquireSeat({ force: !claimIfVacant });
235
- if (!acquired) {
236
- bridgeRuntimeStarting = false;
237
- if (claimIfVacant) {
238
- logOwnership("autostart backoff (live owner holds seat)");
239
- // Keep a bounded standby poll running so a later holder crash frees
240
- // the seat and this session reclaims it without explicit action.
241
- armVacantReacquirePoll();
242
- } else {
243
- logOwnership("seat acquire failed");
244
- }
245
- return;
246
- }
247
- registerSeatTakeoverOnce();
248
- stopVacantReacquirePoll();
249
- logOwnership(claimIfVacant
250
- ? "boot claim (seat acquired, claim-if-vacant)"
251
- : (reclaim ? "reclaim (seat acquired)" : "boot claim (seat acquired, last-wins)"));
252
- } else if (!isSeatHeld()) {
253
- // Refresh/owned path with no seat held: not ours to start.
254
- bridgeRuntimeStarting = false;
255
- return;
256
- }
257
- // Advertise metadata (memory_port/pg_*/channelId/... preserved inside
258
- // refreshActiveInstance). active-instance.json is a pure advert now; the
259
- // seat lock is the ownership authority, so no CAS guard is needed.
260
197
  refreshActiveInstance(instanceId, { backendReady: false });
261
- if (!isSeatHeld()) {
262
- bridgeRuntimeStarting = false;
263
- return;
264
- }
265
198
  } catch (e) {
266
- bridgeRuntimeStarting = false;
267
- 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`);
268
201
  return;
269
202
  }
270
- // Ensure the seat-lock takeover handler is registered so a superseding
271
- // session tears us down promptly (replaces the old 3s ownership poll).
272
- armBridgeOwnershipTimer();
273
203
  // Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
274
204
  // memory service publishes its port. Idempotent + reentrancy-guarded inside
275
205
  // drainBuffer(); unref'd so it never holds the worker open.
@@ -287,7 +217,6 @@ async function startOwnedRuntime(options = {}) {
287
217
  const bailIfStopRequested = async () => {
288
218
  if (!_ownedRuntimeStopRequested) return false;
289
219
  try { await startingBackend.disconnect(); } catch {}
290
- try { await closeSeatServer(); } catch {}
291
220
  try { releaseOwnedChannelLocks(instanceId); } catch {}
292
221
  try { clearActiveInstance(instanceId); } catch {}
293
222
  setBridgeRuntimeConnected(false);
@@ -312,33 +241,13 @@ async function startOwnedRuntime(options = {}) {
312
241
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
313
242
  return;
314
243
  }
315
- // Post-connect seat check. The seat was acquired BEFORE connect; a takeover
316
- // during the (multi-second) connect window would have closed our listener
317
- // via onTakeover. If we no longer hold the seat we LOST it — disconnect the
318
- // backend WE connected and drop remote (no active-instance clear; the newer
319
- // owner holds the seat).
320
- if (!isSeatHeld()) {
321
- try { await startingBackend.disconnect(); } catch {}
322
- try { await closeSeatServer(); } catch {}
323
- try { releaseOwnedChannelLocks(instanceId); } catch {}
324
- if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
325
- setBridgeRuntimeConnected(false);
326
- cancelPendingTranscriptRearm();
327
- try { forwarder.stopWatch(); } catch {}
328
- if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
329
- notifyRemoteSuperseded();
330
- return;
331
- }
332
- // Advertise backend readiness (metadata advert; seat lock is authority).
244
+ // Advertise backend readiness (metadata advert).
333
245
  try { refreshActiveInstance(instanceId, { backendReady: true }); } catch {}
334
246
  setBridgeRuntimeConnected(true);
335
- // Fresh confirmed ownership — tell the parent it holds the seat so it flips
336
- // remote ON. Reached ONLY on a not-connected -> connected transition (the
337
- // top-of-fn early-return skips already-connected re-ticks), so this fires
338
- // exactly once per acquire and covers EVERY win path, not just boot:
339
- // - explicit/auto boot claim (claimAfterReady),
340
- // - the deferred claim when a bridge timer's refreshBridgeOwnership()
341
- // 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).
342
251
  // The parent's acquired handler is idempotent (no-op when already remote),
343
252
  // so notifying post-connect — never pre-connect — means a connect FAILURE
344
253
  // below leaves the parent non-remote instead of stuck remote-with-no-bridge
@@ -384,16 +293,15 @@ async function startOwnedRuntime(options = {}) {
384
293
  try { forwarder.stopWatch(); } catch {}
385
294
  if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
386
295
  // Roll back partial owner-side state advertised before connect() ran:
387
- // disconnect the backend WE started FIRST (a post-connect startup step may
388
- // have thrown while the gateway is live), then release the seat LAST so no
389
- // contender can acquire + connect a second gateway while ours is still up.
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.
390
299
  try { await startingBackend.disconnect(); } catch {}
391
300
  try { releaseOwnedChannelLocks(instanceId); } catch {}
392
301
  try { clearActiveInstance(instanceId); } catch {}
393
302
  if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
394
- try { await closeSeatServer(); } catch {}
395
303
  } finally {
396
- bridgeRuntimeStarting = false;
304
+ settleInFlightStart();
397
305
  }
398
306
  }
399
307
  async function stopOwnedRuntime(reason) {
@@ -441,24 +349,13 @@ async function stopOwnedRuntime(reason) {
441
349
  setBridgeRuntimeConnected(false);
442
350
  logOwnership(`standby: ${reason}`);
443
351
  }
444
- // Release the OS seat lock LAST — only after the backend has drained and
445
- // disconnected — so a contender can never acquire + connect a second gateway
446
- // while ours is still live (double-owner). Idempotent: a takeover-message
447
- // teardown runs this via stopOwnedRuntime, then the seat lock's own close is
448
- // a no-op.
449
- try { await closeSeatServer(); } catch {}
450
352
  }
451
353
  function refreshBridgeOwnershipSafe(options = {}) {
452
354
  refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
453
355
  }
454
- // Ownership-loss detection is push-based under the seat-lock model: the OS
455
- // releases the pipe/socket on holder crash, and an explicit takeover message
456
- // invokes onTakeover. This entry point (called from worker-main start() and
457
- // startOwnedRuntime) now just guarantees that handler is registered — no poll
458
- // timer, no active-instance fs.watch.
459
- function armBridgeOwnershipTimer() {
460
- registerSeatTakeoverOnce();
461
- }
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() {}
462
359
  // Guarded IPC send to the parent: no-ops when there is no channel or it is
463
360
  // already disconnected, and swallows both the synchronous throw and the async
464
361
  // error-callback path of ERR_IPC_CHANNEL_CLOSED (channel closing between the
@@ -473,20 +370,16 @@ function sendToParent(message) {
473
370
  process.stderr.write(`[channels] parent IPC send threw: ${err?.message || err}\n`);
474
371
  }
475
372
  }
476
- // Tell the parent session that this worker LOST the bridge seat to a newer
477
- // remote session (last-wins). The parent flips its remote mode OFF entirely —
478
- // exactly one session holds remote; losers fully release, no handover.
479
- function notifyRemoteSuperseded() {
480
- sendToParent({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
481
- }
482
- // Symmetric to notifyRemoteSuperseded: tell the parent session this worker
483
- // ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
484
- // Guarded by process.send so a manually-forked worker with no IPC parent is a
485
- // no-op instead of crashing. Callers fire this only on a genuine claim
486
- // transition (boot make-before-break, activate when not already owned) — never
487
- // 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.
488
378
  function notifyRemoteAcquired() {
489
- 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' });
490
383
  }
491
384
  async function refreshBridgeOwnership(options = {}) {
492
385
  // Coalesce concurrent callers onto the in-flight refresh so getBackend() tool
@@ -494,29 +387,15 @@ async function refreshBridgeOwnership(options = {}) {
494
387
  // instead of returning early and observing spurious auto-connect failure.
495
388
  if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
496
389
  bridgeOwnershipRefreshInFlight = (async () => {
497
- // Ownership authority is the OS seat lock, not active-instance.json.
390
+ // Daemon model: this runtime is the unconditional bridge owner, so refresh
391
+ // just keeps the owned runtime in sync with channelBridgeActive.
498
392
  if (!getChannelBridgeActive()) {
499
393
  if (getBridgeRuntimeConnected()) await stopOwnedRuntime("bridge inactive");
500
394
  return;
501
395
  }
502
- // We hold the seat -> ensure the owned runtime is up (idempotent).
503
- if (isSeatHeld()) {
504
- await startOwnedRuntime(options);
505
- return;
506
- }
507
- // We do NOT hold the seat but the runtime still thinks it is live — a
508
- // takeover slipped past the push handler; tear down and drop remote.
509
- if (getBridgeRuntimeConnected() || bridgeRuntimeStarting) {
510
- await stopOwnedRuntime("ownership lost (seat released)");
511
- notifyRemoteSuperseded();
512
- return;
513
- }
514
- // Explicit (re)claim only: activation (/remote), reload restart, or the
515
- // auto-connect retry. Default force-takeover; claimIfVacant backs off from a
516
- // live holder. Periodic/defensive nudges pass no claim flag and never steal.
517
- if (options.claim) {
518
- await startOwnedRuntime({ ...options, reclaim: true });
519
- }
396
+ // Active -> ensure the owned runtime is up (idempotent; early-returns when
397
+ // already connected).
398
+ await startOwnedRuntime(options);
520
399
  })();
521
400
  try {
522
401
  return await bridgeOwnershipRefreshInFlight;
@@ -541,6 +420,12 @@ async function reloadRuntimeConfig() {
541
420
  if (backendChanged) {
542
421
  const shouldRestart = getBridgeRuntimeConnected() || bridgeRuntimeStarting;
543
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 {} }
544
429
  setBackend(nextBackend);
545
430
  // The persisted routing channelId belongs to the OLD getBackend() (e.g. a
546
431
  // Discord snowflake) and is meaningless for the new one — sending to it
@@ -560,9 +445,10 @@ async function reloadRuntimeConfig() {
560
445
  state.transcriptPath = "";
561
446
  });
562
447
  } catch {}
563
- // stopOwnedRuntime above released the seat; a same-session reload must
564
- // re-acquire it (claim: force-takeover) to resume owning the bridge.
565
- if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false, claim: true });
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.
451
+ if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
566
452
  } else if (nextBackend !== previousBackend) {
567
453
  try { await nextBackend.disconnect?.(); } catch {}
568
454
  }
@@ -581,6 +467,5 @@ async function reloadRuntimeConfig() {
581
467
  armBridgeOwnershipTimer,
582
468
  clearBridgeOwnershipTimer,
583
469
  notifyRemoteAcquired,
584
- notifyRemoteSuperseded,
585
470
  };
586
471
  }
@@ -1,13 +1,10 @@
1
- // Bridge ownership snapshot + ownership logging. Ownership TRUTH now comes from
2
- // the OS-enforced seat lock (lib/seat-lock.mjs): this process owns the bridge
3
- // iff it holds the named-pipe/unix-socket listener. The old 5s file heartbeat +
4
- // last-wins active-instance CAS is retired a holder crash auto-releases the
5
- // pipe and takeover is an explicit release message, so no periodic refresh or
6
- // staleness eviction is needed. active-instance.json is now a pure metadata
7
- // advert written elsewhere; it is no longer read to decide ownership.
8
- function createOwnerHeartbeat({
9
- isSeatHeld,
10
- }) {
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() {
11
8
  let lastOwnershipNote = "";
12
9
 
13
10
  function logOwnership(note) {
@@ -17,9 +14,8 @@ function createOwnerHeartbeat({
17
14
  `);
18
15
  }
19
16
  function currentOwnerState() {
20
- // Ownership is exactly "do we hold the seat lock". No file read, no PID
21
- // heuristic, no staleness window.
22
- return { owned: isSeatHeld() === true };
17
+ // Daemon singleton: this runtime always owns the bridge.
18
+ return { owned: true };
23
19
  }
24
20
  function getBridgeOwnershipSnapshot() {
25
21
  return currentOwnerState();
@@ -84,12 +84,12 @@ function activeInstanceStaleReason(state) {
84
84
  if (workerPid && !isPidAlive(workerPid)) return `worker PID ${workerPid} is dead`;
85
85
  const serverPid = parsePositivePid(state?.server_pid);
86
86
  if (serverPid && !isPidAlive(serverPid)) return `server PID ${serverPid} is dead`;
87
- // NOTE: ownership is no longer decided here — the OS seat lock
88
- // (lib/seat-lock.mjs) is the authority. This PID-death check only marks a
89
- // metadata advert whose owning process is gone so refreshActiveInstance can
90
- // avoid preserving its owner fields. The former ui_heartbeat_at staleness
91
- // branch was removed: its false-stale eviction was the Discord-flapping root
92
- // cause, and a crashed holder now auto-releases the seat instead.
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.
93
93
  return null;
94
94
  }
95
95
  function buildRuntimeIdentity() {
@@ -50,24 +50,17 @@ 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
- // Genuine acquire transition (we do NOT hold the seat) — tell the
63
- // parent to flip remote ON. The seat itself is acquired inside
64
- // refreshBridgeOwnership({ claim: true }) below (explicit
65
- // takeover). An idempotent re-activate (already own the seat)
66
- // skips both the notify and the takeover.
67
60
  notifyRemoteAcquired?.();
68
61
  }
69
62
  try {
70
- await refreshBridgeOwnership({ restoreBinding: true, claim: true });
63
+ await refreshBridgeOwnership({ restoreBinding: true });
71
64
  // An already-connected owner returns early from
72
65
  // startOwnedRuntime(), so rebind explicitly to follow the
73
66
  // current (parent-chain) session transcript.
@@ -154,9 +147,8 @@ function createToolDispatch({
154
147
  // Remote-owner startup: ensure this owner's backend is connected.
155
148
  for (let i = 0; i < 2 && !getBridgeRuntimeConnected(); i++) {
156
149
  try {
157
- // Auto-connect a remote owner. claimIfVacant: acquire the seat only
158
- // when vacant/stale — never steal a live holder from this retry path.
159
- await refreshBridgeOwnership({ claim: true, claimIfVacant: true });
150
+ // Auto-connect this owner's backend (daemon singleton — no seat claim).
151
+ await refreshBridgeOwnership();
160
152
  } catch {
161
153
  }
162
154
  if (!getBridgeRuntimeConnected()) await new Promise((r) => setTimeout(r, 300));
@@ -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;
@@ -67,7 +67,6 @@ import { createParentBridge } from "./parent-bridge.mjs";
67
67
  import { createInboundRouting } from "./inbound-routing.mjs";
68
68
  import { createToolDispatch } from "./tool-dispatch.mjs";
69
69
  import { createOwnerHeartbeat } from "./owner-heartbeat.mjs";
70
- import { createSeatLock } from "./seat-lock.mjs";
71
70
  import { createTranscriptBinding } from "./transcript-binding.mjs";
72
71
  import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
73
72
  import { runWorkerIpc } from "./worker-ipc.mjs";
@@ -139,9 +138,6 @@ let config = loadConfig();
139
138
  let backend = createBackend(config);
140
139
  const INSTANCE_ID = makeInstanceId();
141
140
  const TERMINAL_LEAD_PID = getTerminalLeadPid();
142
- // OS-enforced bridge-seat singleton. Ownership TRUTH is holding this listener;
143
- // a holder crash auto-releases it and takeover is an explicit release message.
144
- const seatLock = createSeatLock({ runtimeRoot: RUNTIME_ROOT, instanceId: INSTANCE_ID });
145
141
  runWorkerBootstrap({
146
142
  instanceId: INSTANCE_ID,
147
143
  isWorkerMode: _isWorkerMode,
@@ -328,9 +324,7 @@ const {
328
324
  logOwnership,
329
325
  currentOwnerState,
330
326
  getBridgeOwnershipSnapshot,
331
- } = createOwnerHeartbeat({
332
- isSeatHeld: () => seatLock.isSeatHeld(),
333
- });
327
+ } = createOwnerHeartbeat();
334
328
  // ── Owned-runtime lifecycle ─────────────────────────────────────────────────
335
329
  // Extracted -> lib/owned-runtime.mjs. Owns its own start/stop/refresh in-flight
336
330
  // flags + ownership timer + memory-drain timer; shares config/backend/
@@ -359,14 +353,11 @@ const {
359
353
  instanceId: INSTANCE_ID,
360
354
  TERMINAL_LEAD_PID,
361
355
  forwarder,
356
+ sendNotifyToParent,
362
357
  scheduler,
363
358
  statusState,
364
359
  logOwnership,
365
360
  currentOwnerState,
366
- acquireSeat: (opts) => seatLock.acquireSeat(opts),
367
- closeSeatServer: () => seatLock.closeSeatServer(),
368
- isSeatHeld: () => seatLock.isSeatHeld(),
369
- onTakeover: (cb) => seatLock.onTakeover(cb),
370
361
  bindPersistedTranscriptIfAny,
371
362
  cancelPendingTranscriptRearm,
372
363
  schedulePendingTranscriptRearm,
@@ -707,21 +698,13 @@ async function init(_sharedMcp) {
707
698
  async function start() {
708
699
  channelBridgeActive = true;
709
700
  writeBridgeState(true);
710
- // Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
711
- // seat up front: connect our own gateway first and claim only after it reports
712
- // READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
713
- // owner keeps serving, so a boot causes no send/receive gap. This is also the
714
- // SINGLE boot claim — the old "remote start" pre-claim + "re-activate
715
- // 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.
716
705
  const _bindingReadyStart = Date.now();
717
706
  try {
718
- // Boot claim intent (published by the parent via env before this fork):
719
- // explicit (/remote, --remote) -> last-wins force-takeover.
720
- // auto (config/delayed autoStart) -> claim-if-vacant: take the seat only
721
- // when no live owner holds it, otherwise back off silently (no claim, no
722
- // acquire notify) so a live owner is never stolen.
723
- const autoStartClaim = process.env.MIXDOG_REMOTE_INTENT === 'auto';
724
- await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true, claimIfVacant: autoStartClaim });
707
+ await startOwnedRuntime({ restoreBinding: true });
725
708
  bindingReadyStatus = "resolved";
726
709
  dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
727
710
  _bindingReadyResolve(true);
@@ -730,10 +713,8 @@ async function start() {
730
713
  dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
731
714
  _bindingReadyResolve(e);
732
715
  }
733
- // Ownership timer: keep checking whether a newer remote session has taken
734
- // over (last-wins) so a superseded owner disconnects promptly. Normally
735
- // already armed by startOwnedRuntime before connect(); this is the fallback
736
- // 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.
737
718
  armBridgeOwnershipTimer();
738
719
  // Hot-reload config on file change (schedules/webhooks/events).
739
720
  if (!_configWatcher) {
@@ -700,7 +700,10 @@ export function createQueryHandlers({
700
700
  // caller's own session marked "(current)" via the currentSessionId hint.
701
701
  // Falls through to the flat list when everything is one session.
702
702
  const _currentSessionHint = String(args?.currentSessionId || '').trim()
703
- return { text: recallCapPrefix + renderSessionGroupedLines(sliced, { currentSessionId: _currentSessionHint }) }
703
+ // recencyOrder on the date path: without it, chunk members (stored ts-ASC
704
+ // per root) interleave out of order with raw rows inside each session
705
+ // group (e.g. 04:33 rendered above 04:41).
706
+ return { text: recallCapPrefix + renderSessionGroupedLines(sliced, { currentSessionId: _currentSessionHint, recencyOrder: sort === 'date' }) }
704
707
  }
705
708
 
706
709
  async function dumpSessionRootChunks(args = {}) {
@@ -316,7 +316,11 @@ function shortSessionLabel(sid) {
316
316
  // The caller's own session (currentSessionId hint) is marked "(current)".
317
317
  // Single-session (or session-less) result sets fall through to the flat
318
318
  // renderer — no headers when grouping adds nothing.
319
- export function renderSessionGroupedLines(rows, { currentSessionId } = {}) {
319
+ // recencyOrder (date-sorted browse) is forwarded to renderEntryLines so each
320
+ // group's lines are globally ts-desc: without it a chunk root's members (stored
321
+ // ts-ASC) interleave with raw rows and invert the visible timeline within a
322
+ // session (e.g. 04:33 rendered above 04:41).
323
+ export function renderSessionGroupedLines(rows, { currentSessionId, recencyOrder = false } = {}) {
320
324
  if (!rows || rows.length === 0) return '(no results)'
321
325
  const groups = new Map()
322
326
  for (const r of rows) {
@@ -325,14 +329,14 @@ export function renderSessionGroupedLines(rows, { currentSessionId } = {}) {
325
329
  if (!groups.has(key)) groups.set(key, [])
326
330
  groups.get(key).push(r)
327
331
  }
328
- if (groups.size <= 1) return renderEntryLines(rows)
332
+ if (groups.size <= 1) return renderEntryLines(rows, { recencyOrder })
329
333
  const current = String(currentSessionId || '').trim()
330
334
  const parts = []
331
335
  for (const [sid, groupRows] of groups) {
332
336
  const mark = current && sid === current ? ' (current)' : ''
333
337
  const label = sid === '(no session)' ? sid : `session ${shortSessionLabel(sid)}`
334
338
  parts.push(`## ${label}${mark}`)
335
- parts.push(renderEntryLines(groupRows))
339
+ parts.push(renderEntryLines(groupRows, { recencyOrder }))
336
340
  }
337
341
  return parts.join('\n')
338
342
  }
@@ -21,7 +21,7 @@ export const TOOL_DEFS = [
21
21
  status: { type: 'string', enum: ['pending','active','archived'], description: 'Lifecycle status.' },
22
22
  limit: { type: 'number', description: 'Max rows/items.' },
23
23
  confirm: { type: 'string', description: 'Exact confirmation phrase for destructive actions.' },
24
- project_id: { type: 'string', description: 'Core pool: common, slug, or *.' },
24
+ project_id: { type: 'string', description: 'Core pool: common, slug, or *. Required for core add/edit; there is no default pool.' },
25
25
  },
26
26
  required: ['action'],
27
27
  },