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.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- 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/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- package/src/rules/agent/30-explorer.md +6 -0
- 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 +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- 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/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- 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-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- 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 +126 -22
- 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 +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- 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 +150 -6
- 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-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- 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 +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- 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 +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- 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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
706
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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 }
|