mixdog 0.9.19 → 0.9.21
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/LICENSE +21 -0
- package/README.md +100 -34
- package/package.json +3 -2
- package/scripts/build-tui.mjs +6 -0
- package/scripts/hook-bus-test.mjs +8 -0
- package/scripts/log-writer-guard-smoke.mjs +131 -0
- package/scripts/reactive-compact-persist-smoke.mjs +22 -6
- package/scripts/recall-bench-cases.json +0 -1
- package/scripts/recall-quality-cases.json +1 -2
- package/scripts/session-ingest-compaction-smoke.mjs +241 -0
- package/scripts/tool-smoke.mjs +150 -45
- package/src/defaults/skills/setup/SKILL.md +327 -0
- package/src/help.mjs +2 -5
- package/src/lib/mixdog-debug.cjs +13 -0
- package/src/mixdog-session-runtime.mjs +7 -3328
- package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
- package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
- package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
- package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
- package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
- package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
- package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
- package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
- package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
- package/src/runtime/channels/index.mjs +6 -2183
- package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
- package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
- package/src/runtime/channels/lib/network-retry.mjs +23 -0
- package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
- package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
- package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
- package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
- package/src/runtime/channels/lib/worker-main.mjs +777 -0
- package/src/runtime/memory/index.mjs +163 -1725
- package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
- package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
- package/src/runtime/memory/lib/http-router.mjs +811 -0
- package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
- package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
- package/src/runtime/memory/lib/memory-embed.mjs +28 -7
- package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
- package/src/runtime/memory/lib/memory.mjs +39 -0
- package/src/runtime/memory/lib/query-handlers.mjs +2 -2
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
- package/src/runtime/shared/atomic-file.mjs +138 -80
- package/src/runtime/shared/child-guardian.mjs +61 -3
- package/src/session-runtime/boot-profile.mjs +36 -0
- package/src/session-runtime/channel-config-api.mjs +70 -0
- package/src/session-runtime/context-status.mjs +181 -0
- package/src/session-runtime/env.mjs +17 -0
- package/src/session-runtime/lifecycle-api.mjs +242 -0
- package/src/session-runtime/model-route-api.mjs +198 -0
- package/src/session-runtime/provider-auth-api.mjs +135 -0
- package/src/session-runtime/resource-api.mjs +282 -0
- package/src/session-runtime/runtime-core.mjs +2104 -0
- package/src/session-runtime/session-turn-api.mjs +274 -0
- package/src/session-runtime/tool-catalog.mjs +18 -264
- package/src/session-runtime/tool-defs.mjs +2 -2
- package/src/session-runtime/workflow-agents-api.mjs +238 -0
- package/src/standalone/agent-tool.mjs +2 -2
- package/src/standalone/channel-worker.mjs +67 -7
- package/src/standalone/folder-dialog.mjs +4 -1
- package/src/standalone/memory-runtime-proxy.mjs +154 -17
- package/src/standalone/seeds.mjs +28 -1
- package/src/tui/App.jsx +40 -28
- package/src/tui/app/core-memory-picker.mjs +1 -1
- package/src/tui/app/doctor.mjs +175 -0
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +6 -0
- package/src/tui/app/use-transcript-scroll.mjs +77 -3
- package/src/tui/dist/index.mjs +2851 -2162
- package/src/tui/engine/context-state.mjs +145 -0
- package/src/tui/engine/prompt-history.mjs +27 -0
- package/src/tui/engine/session-api-ext.mjs +478 -0
- package/src/tui/engine/session-api.mjs +564 -0
- package/src/tui/engine/session-flow.mjs +485 -0
- package/src/tui/engine/turn.mjs +1078 -0
- package/src/tui/engine.mjs +68 -2620
- package/src/tui/index.jsx +7 -0
- package/vendor/ink/build/ink.js +16 -1
- package/vendor/ink/build/output.js +30 -4
- package/vendor/ink/build/render.js +5 -0
- package/src/workflows/sequential/WORKFLOW.md +0 -51
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { performance } from "perf_hooks";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
const _require = createRequire(import.meta.url);
|
|
7
|
+
import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./config.mjs";
|
|
8
|
+
import { resolveVoiceRuntime } from "./voice-runtime-fetcher.mjs";
|
|
9
|
+
import { ensureReady, stopVoiceWhisperServer } from "./whisper-server.mjs";
|
|
10
|
+
import { loadConfig as loadAgentConfig } from "../../agent/orchestrator/config.mjs";
|
|
11
|
+
import { captureOriginalUserCwd, readLastSessionCwd } from "../../shared/user-cwd.mjs";
|
|
12
|
+
import { initProviders } from "../../agent/orchestrator/providers/registry.mjs";
|
|
13
|
+
import { Scheduler } from "./scheduler.mjs";
|
|
14
|
+
import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./status-snapshot.mjs";
|
|
15
|
+
import { hasPending as dispatchHasPending } from "../../agent/orchestrator/dispatch-persist.mjs";
|
|
16
|
+
import { setListener as setActivityBusListener } from "../../agent/orchestrator/activity-bus.mjs";
|
|
17
|
+
import { stripSoftWarns } from "../../agent/orchestrator/tool-loop-guard.mjs";
|
|
18
|
+
import { WebhookServer } from "./webhook.mjs";
|
|
19
|
+
import { EventPipeline } from "./event-pipeline.mjs";
|
|
20
|
+
import { startCliWorker } from "./cli-worker-host.mjs";
|
|
21
|
+
import {
|
|
22
|
+
OutputForwarder,
|
|
23
|
+
discoverSessionBoundTranscript,
|
|
24
|
+
findLatestTranscriptByMtime,
|
|
25
|
+
sameResolvedPath
|
|
26
|
+
} from "./output-forwarder.mjs";
|
|
27
|
+
import { controlClaudeSession } from "./session-control.mjs";
|
|
28
|
+
import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./state-file.mjs";
|
|
29
|
+
import {
|
|
30
|
+
buildModalRequestSpec,
|
|
31
|
+
PendingInteractionStore
|
|
32
|
+
} from "./interaction-workflows.mjs";
|
|
33
|
+
import {
|
|
34
|
+
ensureRuntimeDirs,
|
|
35
|
+
makeInstanceId,
|
|
36
|
+
getTurnEndPath,
|
|
37
|
+
getStatusPath,
|
|
38
|
+
getPermissionResultPath,
|
|
39
|
+
getChannelOwnerPath,
|
|
40
|
+
getActiveOwnerPid,
|
|
41
|
+
getTerminalLeadPid,
|
|
42
|
+
readActiveInstance,
|
|
43
|
+
refreshActiveInstance,
|
|
44
|
+
cleanupStaleRuntimeFiles,
|
|
45
|
+
probeActiveOwner,
|
|
46
|
+
cleanupInstanceRuntimeFiles,
|
|
47
|
+
releaseOwnedChannelLocks,
|
|
48
|
+
clearActiveInstance,
|
|
49
|
+
notePreviousServerIfAny,
|
|
50
|
+
writeServerPid,
|
|
51
|
+
clearServerPid,
|
|
52
|
+
RUNTIME_ROOT
|
|
53
|
+
} from "./runtime-paths.mjs";
|
|
54
|
+
import { getDiscordToken } from "./config.mjs";
|
|
55
|
+
import { bootProfile, localTimestamp } from "./boot-profile.mjs";
|
|
56
|
+
import {
|
|
57
|
+
isChannelsDegraded,
|
|
58
|
+
logCrash,
|
|
59
|
+
_isBenignCrash,
|
|
60
|
+
BENIGN_CRASH_FATAL_THRESHOLD,
|
|
61
|
+
BENIGN_CRASH_STREAK_WINDOW_MS,
|
|
62
|
+
} from "./crash-log.mjs";
|
|
63
|
+
import { dropTrace, preview, _dtIdxFlush } from "./index-drop-trace.mjs";
|
|
64
|
+
import { createVoiceTranscription } from "./voice-transcription.mjs";
|
|
65
|
+
import { createBackendDispatch } from "./backend-dispatch.mjs";
|
|
66
|
+
import { createParentBridge } from "./parent-bridge.mjs";
|
|
67
|
+
import { createInboundRouting } from "./inbound-routing.mjs";
|
|
68
|
+
import { createToolDispatch } from "./tool-dispatch.mjs";
|
|
69
|
+
import { createOwnerHeartbeat } from "./owner-heartbeat.mjs";
|
|
70
|
+
import { createTranscriptBinding } from "./transcript-binding.mjs";
|
|
71
|
+
import { isNetworkError, retryOnNetwork } from "./network-retry.mjs";
|
|
72
|
+
import { runWorkerIpc } from "./worker-ipc.mjs";
|
|
73
|
+
import { createInteractionHandlers } from "./interaction-handlers.mjs";
|
|
74
|
+
import { createInboundHandler } from "./inbound-handler.mjs";
|
|
75
|
+
import { createOwnedRuntime } from "./owned-runtime.mjs";
|
|
76
|
+
import { runWorkerBootstrap } from "./worker-bootstrap.mjs";
|
|
77
|
+
const memoryClientModulePath = new URL("./memory-client.mjs", import.meta.url).href;
|
|
78
|
+
const {
|
|
79
|
+
appendEntry: memoryAppendEntry,
|
|
80
|
+
ingestTranscript: memoryIngestTranscript,
|
|
81
|
+
drainBuffer: memoryDrainBuffer,
|
|
82
|
+
} = await import(memoryClientModulePath);
|
|
83
|
+
// Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
|
|
84
|
+
// after an unhandled rejection whose async state was already corrupted
|
|
85
|
+
// (observed: EPERM on active-instance.json rename retry), so it spun
|
|
86
|
+
// forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
|
|
87
|
+
let _benignCrashStreak = 0;
|
|
88
|
+
let _lastBenignCrashAt = 0;
|
|
89
|
+
function _fatalCrash(label, err) {
|
|
90
|
+
logCrash(label, err);
|
|
91
|
+
const benign = _isBenignCrash(err);
|
|
92
|
+
if (benign) {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
_benignCrashStreak = (now - _lastBenignCrashAt) <= BENIGN_CRASH_STREAK_WINDOW_MS
|
|
95
|
+
? _benignCrashStreak + 1
|
|
96
|
+
: 1;
|
|
97
|
+
_lastBenignCrashAt = now;
|
|
98
|
+
if (_benignCrashStreak < BENIGN_CRASH_FATAL_THRESHOLD) return;
|
|
99
|
+
} else {
|
|
100
|
+
_benignCrashStreak = 0;
|
|
101
|
+
}
|
|
102
|
+
Promise.resolve()
|
|
103
|
+
.then(() => (typeof stop === "function" ? stop(`fatal:${label}`) : null))
|
|
104
|
+
.catch(() => {})
|
|
105
|
+
.finally(() => {
|
|
106
|
+
try { process.exitCode = 1; } catch {}
|
|
107
|
+
process.exit(1);
|
|
108
|
+
});
|
|
109
|
+
// Best-effort stop() may itself hang (e.g. IPC to a dead child) — a bare
|
|
110
|
+
// .finally() would then never fire and we're back to a zombie. Force the
|
|
111
|
+
// exit unconditionally after a short grace window regardless of outcome.
|
|
112
|
+
setTimeout(() => { try { process.exit(1); } catch {} }, 3000).unref?.();
|
|
113
|
+
}
|
|
114
|
+
process.on("unhandledRejection", (err) => _fatalCrash("unhandled rejection", err));
|
|
115
|
+
process.on("uncaughtException", (err) => _fatalCrash("uncaught exception", err));
|
|
116
|
+
if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
const _isWorkerMode = process.env.MIXDOG_WORKER_MODE === '1'
|
|
120
|
+
const _bootLogEarly = path.join(
|
|
121
|
+
DATA_DIR || path.join(os.tmpdir(), "mixdog"),
|
|
122
|
+
"boot.log"
|
|
123
|
+
);
|
|
124
|
+
const {
|
|
125
|
+
isMixdogDebugEnabled: isMixdogDebug,
|
|
126
|
+
pruneStalePluginDataLogSiblings,
|
|
127
|
+
appendSessionStartCriticalLog,
|
|
128
|
+
DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
129
|
+
} = _require("../../../lib/mixdog-debug.cjs");
|
|
130
|
+
// One-shot log rotation at worker boot (10 MB threshold, .1 suffix overwrite).
|
|
131
|
+
if (isMixdogDebug()) {
|
|
132
|
+
try { if (fs.statSync(_bootLogEarly).size > 10 * 1024 * 1024) fs.renameSync(_bootLogEarly, _bootLogEarly + '.1') } catch {}
|
|
133
|
+
fs.appendFileSync(_bootLogEarly, `[${localTimestamp()}] bootstrap start pid=${process.pid}
|
|
134
|
+
`);
|
|
135
|
+
}
|
|
136
|
+
const _bootLog = path.join(DATA_DIR, "boot.log");
|
|
137
|
+
let config = loadConfig();
|
|
138
|
+
let backend = createBackend(config);
|
|
139
|
+
const INSTANCE_ID = makeInstanceId();
|
|
140
|
+
const TERMINAL_LEAD_PID = getTerminalLeadPid();
|
|
141
|
+
runWorkerBootstrap({
|
|
142
|
+
instanceId: INSTANCE_ID,
|
|
143
|
+
isWorkerMode: _isWorkerMode,
|
|
144
|
+
pruneStalePluginDataLogSiblings,
|
|
145
|
+
DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
146
|
+
});
|
|
147
|
+
const INSTRUCTIONS = "";
|
|
148
|
+
|
|
149
|
+
// ── Parent notification helper ───────────────────────────────────────
|
|
150
|
+
// This worker has no MCP transport of its own. All notifications flow
|
|
151
|
+
// through IPC to the parent (server.mjs), which owns the single connected
|
|
152
|
+
// MCP `Server` instance. The parent's IPC message handler translates
|
|
153
|
+
// `{type:'notify', method, params}` into `server.notification({method, params})`.
|
|
154
|
+
//
|
|
155
|
+
// Before v0.6.7 the worker had its own orphan `Server` instance that was
|
|
156
|
+
// never `connect()`ed to any transport, so `.notification()` silently
|
|
157
|
+
// threw 'Not connected' inside the SDK and every call was dropped by an
|
|
158
|
+
// outer `.catch(() => {})`. That regression is what this path replaces.
|
|
159
|
+
const {
|
|
160
|
+
sendNotifyToParent,
|
|
161
|
+
callMemoryAction,
|
|
162
|
+
handleMemoryCallResponse,
|
|
163
|
+
} = createParentBridge({ getInstanceId: () => INSTANCE_ID });
|
|
164
|
+
let channelBridgeActive = false;
|
|
165
|
+
function writeBridgeState(active) {
|
|
166
|
+
try {
|
|
167
|
+
const stateFile = path.join(os.tmpdir(), "mixdog", "bridge-state.json");
|
|
168
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
169
|
+
fs.writeFileSync(stateFile, JSON.stringify({ active, ts: Date.now() }));
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function isChannelBridgeActive() {
|
|
174
|
+
return channelBridgeActive;
|
|
175
|
+
}
|
|
176
|
+
let typingChannelId = null;
|
|
177
|
+
const pendingSetup = new PendingInteractionStore();
|
|
178
|
+
function startServerTyping(channelId) {
|
|
179
|
+
if (typingChannelId && typingChannelId !== channelId) {
|
|
180
|
+
backend.stopTyping(typingChannelId);
|
|
181
|
+
}
|
|
182
|
+
typingChannelId = channelId;
|
|
183
|
+
backend.startTyping(channelId);
|
|
184
|
+
}
|
|
185
|
+
function stopServerTyping() {
|
|
186
|
+
if (typingChannelId) {
|
|
187
|
+
backend.stopTyping(typingChannelId);
|
|
188
|
+
typingChannelId = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
|
|
192
|
+
const TURN_END_BASENAME = path.basename(TURN_END_FILE);
|
|
193
|
+
const TURN_END_DIR = path.dirname(TURN_END_FILE);
|
|
194
|
+
let turnEndWatcher = null;
|
|
195
|
+
// Config hot-reload watcher (installed by start(); torn down by stop()).
|
|
196
|
+
let _configWatcher = null;
|
|
197
|
+
let _reloadDebounce = null;
|
|
198
|
+
if (!_isWorkerMode) {
|
|
199
|
+
removeFileIfExists(TURN_END_FILE);
|
|
200
|
+
turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
|
|
201
|
+
if (filename !== TURN_END_BASENAME) return;
|
|
202
|
+
try {
|
|
203
|
+
const stat = fs.statSync(TURN_END_FILE);
|
|
204
|
+
if (stat.size > 0) {
|
|
205
|
+
stopServerTyping();
|
|
206
|
+
await forwarder.forwardFinalText();
|
|
207
|
+
removeFileIfExists(TURN_END_FILE);
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const STATUS_FILE = getStatusPath(INSTANCE_ID);
|
|
214
|
+
const statusState = new JsonStateFile(STATUS_FILE, {});
|
|
215
|
+
statusState.ensure();
|
|
216
|
+
const forwarder = new OutputForwarder({
|
|
217
|
+
send: async (ch, text, opts) => {
|
|
218
|
+
if (!channelBridgeActive) {
|
|
219
|
+
throw new Error("send() called while channel bridge is inactive");
|
|
220
|
+
}
|
|
221
|
+
await backend.sendMessage(ch, text, opts);
|
|
222
|
+
},
|
|
223
|
+
formatOutgoing: (text) => backend.formatOutgoing ? backend.formatOutgoing(text) : text,
|
|
224
|
+
recordAssistantTurn: async () => {
|
|
225
|
+
},
|
|
226
|
+
react: (ch, mid, emoji) => {
|
|
227
|
+
if (!channelBridgeActive) return Promise.resolve();
|
|
228
|
+
return backend.react(ch, mid, emoji);
|
|
229
|
+
},
|
|
230
|
+
removeReaction: (ch, mid, emoji) => {
|
|
231
|
+
if (!channelBridgeActive) return Promise.resolve();
|
|
232
|
+
return backend.removeReaction(ch, mid, emoji);
|
|
233
|
+
},
|
|
234
|
+
// Watchdog backstop: force the backend to tear down + rebuild a wedged
|
|
235
|
+
// client so an over-budget send fails fast and the queue releases.
|
|
236
|
+
resetBackend: async () => {
|
|
237
|
+
if (typeof backend?._resetClient === "function") await backend._resetClient();
|
|
238
|
+
}
|
|
239
|
+
}, statusState);
|
|
240
|
+
forwarder.setOnIdle(() => {
|
|
241
|
+
stopServerTyping();
|
|
242
|
+
void forwarder.forwardFinalText();
|
|
243
|
+
});
|
|
244
|
+
// Wire the forwarder ownership probe unconditionally. wireEventQueueHandlers()
|
|
245
|
+
// also sets this, but that path only runs when the event pipeline starts
|
|
246
|
+
// (webhook enabled or event rules present). Without an event pipeline the
|
|
247
|
+
// forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
|
|
248
|
+
// non-owner process forward transcript output (duplicate Discord sends).
|
|
249
|
+
// The closure reads bridgeRuntimeConnected at call time as a fast-path AND;
|
|
250
|
+
// bridgeRuntimeConnected alone can go stale (e.g. this process lost the seat
|
|
251
|
+
// but has not yet observed it), so currentOwnerState().owned is re-read at
|
|
252
|
+
// probe time as the source of truth for ownership.
|
|
253
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
254
|
+
// ── Transcript binding cluster ──────────────────────────────────────────────
|
|
255
|
+
// Extracted → lib/transcript-binding.mjs. Bound to live config/identity/owner
|
|
256
|
+
// getters so file-level reference semantics (runtime reloads, ownership flips)
|
|
257
|
+
// are preserved.
|
|
258
|
+
const {
|
|
259
|
+
sessionIdFromTranscriptPath,
|
|
260
|
+
getPersistedTranscriptPath,
|
|
261
|
+
pickUsableTranscriptPath,
|
|
262
|
+
applyTranscriptBinding,
|
|
263
|
+
cancelPendingTranscriptRearm,
|
|
264
|
+
schedulePendingTranscriptRearm,
|
|
265
|
+
rebindTranscriptContext,
|
|
266
|
+
bindPersistedTranscriptIfAny,
|
|
267
|
+
} = createTranscriptBinding({
|
|
268
|
+
forwarder,
|
|
269
|
+
statusState,
|
|
270
|
+
readActiveInstance,
|
|
271
|
+
refreshActiveInstance,
|
|
272
|
+
instanceId: INSTANCE_ID,
|
|
273
|
+
memoryIngestTranscript,
|
|
274
|
+
memoryDrainBuffer,
|
|
275
|
+
dropTrace,
|
|
276
|
+
discoverSessionBoundTranscript,
|
|
277
|
+
sameResolvedPath,
|
|
278
|
+
getConfig: () => config,
|
|
279
|
+
getChannelBridgeActive: () => channelBridgeActive,
|
|
280
|
+
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
281
|
+
RUNTIME_ROOT,
|
|
282
|
+
});
|
|
283
|
+
const scheduler = new Scheduler(
|
|
284
|
+
config.nonInteractive ?? [],
|
|
285
|
+
config.interactive ?? [],
|
|
286
|
+
// Single resolved main-channel id used for the schedule `channel` flag.
|
|
287
|
+
config.channelId
|
|
288
|
+
);
|
|
289
|
+
// Register the pending-dispatch probe so the scheduler treats an in-flight
|
|
290
|
+
// bridge dispatch as "active" regardless of user-inbound silence.
|
|
291
|
+
scheduler.setPendingCheck(() => {
|
|
292
|
+
try {
|
|
293
|
+
return dispatchHasPending(DATA_DIR);
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
// Bridge the orchestrator-side activity notifier into the scheduler so
|
|
299
|
+
// events like `addPending` can bump lastActivity without importing the
|
|
300
|
+
// scheduler instance directly (avoids module cycles).
|
|
301
|
+
setActivityBusListener(() => scheduler.noteActivity());
|
|
302
|
+
let webhookServer = null;
|
|
303
|
+
let eventPipeline = null;
|
|
304
|
+
let bridgeRuntimeConnected = false;
|
|
305
|
+
// Stop-requested signal: set by stopOwnedRuntime() when it runs during the
|
|
306
|
+
// startOwnedRuntime() in-flight window (bridgeRuntimeStarting=true). Checked
|
|
307
|
+
// by startOwnedRuntime() right after backend.connect() resolves so the
|
|
308
|
+
// in-flight start does not revive owner state after the stop already tore
|
|
309
|
+
// the partial-start state down.
|
|
310
|
+
const ACTIVE_OWNER_STALE_MS = 1e4;
|
|
311
|
+
// Owner gating here is multi-process runtime coordination: only the active
|
|
312
|
+
// bindingReady gates all send paths until the boot-time refreshBridgeOwnership
|
|
313
|
+
// ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
|
|
314
|
+
// emissions fired within the first ~few hundred ms after restart drop because
|
|
315
|
+
// the Discord backend binding has not yet been established.
|
|
316
|
+
let bindingReadyStatus = "pending";
|
|
317
|
+
let _bindingReadyResolve;
|
|
318
|
+
const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
|
|
319
|
+
dropTrace("bindingReady.create", { status: bindingReadyStatus });
|
|
320
|
+
// ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
|
|
321
|
+
// Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
|
|
322
|
+
// dedup; bound to live identity + active-instance primitives.
|
|
323
|
+
const {
|
|
324
|
+
logOwnership,
|
|
325
|
+
currentOwnerState,
|
|
326
|
+
getBridgeOwnershipSnapshot,
|
|
327
|
+
claimBridgeOwnership,
|
|
328
|
+
startOwnerHeartbeat,
|
|
329
|
+
stopOwnerHeartbeat,
|
|
330
|
+
} = createOwnerHeartbeat({
|
|
331
|
+
getInstanceId: () => INSTANCE_ID,
|
|
332
|
+
readActiveInstance,
|
|
333
|
+
refreshActiveInstance,
|
|
334
|
+
});
|
|
335
|
+
// ── Owned-runtime lifecycle ─────────────────────────────────────────────────
|
|
336
|
+
// Extracted -> lib/owned-runtime.mjs. Owns its own start/stop/refresh in-flight
|
|
337
|
+
// flags + ownership timer + memory-drain timer; shares config/backend/
|
|
338
|
+
// bridgeRuntimeConnected/webhookServer/eventPipeline with the worker via get/set.
|
|
339
|
+
const {
|
|
340
|
+
startOwnedRuntime,
|
|
341
|
+
stopOwnedRuntime,
|
|
342
|
+
refreshBridgeOwnership,
|
|
343
|
+
refreshBridgeOwnershipSafe,
|
|
344
|
+
reloadRuntimeConfig,
|
|
345
|
+
armBridgeOwnershipTimer,
|
|
346
|
+
clearBridgeOwnershipTimer,
|
|
347
|
+
notifyRemoteAcquired,
|
|
348
|
+
} = createOwnedRuntime({
|
|
349
|
+
getConfig: () => config,
|
|
350
|
+
setConfig: (v) => { config = v; },
|
|
351
|
+
getBackend: () => backend,
|
|
352
|
+
setBackend: (v) => { backend = v; },
|
|
353
|
+
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
354
|
+
setBridgeRuntimeConnected: (v) => { bridgeRuntimeConnected = v; },
|
|
355
|
+
getWebhookServer: () => webhookServer,
|
|
356
|
+
setWebhookServer: (v) => { webhookServer = v; },
|
|
357
|
+
getEventPipeline: () => eventPipeline,
|
|
358
|
+
setEventPipeline: (v) => { eventPipeline = v; },
|
|
359
|
+
getChannelBridgeActive: () => channelBridgeActive,
|
|
360
|
+
instanceId: INSTANCE_ID,
|
|
361
|
+
TERMINAL_LEAD_PID,
|
|
362
|
+
forwarder,
|
|
363
|
+
scheduler,
|
|
364
|
+
statusState,
|
|
365
|
+
logOwnership,
|
|
366
|
+
currentOwnerState,
|
|
367
|
+
claimBridgeOwnership,
|
|
368
|
+
startOwnerHeartbeat,
|
|
369
|
+
stopOwnerHeartbeat,
|
|
370
|
+
bindPersistedTranscriptIfAny,
|
|
371
|
+
cancelPendingTranscriptRearm,
|
|
372
|
+
schedulePendingTranscriptRearm,
|
|
373
|
+
stopServerTyping,
|
|
374
|
+
wireWebhookHandlers,
|
|
375
|
+
wireEventQueueHandlers,
|
|
376
|
+
memoryDrainBuffer,
|
|
377
|
+
});
|
|
378
|
+
function injectAndRecord(channelId, name, content, options) {
|
|
379
|
+
// Strip soft-warn marker blocks (Tool-loop / Repeated-input / legacy
|
|
380
|
+
// Repeated-tool / Mixed-tool / Tool-budget / Same-file multi-chunk /
|
|
381
|
+
// Bash file-lookup / Iteration / 0-match advisory) from anywhere in the
|
|
382
|
+
// outbound body. Markers are
|
|
383
|
+
// intentionally prepended onto tool RESULTS upstream (tool-loop-guard.mjs
|
|
384
|
+
// build*Warn) so the model
|
|
385
|
+
// self-corrects, but agent roles commonly echo them and we don't want them
|
|
386
|
+
// surfacing in Discord / Lead channel push.
|
|
387
|
+
if (typeof content === 'string') content = stripSoftWarns(content);
|
|
388
|
+
// Skip-protocol guard: agents (webhook-handler / scheduler-task)
|
|
389
|
+
// prefix `[meta:silent]` on the first line to opt out
|
|
390
|
+
// of Lead inject for genuine no-op results (label-only events, dedup,
|
|
391
|
+
// "nothing to report"). The body still goes to Discord for audit; only
|
|
392
|
+
// the Lead-context inject is suppressed. See rules/agent/20-skip-protocol.md.
|
|
393
|
+
if (typeof content === 'string') {
|
|
394
|
+
const m = content.match(/^\s*\[meta:silent\][^\n]*\n?([\s\S]*)$/);
|
|
395
|
+
if (m) {
|
|
396
|
+
content = m[1];
|
|
397
|
+
options = { ...(options || {}), silent_to_agent: true };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const ts = new Date().toISOString();
|
|
401
|
+
const now = new Date();
|
|
402
|
+
const timeLabel = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")} `;
|
|
403
|
+
const sourceLabel = options?.type ? `${timeLabel}: ${options.type}` : timeLabel;
|
|
404
|
+
const meta = { chat_id: channelId, user: sourceLabel, user_id: "system", ts };
|
|
405
|
+
if (options?.instruction) meta.instruction = options.instruction;
|
|
406
|
+
if (options?.type) meta.type = options.type;
|
|
407
|
+
// `silent_to_agent` — lifecycle status pings (worker/iter/started echoes)
|
|
408
|
+
// surface on Discord but should NOT land in Lead's context window. When
|
|
409
|
+
// set, skip the parent-notify hop but keep the Discord-forward + event-log
|
|
410
|
+
// record. The meta flag is also propagated downstream so consumers that
|
|
411
|
+
// still see the notification (e.g. Lead itself if emission changes later)
|
|
412
|
+
// can recognise and drop it. Default is false → legacy behaviour preserved.
|
|
413
|
+
if (options?.silent_to_agent) meta.silent_to_agent = true;
|
|
414
|
+
const silent = options?.silent_to_agent === true;
|
|
415
|
+
if (!silent) {
|
|
416
|
+
sendNotifyToParent("notifications/claude/channel", { content, meta });
|
|
417
|
+
} else {
|
|
418
|
+
forwardLifecycleToDiscord(channelId, content);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Best-effort direct Discord emission for silent-to-agent lifecycle pings.
|
|
423
|
+
// Only used when the parent-notify hop is skipped, so the user still sees
|
|
424
|
+
// the status on Discord even though Lead will never echo it through the
|
|
425
|
+
// transcript-tail forwarder. Falls back to a no-op when no channel is
|
|
426
|
+
// resolvable — lifecycle pings are non-critical.
|
|
427
|
+
function forwardLifecycleToDiscord(channelId, content) {
|
|
428
|
+
try {
|
|
429
|
+
// Skip rather than guess: lifecycle callers pass the channelId they own;
|
|
430
|
+
// falling back to statusState.channelId can route to a stale/unrelated
|
|
431
|
+
// channel when the caller did not supply one intentionally.
|
|
432
|
+
const target = channelId || null;
|
|
433
|
+
dropTrace("send.lifecycle.entry", { channelId: target || "(none)", bindingReadyStatus, backendPresent: !!backend?.sendMessage, preview: preview(content) });
|
|
434
|
+
if (!target || !backend?.sendMessage) return;
|
|
435
|
+
void bindingReady.then(() =>
|
|
436
|
+
backend.sendMessage(target, content)
|
|
437
|
+
.then(() => dropTrace("send.lifecycle.ok", { channelId: target }))
|
|
438
|
+
.catch((err) => dropTrace("send.lifecycle.err", { channelId: target, err: String(err) }))
|
|
439
|
+
).catch(() => {});
|
|
440
|
+
} catch { /* best-effort */ }
|
|
441
|
+
}
|
|
442
|
+
scheduler.setInjectHandler((channelId, name, content, options) => {
|
|
443
|
+
injectAndRecord(channelId, name, content, options);
|
|
444
|
+
});
|
|
445
|
+
scheduler.setSendHandler(async (channelId, text) => {
|
|
446
|
+
// Skip protocol: a scheduler-task emitting `[meta:silent]` has nothing to
|
|
447
|
+
// report — suppress the channel send entirely (no noise). Mirrors the
|
|
448
|
+
// webhook delegate drop and injectAndRecord's silent handling.
|
|
449
|
+
if (typeof text === "string" && /^\s*\[meta:silent\]/.test(text)) {
|
|
450
|
+
dropTrace("send.scheduler.silent", { channelId });
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
dropTrace("send.scheduler.entry", { channelId, preview: preview(text) });
|
|
454
|
+
await bindingReady;
|
|
455
|
+
dropTrace("send.scheduler.ready", { channelId });
|
|
456
|
+
try {
|
|
457
|
+
await backend.sendMessage(channelId, text);
|
|
458
|
+
dropTrace("send.scheduler.ok", { channelId });
|
|
459
|
+
} catch (err) {
|
|
460
|
+
dropTrace("send.scheduler.err", { channelId, err: String(err) });
|
|
461
|
+
throw err;
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
function wireWebhookHandlers() {
|
|
465
|
+
if (!webhookServer) return;
|
|
466
|
+
webhookServer.setEventPipeline(eventPipeline);
|
|
467
|
+
webhookServer.setBridgeDispatch(async ({ role, preset, prompt, cwd, context }) => {
|
|
468
|
+
// Delegate-mode webhook → bridge orchestrator. Each bridge progress /
|
|
469
|
+
// final event is forwarded to the Lead via the same channel-notify
|
|
470
|
+
// path used by schedule & event-queue (injectAndRecord). Silent
|
|
471
|
+
// lifecycle pings keep routing only to Discord.
|
|
472
|
+
const agentMod = await import("../../agent/index.mjs");
|
|
473
|
+
const channelId = resolveWebhookChannelId(context?.channel);
|
|
474
|
+
const endpoint = context?.endpoint || "unknown";
|
|
475
|
+
const event = context?.event || null;
|
|
476
|
+
const deliveryId = context?.deliveryId || null;
|
|
477
|
+
const label = `webhook:${endpoint}`;
|
|
478
|
+
const instruction = `Webhook review from role=${role} on endpoint "${endpoint}"`
|
|
479
|
+
+ (event ? ` (event=${event})` : "")
|
|
480
|
+
+ (deliveryId ? ` (delivery=${deliveryId})` : "")
|
|
481
|
+
+ ". Relay the finding to the user naturally — summarize clearly, call out any issues, and note what needs a decision.";
|
|
482
|
+
const notifyFn = (text, meta = {}) => {
|
|
483
|
+
if (!text) return;
|
|
484
|
+
// Webhook skip protocol: when the agent worker emits a `[meta:silent]`
|
|
485
|
+
// marker (optionally behind model/role tag prefixes), the event is a
|
|
486
|
+
// no-op (label-only, dedup, "nothing to report"). Drop the message
|
|
487
|
+
// entirely — neither Lead inject nor Discord forward — instead of the
|
|
488
|
+
// partial `silent_to_agent` semantics that still audit to Discord.
|
|
489
|
+
const raw = String(text);
|
|
490
|
+
if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
|
|
491
|
+
// Deterministic findings-count drop. Code-review handlers emit a
|
|
492
|
+
// structured `[[findings:N]]` token (N = number of issues). The RELAY —
|
|
493
|
+
// not the worker's prose — decides: N==0 => clean review, drop entirely
|
|
494
|
+
// (no Lead inject, no Discord forward). Token absent => fail-safe forward
|
|
495
|
+
// so a real finding is never silently dropped if the worker omits it.
|
|
496
|
+
const fc = raw.match(/\[\[findings:(\d+)\]\]/i);
|
|
497
|
+
if (fc && Number(fc[1]) === 0) return;
|
|
498
|
+
// Lifecycle pings (started / iter echoes, marked silent_to_agent) are
|
|
499
|
+
// channel noise for an automated webhook review — drop them entirely so
|
|
500
|
+
// a skip stays fully silent and only the final answer reaches the
|
|
501
|
+
// channel. The final [meta:silent] skip result is already dropped above.
|
|
502
|
+
if (meta?.silent_to_agent === true) return;
|
|
503
|
+
// Strip the verdict token before surfacing (findings present, N>0).
|
|
504
|
+
const surfaced = raw.replace(/\[\[findings:\d+\]\]/gi, "").replace(/[^\S\n]{2,}/g, " ").trim();
|
|
505
|
+
injectAndRecord(channelId, label, surfaced || raw, {
|
|
506
|
+
type: "webhook",
|
|
507
|
+
instruction,
|
|
508
|
+
});
|
|
509
|
+
};
|
|
510
|
+
// Per-terminal cwd under the daemon's single channels worker. A webhook
|
|
511
|
+
// result is injected to ownerConn() — the connection whose session.leadPid
|
|
512
|
+
// equals active-instance ownerLeadPid — so the worker must run in THAT
|
|
513
|
+
// owner terminal's cwd. Read the sentinel keyed by ownerLeadPid; cwd-tool
|
|
514
|
+
// writes session-cwd-<leadPid>.txt per connection, so write and read meet
|
|
515
|
+
// on the same leadPid key no matter which terminal holds the owner seat.
|
|
516
|
+
// Falls back to the session entry position; never the plugin CACHE root.
|
|
517
|
+
const ownerPid = getActiveOwnerPid(readActiveInstance());
|
|
518
|
+
const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
|
|
519
|
+
return agentMod.handleToolCall(
|
|
520
|
+
"bridge",
|
|
521
|
+
{ role, preset, prompt, cwd: cwd || ownerCwd },
|
|
522
|
+
{ notifyFn },
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
function resolveWebhookChannelId(channelFlag) {
|
|
527
|
+
// Single main channel: the endpoint's `channel` frontmatter is a boolean-ish
|
|
528
|
+
// flag (any non-empty value except "false" → post to the main channel). A raw
|
|
529
|
+
// channel id is still honored verbatim for owner-authored overrides.
|
|
530
|
+
const main = config?.channelId || "";
|
|
531
|
+
if (channelFlag == null) return main;
|
|
532
|
+
const v = String(channelFlag).trim();
|
|
533
|
+
if (v === "" || v.toLowerCase() === "false") return "";
|
|
534
|
+
// A pure-digit / snowflake-looking value is treated as an explicit id;
|
|
535
|
+
// anything else (legacy labels like "main") maps to the main channel.
|
|
536
|
+
return /^-?\d+$/.test(v) ? v : main;
|
|
537
|
+
}
|
|
538
|
+
function wireEventQueueHandlers(eventQueue) {
|
|
539
|
+
if (!eventQueue) return;
|
|
540
|
+
eventQueue.setInjectHandler((channelId, name, content, options) => {
|
|
541
|
+
injectAndRecord(channelId, name, content, options);
|
|
542
|
+
});
|
|
543
|
+
// Defensive ownership probe: the queue tick should only run in the active
|
|
544
|
+
// owner process. Non-owner instances see bridgeRuntimeConnected=false and
|
|
545
|
+
// will skip the tick even if an errant start() slipped through.
|
|
546
|
+
// bridgeRuntimeConnected is a fast-path AND; currentOwnerState().owned is
|
|
547
|
+
// re-read at probe time so a stale-connected flag cannot mask a lost seat.
|
|
548
|
+
eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
549
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
550
|
+
}
|
|
551
|
+
const {
|
|
552
|
+
pendingPermRequests,
|
|
553
|
+
refreshToolExecConsumerMarker,
|
|
554
|
+
} = createInteractionHandlers({
|
|
555
|
+
getBackend: () => backend,
|
|
556
|
+
getConfig: () => config,
|
|
557
|
+
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
558
|
+
instanceId: INSTANCE_ID,
|
|
559
|
+
getBridgeOwnershipSnapshot,
|
|
560
|
+
refreshBridgeOwnershipSafe,
|
|
561
|
+
pendingSetup,
|
|
562
|
+
buildModalRequestSpec,
|
|
563
|
+
loadProfileConfig,
|
|
564
|
+
getDiscordToken,
|
|
565
|
+
sendNotifyToParent,
|
|
566
|
+
scheduler,
|
|
567
|
+
controlClaudeSession,
|
|
568
|
+
writeTextFile,
|
|
569
|
+
TURN_END_FILE,
|
|
570
|
+
getPermissionResultPath,
|
|
571
|
+
TERMINAL_LEAD_PID,
|
|
572
|
+
localTimestamp,
|
|
573
|
+
isMixdogDebug,
|
|
574
|
+
appendSessionStartCriticalLog,
|
|
575
|
+
DATA_DIR,
|
|
576
|
+
_bootLog,
|
|
577
|
+
RUNTIME_ROOT,
|
|
578
|
+
});
|
|
579
|
+
const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
|
|
580
|
+
getConfig: () => config,
|
|
581
|
+
dataDir: DATA_DIR,
|
|
582
|
+
});
|
|
583
|
+
import { TOOL_DEFS } from '../tool-defs.mjs';
|
|
584
|
+
// Tool dispatch in worker mode goes through the IPC `call` handler at the
|
|
585
|
+
// bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
|
|
586
|
+
// orphan worker-level MCP Server: the parent (server.mjs) owns the single
|
|
587
|
+
// connected transport and routes CallTool through the IPC `call` path.
|
|
588
|
+
const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
|
|
589
|
+
// ── Inbound routing / dedup / ownership helpers ─────────────────────────────
|
|
590
|
+
// Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
|
|
591
|
+
const {
|
|
592
|
+
writeChannelOwner,
|
|
593
|
+
shouldDropDuplicateInbound,
|
|
594
|
+
resolveInboundRoute,
|
|
595
|
+
} = createInboundRouting({
|
|
596
|
+
getConfig: () => config,
|
|
597
|
+
getInstanceId: () => INSTANCE_ID,
|
|
598
|
+
getChannelOwnerPath,
|
|
599
|
+
});
|
|
600
|
+
// ── Backend-tool dispatch helpers ───────────────────────────────────────────
|
|
601
|
+
// Each helper dispatches through the local backend (this process is always the
|
|
602
|
+
// owner in opt-in remote mode). Extracted → lib/backend-dispatch.mjs. Bound to
|
|
603
|
+
// live config/backend getters so runtime reloads keep the original file-level
|
|
604
|
+
// reference semantics.
|
|
605
|
+
const {
|
|
606
|
+
dispatchReply,
|
|
607
|
+
dispatchFetch,
|
|
608
|
+
} = createBackendDispatch({
|
|
609
|
+
getConfig: () => config,
|
|
610
|
+
getBackend: () => backend,
|
|
611
|
+
scheduler,
|
|
612
|
+
});
|
|
613
|
+
// ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
|
|
614
|
+
// handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
|
|
615
|
+
// lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
|
|
616
|
+
// functions plus mutable owner state (channelBridgeActive/bridgeRuntimeConnected)
|
|
617
|
+
// and the forwarder; those are threaded as a lifecycle bag of lazy getters so
|
|
618
|
+
// the module reads live file-level references at call time (original closure
|
|
619
|
+
// semantics preserved). Used by the HTTP MCP CallTool path and the worker IPC
|
|
620
|
+
// `call` handler at the bottom of this file.
|
|
621
|
+
const {
|
|
622
|
+
handleToolCall,
|
|
623
|
+
handleToolCallWithBridgeRetry,
|
|
624
|
+
} = createToolDispatch({
|
|
625
|
+
getForwarder: () => forwarder,
|
|
626
|
+
BACKEND_TOOLS,
|
|
627
|
+
isChannelsDegraded,
|
|
628
|
+
dispatchReply,
|
|
629
|
+
dispatchFetch,
|
|
630
|
+
lifecycle: {
|
|
631
|
+
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
632
|
+
getChannelBridgeActive: () => channelBridgeActive,
|
|
633
|
+
getOwned: () => getBridgeOwnershipSnapshot().owned,
|
|
634
|
+
setChannelBridgeActive: (v) => { channelBridgeActive = v; },
|
|
635
|
+
writeBridgeState,
|
|
636
|
+
stopServerTyping,
|
|
637
|
+
claimBridgeOwnership,
|
|
638
|
+
notifyRemoteAcquired,
|
|
639
|
+
refreshBridgeOwnership,
|
|
640
|
+
bindPersistedTranscriptIfAny,
|
|
641
|
+
stopOwnedRuntime,
|
|
642
|
+
reloadRuntimeConfig,
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
createInboundHandler({
|
|
646
|
+
getBackend: () => backend,
|
|
647
|
+
getConfig: () => config,
|
|
648
|
+
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
649
|
+
getChannelBridgeActive: () => channelBridgeActive,
|
|
650
|
+
instanceId: INSTANCE_ID,
|
|
651
|
+
forwarder,
|
|
652
|
+
scheduler,
|
|
653
|
+
statusState,
|
|
654
|
+
getBridgeOwnershipSnapshot,
|
|
655
|
+
refreshBridgeOwnershipSafe,
|
|
656
|
+
writeChannelOwner,
|
|
657
|
+
shouldDropDuplicateInbound,
|
|
658
|
+
resolveInboundRoute,
|
|
659
|
+
isVoiceAttachment,
|
|
660
|
+
transcribeVoice,
|
|
661
|
+
getPersistedTranscriptPath,
|
|
662
|
+
sessionIdFromTranscriptPath,
|
|
663
|
+
pickUsableTranscriptPath,
|
|
664
|
+
applyTranscriptBinding,
|
|
665
|
+
rebindTranscriptContext,
|
|
666
|
+
sendNotifyToParent,
|
|
667
|
+
memoryAppendEntry,
|
|
668
|
+
startServerTyping,
|
|
669
|
+
stopServerTyping,
|
|
670
|
+
});
|
|
671
|
+
async function init(_sharedMcp) {
|
|
672
|
+
// _sharedMcp is no longer used. Notifications now flow via IPC to the parent
|
|
673
|
+
// (sendNotifyToParent above). The parameter is retained for backward
|
|
674
|
+
// compatibility with any caller that still passes a Server reference.
|
|
675
|
+
scheduler.setInjectHandler((channelId, name, content, options) => {
|
|
676
|
+
injectAndRecord(channelId, name, content, options);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
async function start() {
|
|
680
|
+
channelBridgeActive = true;
|
|
681
|
+
writeBridgeState(true);
|
|
682
|
+
// Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
|
|
683
|
+
// seat up front: connect our own gateway first and claim only after it reports
|
|
684
|
+
// READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
|
|
685
|
+
// owner keeps serving, so a boot causes no send/receive gap. This is also the
|
|
686
|
+
// SINGLE boot claim — the old "remote start" pre-claim + "re-activate
|
|
687
|
+
// takeover" second claim (double reconnect) is gone.
|
|
688
|
+
const _bindingReadyStart = Date.now();
|
|
689
|
+
try {
|
|
690
|
+
// Boot claim intent (published by the parent via env before this fork):
|
|
691
|
+
// explicit (/remote, --remote) -> last-wins force-takeover.
|
|
692
|
+
// auto (config/delayed autoStart) -> claim-if-vacant: take the seat only
|
|
693
|
+
// when no live owner holds it, otherwise back off silently (no claim, no
|
|
694
|
+
// acquire notify) so a live owner is never stolen.
|
|
695
|
+
const autoStartClaim = process.env.MIXDOG_REMOTE_INTENT === 'auto';
|
|
696
|
+
await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true, claimIfVacant: autoStartClaim });
|
|
697
|
+
bindingReadyStatus = "resolved";
|
|
698
|
+
dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
|
|
699
|
+
_bindingReadyResolve(true);
|
|
700
|
+
} catch (e) {
|
|
701
|
+
bindingReadyStatus = "rejected";
|
|
702
|
+
dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
|
|
703
|
+
_bindingReadyResolve(e);
|
|
704
|
+
}
|
|
705
|
+
// Ownership timer: keep checking whether a newer remote session has taken
|
|
706
|
+
// over (last-wins) so a superseded owner disconnects promptly. Normally
|
|
707
|
+
// already armed by startOwnedRuntime before connect(); this is the fallback
|
|
708
|
+
// for a start path that aborted pre-connect (idempotent).
|
|
709
|
+
armBridgeOwnershipTimer();
|
|
710
|
+
// Hot-reload config on file change (schedules/webhooks/events).
|
|
711
|
+
if (!_configWatcher) {
|
|
712
|
+
try {
|
|
713
|
+
_configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
|
|
714
|
+
if (_reloadDebounce) clearTimeout(_reloadDebounce);
|
|
715
|
+
_reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
|
|
716
|
+
});
|
|
717
|
+
} catch {}
|
|
718
|
+
}
|
|
719
|
+
// Pre-warm the whisper-server manager once at owner startup so the first
|
|
720
|
+
// voice transcription does not pay cold-start cost. Non-blocking: failures
|
|
721
|
+
// (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
|
|
722
|
+
void (async () => {
|
|
723
|
+
try {
|
|
724
|
+
if (config.voice?.enabled === false) return;
|
|
725
|
+
const runtime = resolveVoiceRuntime(DATA_DIR);
|
|
726
|
+
if (!runtime?.installed) return;
|
|
727
|
+
const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
|
|
728
|
+
const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
|
|
729
|
+
await ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
|
|
730
|
+
} catch (err) {
|
|
731
|
+
try { process.stderr.write(`mixdog: voice.transcription pre-warm skipped: ${err}\n`); } catch {}
|
|
732
|
+
}
|
|
733
|
+
})();
|
|
734
|
+
}
|
|
735
|
+
async function stop() {
|
|
736
|
+
try { await stopVoiceWhisperServer(); } catch {}
|
|
737
|
+
await stopOwnedRuntime("unified server stop");
|
|
738
|
+
cleanupInstanceRuntimeFiles(INSTANCE_ID);
|
|
739
|
+
clearBridgeOwnershipTimer();
|
|
740
|
+
if (_reloadDebounce) { clearTimeout(_reloadDebounce); _reloadDebounce = null; }
|
|
741
|
+
if (_configWatcher) { try { _configWatcher.close(); } catch {} _configWatcher = null; }
|
|
742
|
+
if (turnEndWatcher) {
|
|
743
|
+
try { turnEndWatcher.close(); } catch {}
|
|
744
|
+
turnEndWatcher = null;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// ── IPC worker mode ──────────────────────────────────────────────
|
|
748
|
+
if (_isWorkerMode && process.send) {
|
|
749
|
+
runWorkerIpc({
|
|
750
|
+
start,
|
|
751
|
+
stop,
|
|
752
|
+
stopVoiceWhisperServer,
|
|
753
|
+
cleanupInstanceRuntimeFiles,
|
|
754
|
+
clearServerPid,
|
|
755
|
+
instanceId: INSTANCE_ID,
|
|
756
|
+
statusState,
|
|
757
|
+
getBackend: () => backend,
|
|
758
|
+
getConfig: () => config,
|
|
759
|
+
pendingPermRequests,
|
|
760
|
+
refreshToolExecConsumerMarker,
|
|
761
|
+
handleMemoryCallResponse,
|
|
762
|
+
handleToolCallWithBridgeRetry,
|
|
763
|
+
bootProfile,
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
export {
|
|
768
|
+
TOOL_DEFS,
|
|
769
|
+
handleToolCall,
|
|
770
|
+
handleToolCallWithBridgeRetry,
|
|
771
|
+
init,
|
|
772
|
+
INSTRUCTIONS as instructions,
|
|
773
|
+
isChannelBridgeActive,
|
|
774
|
+
isChannelsDegraded,
|
|
775
|
+
start,
|
|
776
|
+
stop
|
|
777
|
+
};
|