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.
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +38 -30
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +209 -44
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- 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
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
74
|
-
//
|
|
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
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
//
|
|
222
|
-
//
|
|
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
|
-
|
|
267
|
-
process.stderr.write(`mixdog: pre-connect
|
|
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
|
-
//
|
|
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
|
|
336
|
-
//
|
|
337
|
-
//
|
|
338
|
-
//
|
|
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
|
|
388
|
-
//
|
|
389
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
455
|
-
//
|
|
456
|
-
|
|
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
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
564
|
-
//
|
|
565
|
-
|
|
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.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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
|
-
//
|
|
21
|
-
|
|
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
|
|
88
|
-
// (
|
|
89
|
-
// metadata advert whose owning process is gone so
|
|
90
|
-
// avoid preserving its owner fields. The former
|
|
91
|
-
// branch was removed: its false-stale eviction was
|
|
92
|
-
//
|
|
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
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
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
|
|
158
|
-
|
|
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
|
-
//
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
734
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|