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
|
@@ -1,2192 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { createRequire } from "module";
|
|
6
|
-
const _require = createRequire(import.meta.url);
|
|
7
|
-
import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
|
|
8
|
-
import { resolveVoiceRuntime } from "./lib/voice-runtime-fetcher.mjs";
|
|
9
|
-
import { ensureReady, stopVoiceWhisperServer } from "./lib/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 "./lib/scheduler.mjs";
|
|
14
|
-
import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./lib/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 "./lib/webhook.mjs";
|
|
19
|
-
import { EventPipeline } from "./lib/event-pipeline.mjs";
|
|
20
|
-
import { startCliWorker } from "./lib/cli-worker-host.mjs";
|
|
21
|
-
import {
|
|
22
|
-
OutputForwarder,
|
|
23
|
-
discoverSessionBoundTranscript,
|
|
24
|
-
findLatestTranscriptByMtime,
|
|
25
|
-
sameResolvedPath
|
|
26
|
-
} from "./lib/output-forwarder.mjs";
|
|
27
|
-
import { controlClaudeSession } from "./lib/session-control.mjs";
|
|
28
|
-
import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./lib/state-file.mjs";
|
|
29
|
-
import {
|
|
30
|
-
buildModalRequestSpec,
|
|
31
|
-
PendingInteractionStore
|
|
32
|
-
} from "./lib/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 "./lib/runtime-paths.mjs";
|
|
54
|
-
import { getDiscordToken } from "./lib/config.mjs";
|
|
55
|
-
import { bootProfile, localTimestamp } from "./lib/boot-profile.mjs";
|
|
56
|
-
import {
|
|
57
|
-
isChannelsDegraded,
|
|
58
|
-
logCrash,
|
|
59
|
-
_isBenignCrash,
|
|
60
|
-
BENIGN_CRASH_FATAL_THRESHOLD,
|
|
61
|
-
BENIGN_CRASH_STREAK_WINDOW_MS,
|
|
62
|
-
} from "./lib/crash-log.mjs";
|
|
63
|
-
import { dropTrace, preview, _dtIdxFlush } from "./lib/index-drop-trace.mjs";
|
|
64
|
-
import { createVoiceTranscription } from "./lib/voice-transcription.mjs";
|
|
65
|
-
import { createBackendDispatch } from "./lib/backend-dispatch.mjs";
|
|
66
|
-
import { createParentBridge } from "./lib/parent-bridge.mjs";
|
|
67
|
-
import { createInboundRouting } from "./lib/inbound-routing.mjs";
|
|
68
|
-
import { createToolDispatch } from "./lib/tool-dispatch.mjs";
|
|
69
|
-
import { createOwnerHeartbeat } from "./lib/owner-heartbeat.mjs";
|
|
70
|
-
const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.url).href;
|
|
71
|
-
const {
|
|
72
|
-
appendEntry: memoryAppendEntry,
|
|
73
|
-
ingestTranscript: memoryIngestTranscript,
|
|
74
|
-
drainBuffer: memoryDrainBuffer,
|
|
75
|
-
} = await import(memoryClientModulePath);
|
|
76
|
-
// Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
|
|
77
|
-
// after an unhandled rejection whose async state was already corrupted
|
|
78
|
-
// (observed: EPERM on active-instance.json rename retry), so it spun
|
|
79
|
-
// forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
|
|
80
|
-
let _benignCrashStreak = 0;
|
|
81
|
-
let _lastBenignCrashAt = 0;
|
|
82
|
-
function _fatalCrash(label, err) {
|
|
83
|
-
logCrash(label, err);
|
|
84
|
-
const benign = _isBenignCrash(err);
|
|
85
|
-
if (benign) {
|
|
86
|
-
const now = Date.now();
|
|
87
|
-
_benignCrashStreak = (now - _lastBenignCrashAt) <= BENIGN_CRASH_STREAK_WINDOW_MS
|
|
88
|
-
? _benignCrashStreak + 1
|
|
89
|
-
: 1;
|
|
90
|
-
_lastBenignCrashAt = now;
|
|
91
|
-
if (_benignCrashStreak < BENIGN_CRASH_FATAL_THRESHOLD) return;
|
|
92
|
-
} else {
|
|
93
|
-
_benignCrashStreak = 0;
|
|
94
|
-
}
|
|
95
|
-
Promise.resolve()
|
|
96
|
-
.then(() => (typeof stop === "function" ? stop(`fatal:${label}`) : null))
|
|
97
|
-
.catch(() => {})
|
|
98
|
-
.finally(() => {
|
|
99
|
-
try { process.exitCode = 1; } catch {}
|
|
100
|
-
process.exit(1);
|
|
101
|
-
});
|
|
102
|
-
// Best-effort stop() may itself hang (e.g. IPC to a dead child) — a bare
|
|
103
|
-
// .finally() would then never fire and we're back to a zombie. Force the
|
|
104
|
-
// exit unconditionally after a short grace window regardless of outcome.
|
|
105
|
-
setTimeout(() => { try { process.exit(1); } catch {} }, 3000).unref?.();
|
|
106
|
-
}
|
|
107
|
-
process.on("unhandledRejection", (err) => _fatalCrash("unhandled rejection", err));
|
|
108
|
-
process.on("uncaughtException", (err) => _fatalCrash("uncaught exception", err));
|
|
109
|
-
if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
|
|
110
|
-
process.exit(0);
|
|
111
|
-
}
|
|
112
|
-
const _isWorkerMode = process.env.MIXDOG_WORKER_MODE === '1'
|
|
113
|
-
const _bootLogEarly = path.join(
|
|
114
|
-
DATA_DIR || path.join(os.tmpdir(), "mixdog"),
|
|
115
|
-
"boot.log"
|
|
116
|
-
);
|
|
117
|
-
const {
|
|
118
|
-
isMixdogDebugEnabled: isMixdogDebug,
|
|
119
|
-
pruneStalePluginDataLogSiblings,
|
|
120
|
-
appendSessionStartCriticalLog,
|
|
121
|
-
DEFAULT_STALE_LOG_SIBLING_MAX,
|
|
122
|
-
} = _require("../../lib/mixdog-debug.cjs");
|
|
123
|
-
// One-shot log rotation at worker boot (10 MB threshold, .1 suffix overwrite).
|
|
124
|
-
if (isMixdogDebug()) {
|
|
125
|
-
try { if (fs.statSync(_bootLogEarly).size > 10 * 1024 * 1024) fs.renameSync(_bootLogEarly, _bootLogEarly + '.1') } catch {}
|
|
126
|
-
fs.appendFileSync(_bootLogEarly, `[${localTimestamp()}] bootstrap start pid=${process.pid}
|
|
127
|
-
`);
|
|
128
|
-
}
|
|
129
|
-
const _bootLog = path.join(DATA_DIR, "boot.log");
|
|
130
|
-
let config = loadConfig();
|
|
131
|
-
let backend = createBackend(config);
|
|
132
|
-
const INSTANCE_ID = makeInstanceId();
|
|
133
|
-
const TERMINAL_LEAD_PID = getTerminalLeadPid();
|
|
134
|
-
// Rotate additional worker logs (10 MB threshold).
|
|
135
|
-
for (const _rotLog of ["channels-worker.log", "schedule.log", "event.log", "memory-worker.log", "mcp-debug.log", "webhook.log", "pg.log", "session-start.log"]) {
|
|
136
|
-
const _rotPath = path.join(DATA_DIR, _rotLog);
|
|
137
|
-
try { if (fs.statSync(_rotPath).size > 10 * 1024 * 1024) fs.renameSync(_rotPath, _rotPath + ".1") } catch {}
|
|
138
|
-
}
|
|
139
|
-
// GC per-worker scoped sibling logs (`<name>-worker.<leadPid>.<workerPid>.log`).
|
|
140
|
-
// Master logs above rotate live; scoped siblings are opened once per worker
|
|
141
|
-
// process and never reopened, so age-based removal is the only reliable
|
|
142
|
-
// cleanup signal. 7-day TTL keeps recent crash forensics while bounding leak.
|
|
143
|
-
const _STALE_WORKER_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
144
|
-
try {
|
|
145
|
-
const _now = Date.now();
|
|
146
|
-
for (const _f of fs.readdirSync(DATA_DIR)) {
|
|
147
|
-
if (!/^(channels|memory)-worker\.\d+\.\d+\.log$/.test(_f)
|
|
148
|
-
&& !/^mcp-debug\.\d+\.\d+\.log$/.test(_f)
|
|
149
|
-
&& !/^supervisor\.\d+\.log$/.test(_f)) continue;
|
|
150
|
-
const _p = path.join(DATA_DIR, _f);
|
|
151
|
-
try { if (_now - fs.statSync(_p).mtimeMs > _STALE_WORKER_LOG_TTL_MS) fs.unlinkSync(_p); } catch {}
|
|
152
|
-
}
|
|
153
|
-
} catch {}
|
|
154
|
-
// GC stale ephemeral session files. closeSession plants a closed=true
|
|
155
|
-
// tombstone, but bench / smoke / probe drivers historically created sessions
|
|
156
|
-
// without ever calling closeSession, leaving 175-byte placeholders behind.
|
|
157
|
-
// 7-day TTL is safe because live agent sessions touch their JSON file on
|
|
158
|
-
// every ask iteration, so any file older than 7 days is provably abandoned.
|
|
159
|
-
const _SESSIONS_DIR = path.join(DATA_DIR, 'sessions');
|
|
160
|
-
const _STALE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
161
|
-
try {
|
|
162
|
-
const _now = Date.now();
|
|
163
|
-
for (const _f of fs.readdirSync(_SESSIONS_DIR)) {
|
|
164
|
-
if (!_f.endsWith('.json')) continue;
|
|
165
|
-
const _p = path.join(_SESSIONS_DIR, _f);
|
|
166
|
-
try { if (_now - fs.statSync(_p).mtimeMs > _STALE_SESSION_TTL_MS) fs.unlinkSync(_p); } catch {}
|
|
167
|
-
}
|
|
168
|
-
} catch {}
|
|
169
|
-
// Count-based cap: drop oldest *.log siblings when plugin-data accumulates
|
|
170
|
-
// hundreds of per-process files (doctor warns above 300).
|
|
171
|
-
try {
|
|
172
|
-
pruneStalePluginDataLogSiblings(DATA_DIR, DEFAULT_STALE_LOG_SIBLING_MAX);
|
|
173
|
-
} catch {}
|
|
174
|
-
// SIGTERM: flush the drop-trace buffer, but do NOT exit here. In worker
|
|
175
|
-
// mode the graceful `_channelsShutdownHandler` below owns shutdown
|
|
176
|
-
// (stop() → cleanup → process.exit). In non-worker mode no SIGTERM
|
|
177
|
-
// handler was previously installed beyond this one; defer to default
|
|
178
|
-
// termination so process.on('exit') hooks still run.
|
|
179
|
-
process.on("SIGTERM", () => {
|
|
180
|
-
void _dtIdxFlush();
|
|
181
|
-
if (!_isWorkerMode) process.exit(0);
|
|
182
|
-
});
|
|
183
|
-
// ────────────────────────────────────────────────────────────────────────────
|
|
184
|
-
ensureRuntimeDirs();
|
|
185
|
-
cleanupStaleRuntimeFiles();
|
|
186
|
-
if (!_isWorkerMode) {
|
|
187
|
-
notePreviousServerIfAny();
|
|
188
|
-
writeServerPid();
|
|
189
|
-
// Publish owner identity immediately so the SessionStart shim's
|
|
190
|
-
// owner_lead_alive() sees a live owner and uses the full connect budget
|
|
191
|
-
// instead of the 5s no-owner grace (fixes missing recap/core on restart).
|
|
192
|
-
// backendReady intentionally omitted — readiness stays gated until connect.
|
|
193
|
-
try {
|
|
194
|
-
refreshActiveInstance(INSTANCE_ID);
|
|
195
|
-
} catch (e) {
|
|
196
|
-
const code = e?.code;
|
|
197
|
-
const transient =
|
|
198
|
-
code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOENT";
|
|
199
|
-
if (!transient) throw e;
|
|
200
|
-
try {
|
|
201
|
-
process.stderr.write(
|
|
202
|
-
`mixdog channels: refreshActiveInstance at startup failed (non-fatal, ${code}): ${e instanceof Error ? e.message : String(e)}\n`,
|
|
203
|
-
);
|
|
204
|
-
} catch {}
|
|
205
|
-
}
|
|
206
|
-
startCliWorker();
|
|
207
|
-
}
|
|
208
|
-
const INSTRUCTIONS = "";
|
|
209
|
-
|
|
210
|
-
// ── Parent notification helper ───────────────────────────────────────
|
|
211
|
-
// This worker has no MCP transport of its own. All notifications flow
|
|
212
|
-
// through IPC to the parent (server.mjs), which owns the single connected
|
|
213
|
-
// MCP `Server` instance. The parent's IPC message handler translates
|
|
214
|
-
// `{type:'notify', method, params}` into `server.notification({method, params})`.
|
|
215
|
-
//
|
|
216
|
-
// Before v0.6.7 the worker had its own orphan `Server` instance that was
|
|
217
|
-
// never `connect()`ed to any transport, so `.notification()` silently
|
|
218
|
-
// threw 'Not connected' inside the SDK and every call was dropped by an
|
|
219
|
-
// outer `.catch(() => {})`. That regression is what this path replaces.
|
|
220
|
-
const {
|
|
221
|
-
sendNotifyToParent,
|
|
222
|
-
callMemoryAction,
|
|
223
|
-
handleMemoryCallResponse,
|
|
224
|
-
} = createParentBridge({ getInstanceId: () => INSTANCE_ID });
|
|
225
|
-
let channelBridgeActive = false;
|
|
226
|
-
function writeBridgeState(active) {
|
|
227
|
-
try {
|
|
228
|
-
const stateFile = path.join(os.tmpdir(), "mixdog", "bridge-state.json");
|
|
229
|
-
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
230
|
-
fs.writeFileSync(stateFile, JSON.stringify({ active, ts: Date.now() }));
|
|
231
|
-
} catch {
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
function isChannelBridgeActive() {
|
|
235
|
-
return channelBridgeActive;
|
|
236
|
-
}
|
|
237
|
-
let typingChannelId = null;
|
|
238
|
-
const pendingSetup = new PendingInteractionStore();
|
|
239
|
-
function startServerTyping(channelId) {
|
|
240
|
-
if (typingChannelId && typingChannelId !== channelId) {
|
|
241
|
-
backend.stopTyping(typingChannelId);
|
|
242
|
-
}
|
|
243
|
-
typingChannelId = channelId;
|
|
244
|
-
backend.startTyping(channelId);
|
|
245
|
-
}
|
|
246
|
-
function stopServerTyping() {
|
|
247
|
-
if (typingChannelId) {
|
|
248
|
-
backend.stopTyping(typingChannelId);
|
|
249
|
-
typingChannelId = null;
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
|
|
253
|
-
const TURN_END_BASENAME = path.basename(TURN_END_FILE);
|
|
254
|
-
const TURN_END_DIR = path.dirname(TURN_END_FILE);
|
|
255
|
-
let turnEndWatcher = null;
|
|
256
|
-
// Config hot-reload watcher (installed by start(); torn down by stop()).
|
|
257
|
-
let _configWatcher = null;
|
|
258
|
-
let _reloadDebounce = null;
|
|
259
|
-
if (!_isWorkerMode) {
|
|
260
|
-
removeFileIfExists(TURN_END_FILE);
|
|
261
|
-
turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
|
|
262
|
-
if (filename !== TURN_END_BASENAME) return;
|
|
263
|
-
try {
|
|
264
|
-
const stat = fs.statSync(TURN_END_FILE);
|
|
265
|
-
if (stat.size > 0) {
|
|
266
|
-
stopServerTyping();
|
|
267
|
-
await forwarder.forwardFinalText();
|
|
268
|
-
removeFileIfExists(TURN_END_FILE);
|
|
269
|
-
}
|
|
270
|
-
} catch {
|
|
271
|
-
}
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
const STATUS_FILE = getStatusPath(INSTANCE_ID);
|
|
275
|
-
const statusState = new JsonStateFile(STATUS_FILE, {});
|
|
276
|
-
statusState.ensure();
|
|
277
|
-
function sessionIdFromTranscriptPath(transcriptPath) {
|
|
278
|
-
const base = path.basename(transcriptPath);
|
|
279
|
-
return base.endsWith(".jsonl") ? base.slice(0, -6) : "";
|
|
280
|
-
}
|
|
281
|
-
function getPersistedTranscriptPath() {
|
|
282
|
-
const state = statusState.read();
|
|
283
|
-
if (typeof state.transcriptPath === "string" && state.transcriptPath) return state.transcriptPath;
|
|
284
|
-
return readActiveInstance()?.transcriptPath ?? "";
|
|
285
|
-
}
|
|
286
|
-
function pickUsableTranscriptPath(bound, previousPath) {
|
|
287
|
-
if (bound?.exists) return bound.transcriptPath;
|
|
288
|
-
if (!previousPath) return "";
|
|
289
|
-
if (!bound?.sessionId) return previousPath;
|
|
290
|
-
return sessionIdFromTranscriptPath(previousPath) === bound.sessionId ? previousPath : "";
|
|
291
|
-
}
|
|
292
|
-
const forwarder = new OutputForwarder({
|
|
293
|
-
send: async (ch, text, opts) => {
|
|
294
|
-
if (!channelBridgeActive) {
|
|
295
|
-
throw new Error("send() called while channel bridge is inactive");
|
|
296
|
-
}
|
|
297
|
-
await backend.sendMessage(ch, text, opts);
|
|
298
|
-
},
|
|
299
|
-
formatOutgoing: (text) => backend.formatOutgoing ? backend.formatOutgoing(text) : text,
|
|
300
|
-
recordAssistantTurn: async () => {
|
|
301
|
-
},
|
|
302
|
-
react: (ch, mid, emoji) => {
|
|
303
|
-
if (!channelBridgeActive) return Promise.resolve();
|
|
304
|
-
return backend.react(ch, mid, emoji);
|
|
305
|
-
},
|
|
306
|
-
removeReaction: (ch, mid, emoji) => {
|
|
307
|
-
if (!channelBridgeActive) return Promise.resolve();
|
|
308
|
-
return backend.removeReaction(ch, mid, emoji);
|
|
309
|
-
},
|
|
310
|
-
// Watchdog backstop: force the backend to tear down + rebuild a wedged
|
|
311
|
-
// client so an over-budget send fails fast and the queue releases.
|
|
312
|
-
resetBackend: async () => {
|
|
313
|
-
if (typeof backend?._resetClient === "function") await backend._resetClient();
|
|
314
|
-
}
|
|
315
|
-
}, statusState);
|
|
316
|
-
forwarder.setOnIdle(() => {
|
|
317
|
-
stopServerTyping();
|
|
318
|
-
void forwarder.forwardFinalText();
|
|
319
|
-
});
|
|
320
|
-
// Wire the forwarder ownership probe unconditionally. wireEventQueueHandlers()
|
|
321
|
-
// also sets this, but that path only runs when the event pipeline starts
|
|
322
|
-
// (webhook enabled or event rules present). Without an event pipeline the
|
|
323
|
-
// forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
|
|
324
|
-
// non-owner process forward transcript output (duplicate Discord sends).
|
|
325
|
-
// The closure reads bridgeRuntimeConnected at call time as a fast-path AND;
|
|
326
|
-
// bridgeRuntimeConnected alone can go stale (e.g. this process lost the seat
|
|
327
|
-
// but has not yet observed it), so currentOwnerState().owned is re-read at
|
|
328
|
-
// probe time as the source of truth for ownership.
|
|
329
|
-
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
330
|
-
function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
331
|
-
if (!transcriptPath) return;
|
|
332
|
-
forwarder.setContext(channelId, transcriptPath, {
|
|
333
|
-
replayFromStart: options.replayFromStart,
|
|
334
|
-
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
335
|
-
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
336
|
-
});
|
|
337
|
-
const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
|
|
338
|
-
forwarder.startWatch();
|
|
339
|
-
void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
|
|
340
|
-
// Opportunistic drain: an ingest that had to buffer (memory port not yet
|
|
341
|
-
// published) leaves entry-/ingest- files behind; kick the drainer so they
|
|
342
|
-
// replay as soon as the port appears, without waiting for the periodic tick.
|
|
343
|
-
void memoryDrainBuffer().catch(() => {});
|
|
344
|
-
// onlyIfOwned: binds happen on the owned path, but discovery/poll loops
|
|
345
|
-
// above can outlast an ownership handoff — never overwrite a newer owner.
|
|
346
|
-
refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
|
|
347
|
-
if (options.persistStatus !== false) {
|
|
348
|
-
statusState.update((state) => {
|
|
349
|
-
const wasSameTranscript = typeof state.transcriptPath === "string"
|
|
350
|
-
&& state.transcriptPath
|
|
351
|
-
&& sameResolvedPath(state.transcriptPath, boundTranscriptPath);
|
|
352
|
-
const prevSentCount = Number(state.sentCount ?? 0);
|
|
353
|
-
const nextSentCount = Number(forwarder.sentCount ?? 0);
|
|
354
|
-
state.channelId = channelId;
|
|
355
|
-
state.transcriptPath = boundTranscriptPath;
|
|
356
|
-
state.lastFileSize = forwarder.lastFileSize;
|
|
357
|
-
if (wasSameTranscript) {
|
|
358
|
-
state.sentCount = Math.max(
|
|
359
|
-
Number.isFinite(prevSentCount) ? prevSentCount : 0,
|
|
360
|
-
Number.isFinite(nextSentCount) ? nextSentCount : 0,
|
|
361
|
-
);
|
|
362
|
-
if (forwarder.lastHash) state.lastSentHash = forwarder.lastHash;
|
|
363
|
-
else if (typeof state.lastSentHash !== "string") state.lastSentHash = "";
|
|
364
|
-
if (!Number.isFinite(Number(state.lastSentTime))) state.lastSentTime = 0;
|
|
365
|
-
if (typeof state.sessionIdle !== "boolean") state.sessionIdle = false;
|
|
366
|
-
} else {
|
|
367
|
-
state.sentCount = forwarder.sentCount;
|
|
368
|
-
state.lastSentHash = forwarder.lastHash;
|
|
369
|
-
state.lastSentTime = 0;
|
|
370
|
-
state.sessionIdle = false;
|
|
371
|
-
}
|
|
372
|
-
if (options.cwd !== undefined) state.sessionCwd = options.cwd ?? null;
|
|
373
|
-
else if (!wasSameTranscript) state.sessionCwd = null;
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
// ── Pending-transcript re-arm ────────────────────────────────────────
|
|
378
|
-
// fs.watch cannot watch a file that does not exist yet, so when we bind a
|
|
379
|
-
// session's known-but-not-yet-written transcript path, OutputForwarder.
|
|
380
|
-
// startWatch() silently fails (watch.start.catch) and the file's later
|
|
381
|
-
// creation is never observed. This bounded poll bridges that gap: once the
|
|
382
|
-
// transcript-writer creates the file, we install the watch and forward the
|
|
383
|
-
// backlog. It self-cancels on success, on timeout, and whenever a fresh
|
|
384
|
-
// (re)bind supersedes it — so no timers leak and no double-forward occurs.
|
|
385
|
-
let _pendingRearmTimer = null;
|
|
386
|
-
const PENDING_REARM_INTERVAL_MS = 250;
|
|
387
|
-
const PENDING_REARM_MAX_MS = 60_000;
|
|
388
|
-
function cancelPendingTranscriptRearm() {
|
|
389
|
-
if (_pendingRearmTimer) {
|
|
390
|
-
clearTimeout(_pendingRearmTimer);
|
|
391
|
-
_pendingRearmTimer = null;
|
|
392
|
-
dropTrace("rebind.rearm.cancel");
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
function schedulePendingTranscriptRearm(channelId, boundPath) {
|
|
396
|
-
cancelPendingTranscriptRearm();
|
|
397
|
-
if (!boundPath) return;
|
|
398
|
-
const deadline = Date.now() + PENDING_REARM_MAX_MS;
|
|
399
|
-
dropTrace("rebind.rearm.schedule", { channelId, path: boundPath });
|
|
400
|
-
const tick = () => {
|
|
401
|
-
_pendingRearmTimer = null;
|
|
402
|
-
// A different transcript got bound in the meantime — abandon this poll.
|
|
403
|
-
if (forwarder.transcriptPath !== boundPath) {
|
|
404
|
-
dropTrace("rebind.rearm.superseded", { path: boundPath, now: forwarder.transcriptPath || "(none)" });
|
|
405
|
-
return;
|
|
406
|
-
}
|
|
407
|
-
// Ownership may have been lost (bridge deactivated / superseded owner)
|
|
408
|
-
// while this poll was pending. Do not reinstall the fs.watch handle after
|
|
409
|
-
// teardown; startWatch() is not owner-gated so guard it here.
|
|
410
|
-
if (!bridgeRuntimeConnected) {
|
|
411
|
-
dropTrace("rebind.rearm.not-owner", { path: boundPath });
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
if (fs.existsSync(boundPath)) {
|
|
415
|
-
dropTrace("rebind.rearm.fire", { channelId, path: boundPath });
|
|
416
|
-
forwarder.startWatch();
|
|
417
|
-
void forwarder.forwardNewText().catch((err) => {
|
|
418
|
-
try { process.stderr.write(`mixdog: rearm forwardNewText rejection: ${err?.stack || err}\n`); } catch {}
|
|
419
|
-
});
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
if (Date.now() >= deadline) {
|
|
423
|
-
dropTrace("rebind.rearm.timeout", { channelId, path: boundPath });
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
_pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
|
|
427
|
-
_pendingRearmTimer?.unref?.();
|
|
428
|
-
};
|
|
429
|
-
_pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
|
|
430
|
-
_pendingRearmTimer?.unref?.();
|
|
431
|
-
}
|
|
432
|
-
async function rebindTranscriptContext(channelId, options = {}) {
|
|
433
|
-
const previousPath = options.previousPath ?? "";
|
|
434
|
-
const mode = options.mode ?? "same";
|
|
435
|
-
// A new (re)bind supersedes any pending re-arm poll left over from a prior
|
|
436
|
-
// bind of a not-yet-existing transcript, so we never leak timers or
|
|
437
|
-
// double-forward once the fresh bind takes over.
|
|
438
|
-
cancelPendingTranscriptRearm();
|
|
439
|
-
const explicitTranscriptPath = typeof options.transcriptPath === "string" ? options.transcriptPath.trim() : "";
|
|
440
|
-
if (explicitTranscriptPath) {
|
|
441
|
-
let explicitExists = false;
|
|
442
|
-
try {
|
|
443
|
-
explicitExists = fs.statSync(explicitTranscriptPath).isFile();
|
|
444
|
-
} catch {
|
|
445
|
-
explicitExists = false;
|
|
446
|
-
}
|
|
447
|
-
if (explicitExists) {
|
|
448
|
-
applyTranscriptBinding(channelId, explicitTranscriptPath, {
|
|
449
|
-
replayFromStart: Boolean(options.catchUp),
|
|
450
|
-
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
451
|
-
persistStatus: options.persistStatus,
|
|
452
|
-
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
453
|
-
});
|
|
454
|
-
if (options.catchUp || options.catchUpFromPersisted) {
|
|
455
|
-
await forwarder.forwardNewText();
|
|
456
|
-
}
|
|
457
|
-
return explicitTranscriptPath;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
let sawPendingTranscript = false;
|
|
461
|
-
let pendingSessionId = "";
|
|
462
|
-
// Distinct from sawPendingTranscript/pendingSessionId (which only track the
|
|
463
|
-
// sessionId): remember the FULL not-yet-on-disk candidate — its concrete
|
|
464
|
-
// transcriptPath (from the session record) + cwd — so we can bind it after
|
|
465
|
-
// the loop even though the `.jsonl` does not exist yet. This breaks the
|
|
466
|
-
// chicken-and-egg deadlock where the first assistant reply is only written
|
|
467
|
-
// seconds after inbound time.
|
|
468
|
-
let pendingTranscriptPath = "";
|
|
469
|
-
let pendingTranscriptCwd = null;
|
|
470
|
-
const maxAttempts = Number.isFinite(options.maxAttempts) ? Math.max(1, Math.floor(options.maxAttempts)) : 30;
|
|
471
|
-
const pollMs = Number.isFinite(options.pollMs) ? Math.max(25, Math.floor(options.pollMs)) : 150;
|
|
472
|
-
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
473
|
-
const bound = discoverSessionBoundTranscript();
|
|
474
|
-
if (bound?.exists) {
|
|
475
|
-
const acceptable = mode === "same" || !previousPath || bound.transcriptPath !== previousPath;
|
|
476
|
-
if (acceptable) {
|
|
477
|
-
const replayFromStart = Boolean(
|
|
478
|
-
options.catchUp && !previousPath && sawPendingTranscript && pendingSessionId === bound.sessionId
|
|
479
|
-
);
|
|
480
|
-
applyTranscriptBinding(channelId, bound.transcriptPath, {
|
|
481
|
-
replayFromStart,
|
|
482
|
-
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
483
|
-
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
484
|
-
persistStatus: options.persistStatus,
|
|
485
|
-
cwd: bound.sessionCwd,
|
|
486
|
-
});
|
|
487
|
-
if (replayFromStart || options.catchUpFromPersisted) {
|
|
488
|
-
await forwarder.forwardNewText();
|
|
489
|
-
}
|
|
490
|
-
return bound.transcriptPath;
|
|
491
|
-
}
|
|
492
|
-
} else if (bound?.sessionId) {
|
|
493
|
-
sawPendingTranscript = true;
|
|
494
|
-
pendingSessionId = bound.sessionId;
|
|
495
|
-
if (bound.transcriptPath) {
|
|
496
|
-
pendingTranscriptPath = bound.transcriptPath;
|
|
497
|
-
pendingTranscriptCwd = bound.sessionCwd ?? null;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
501
|
-
}
|
|
502
|
-
// No existing transcript surfaced during the loop, but the session record
|
|
503
|
-
// named a concrete transcript path that simply is not on disk yet. Bind it
|
|
504
|
-
// now: applyTranscriptBinding persists state.transcriptPath (so the
|
|
505
|
-
// getPersistedTranscriptPath() fallback works for future rebinds) and calls
|
|
506
|
-
// forwarder.startWatch(). Because fs.watch cannot watch a missing file, we
|
|
507
|
-
// also schedule a bounded re-arm poll that installs the watch + catches up
|
|
508
|
-
// once the transcript-writer creates the file.
|
|
509
|
-
if (pendingTranscriptPath) {
|
|
510
|
-
// Same wrong-session guard as the exists:true path: refuse to bind a
|
|
511
|
-
// candidate that conflicts with an explicit previousPath when this is a
|
|
512
|
-
// switch (mode!=="same").
|
|
513
|
-
const acceptable = mode === "same" || !previousPath || pendingTranscriptPath !== previousPath;
|
|
514
|
-
if (acceptable) {
|
|
515
|
-
dropTrace("rebind.pending.bind", { channelId, path: pendingTranscriptPath, sessionId: pendingSessionId });
|
|
516
|
-
// If the persisted cursor belongs to THIS transcript, resume from it;
|
|
517
|
-
// otherwise this is a freshly-discovered session transcript that was
|
|
518
|
-
// never bound before (the chicken-and-egg case), so forward from the
|
|
519
|
-
// start of file. Binding with catchUpFromPersisted against a
|
|
520
|
-
// non-matching persisted path would set the read cursor to EOF and
|
|
521
|
-
// silently skip the first reply (output-forwarder setContext).
|
|
522
|
-
// Resume from the persisted cursor only when it belongs to THIS
|
|
523
|
-
// transcript; otherwise forward from the start of file (see comment
|
|
524
|
-
// above). sameResolvedPath handles Windows case-insensitive paths.
|
|
525
|
-
const samePersisted = sameResolvedPath(getPersistedTranscriptPath(), pendingTranscriptPath);
|
|
526
|
-
applyTranscriptBinding(channelId, pendingTranscriptPath, {
|
|
527
|
-
replayFromStart: !samePersisted,
|
|
528
|
-
catchUpFromPersisted: samePersisted,
|
|
529
|
-
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
530
|
-
cwd: pendingTranscriptCwd,
|
|
531
|
-
persistStatus: options.persistStatus,
|
|
532
|
-
});
|
|
533
|
-
const boundPath = forwarder.transcriptPath || pendingTranscriptPath;
|
|
534
|
-
if (fs.existsSync(boundPath)) {
|
|
535
|
-
// Raced: file appeared between discovery and bind — forward immediately.
|
|
536
|
-
await forwarder.forwardNewText();
|
|
537
|
-
} else {
|
|
538
|
-
schedulePendingTranscriptRearm(channelId, boundPath);
|
|
539
|
-
}
|
|
540
|
-
process.stderr.write(`mixdog: rebind pending: bound not-yet-existing transcript ${boundPath}\n`);
|
|
541
|
-
return pendingTranscriptPath;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
if (previousPath && options.fallbackPrevious !== false) {
|
|
545
|
-
applyTranscriptBinding(channelId, previousPath, {
|
|
546
|
-
catchUpFromPersisted: true,
|
|
547
|
-
recoverUnsyncedTail: options.recoverUnsyncedTail,
|
|
548
|
-
cwd: statusState.read().sessionCwd
|
|
549
|
-
});
|
|
550
|
-
if (fs.existsSync(previousPath)) {
|
|
551
|
-
await forwarder.forwardNewText();
|
|
552
|
-
} else {
|
|
553
|
-
// Same not-yet-on-disk situation as the pending branch: arm a poll so
|
|
554
|
-
// forwarding starts when the file is created.
|
|
555
|
-
schedulePendingTranscriptRearm(channelId, forwarder.transcriptPath || previousPath);
|
|
556
|
-
}
|
|
557
|
-
process.stderr.write(`mixdog: rebind fallback: bound previous transcript ${previousPath}\n`);
|
|
558
|
-
return previousPath;
|
|
559
|
-
}
|
|
560
|
-
process.stderr.write(`mixdog: rebind failed: no transcript found and no previous path to fall back to\n`);
|
|
561
|
-
return "";
|
|
562
|
-
}
|
|
563
|
-
const scheduler = new Scheduler(
|
|
564
|
-
config.nonInteractive ?? [],
|
|
565
|
-
config.interactive ?? [],
|
|
566
|
-
// Single resolved main-channel id used for the schedule `channel` flag.
|
|
567
|
-
config.channelId
|
|
568
|
-
);
|
|
569
|
-
// Register the pending-dispatch probe so the scheduler treats an in-flight
|
|
570
|
-
// bridge dispatch as "active" regardless of user-inbound silence.
|
|
571
|
-
scheduler.setPendingCheck(() => {
|
|
572
|
-
try {
|
|
573
|
-
return dispatchHasPending(DATA_DIR);
|
|
574
|
-
} catch {
|
|
575
|
-
return false;
|
|
576
|
-
}
|
|
577
|
-
});
|
|
578
|
-
// Bridge the orchestrator-side activity notifier into the scheduler so
|
|
579
|
-
// events like `addPending` can bump lastActivity without importing the
|
|
580
|
-
// scheduler instance directly (avoids module cycles).
|
|
581
|
-
setActivityBusListener(() => scheduler.noteActivity());
|
|
582
|
-
let webhookServer = null;
|
|
583
|
-
let eventPipeline = null;
|
|
584
|
-
let bridgeRuntimeConnected = false;
|
|
585
|
-
let bridgeRuntimeStarting = false;
|
|
586
|
-
// Stop-requested signal: set by stopOwnedRuntime() when it runs during the
|
|
587
|
-
// startOwnedRuntime() in-flight window (bridgeRuntimeStarting=true). Checked
|
|
588
|
-
// by startOwnedRuntime() right after backend.connect() resolves so the
|
|
589
|
-
// in-flight start does not revive owner state after the stop already tore
|
|
590
|
-
// the partial-start state down.
|
|
591
|
-
let _ownedRuntimeStopRequested = false;
|
|
592
|
-
let bridgeOwnershipRefreshInFlight = null;
|
|
593
|
-
let bridgeOwnershipTimer = null;
|
|
594
|
-
const ACTIVE_OWNER_STALE_MS = 1e4;
|
|
595
|
-
// Owner gating here is multi-process runtime coordination: only the active
|
|
596
|
-
// bindingReady gates all send paths until the boot-time refreshBridgeOwnership
|
|
597
|
-
// ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
|
|
598
|
-
// emissions fired within the first ~few hundred ms after restart drop because
|
|
599
|
-
// the Discord backend binding has not yet been established.
|
|
600
|
-
let bindingReadyStatus = "pending";
|
|
601
|
-
let _bindingReadyResolve;
|
|
602
|
-
const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
|
|
603
|
-
let _memoryDrainTimer = null;
|
|
604
|
-
dropTrace("bindingReady.create", { status: bindingReadyStatus });
|
|
605
|
-
// ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
|
|
606
|
-
// Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
|
|
607
|
-
// dedup; bound to live identity + active-instance primitives.
|
|
608
|
-
const {
|
|
609
|
-
logOwnership,
|
|
610
|
-
currentOwnerState,
|
|
611
|
-
getBridgeOwnershipSnapshot,
|
|
612
|
-
claimBridgeOwnership,
|
|
613
|
-
startOwnerHeartbeat,
|
|
614
|
-
stopOwnerHeartbeat,
|
|
615
|
-
} = createOwnerHeartbeat({
|
|
616
|
-
getInstanceId: () => INSTANCE_ID,
|
|
617
|
-
readActiveInstance,
|
|
618
|
-
refreshActiveInstance,
|
|
619
|
-
});
|
|
620
|
-
async function bindPersistedTranscriptIfAny() {
|
|
621
|
-
// Main-channel fallback requires channelBridgeActive (set in start() before
|
|
622
|
-
// refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
|
|
623
|
-
// Resolve channelId first from persisted status; fall back to the most
|
|
624
|
-
// recent status-*.json snapshot, then to the configured main channel when
|
|
625
|
-
// the bridge is active. No exists-gate here — once we have a channelId,
|
|
626
|
-
// hand off to rebindTranscriptContext(), which owns the 30-attempt retry
|
|
627
|
-
// for transcripts that are not yet on disk at boot/activate time.
|
|
628
|
-
let currentStatus = statusState.read();
|
|
629
|
-
if (!currentStatus.channelId) {
|
|
630
|
-
try {
|
|
631
|
-
const files = fs.readdirSync(RUNTIME_ROOT).filter((f) => f.startsWith("status-") && f.endsWith(".json")).map((f) => {
|
|
632
|
-
const full = path.join(RUNTIME_ROOT, f);
|
|
633
|
-
return { path: full, mtime: fs.statSync(full).mtimeMs };
|
|
634
|
-
}).sort((a, b) => b.mtime - a.mtime);
|
|
635
|
-
for (const { path: fp } of files) {
|
|
636
|
-
try {
|
|
637
|
-
const data = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
638
|
-
if (data.channelId) {
|
|
639
|
-
statusState.update((state) => {
|
|
640
|
-
Object.assign(state, data);
|
|
641
|
-
});
|
|
642
|
-
currentStatus = statusState.read();
|
|
643
|
-
process.stderr.write(`mixdog: restored status from ${fp}
|
|
644
|
-
`);
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
|
-
} catch {
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
} catch {
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
if (!currentStatus.channelId && channelBridgeActive) {
|
|
654
|
-
const mainId = config.channelId;
|
|
655
|
-
if (mainId) {
|
|
656
|
-
statusState.update((state) => {
|
|
657
|
-
state.channelId = mainId;
|
|
658
|
-
});
|
|
659
|
-
currentStatus = statusState.read();
|
|
660
|
-
process.stderr.write(`mixdog: auto-bound to main channel ${mainId}
|
|
661
|
-
`);
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
if (!currentStatus.channelId) return;
|
|
665
|
-
const bound = await rebindTranscriptContext(currentStatus.channelId, {
|
|
666
|
-
previousPath: getPersistedTranscriptPath(),
|
|
667
|
-
persistStatus: true,
|
|
668
|
-
catchUpFromPersisted: true,
|
|
669
|
-
recoverUnsyncedTail: true,
|
|
670
|
-
});
|
|
671
|
-
if (bound) {
|
|
672
|
-
process.stderr.write(`mixdog: initial transcript bind: ${bound}
|
|
673
|
-
`);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
function shouldStartEventPipelineRuntime() {
|
|
677
|
-
return config.webhook?.enabled === true || (Array.isArray(config.events?.rules) && config.events.rules.length > 0);
|
|
678
|
-
}
|
|
679
|
-
function ensureEventPipelineRuntime() {
|
|
680
|
-
if (!eventPipeline) {
|
|
681
|
-
eventPipeline = new EventPipeline(config.events, config.channelId);
|
|
682
|
-
wireEventQueueHandlers(eventPipeline.getQueue());
|
|
683
|
-
}
|
|
684
|
-
return eventPipeline;
|
|
685
|
-
}
|
|
686
|
-
function ensureWebhookServerRuntime() {
|
|
687
|
-
if (!webhookServer) {
|
|
688
|
-
webhookServer = new WebhookServer(config.webhook);
|
|
689
|
-
}
|
|
690
|
-
wireWebhookHandlers();
|
|
691
|
-
return webhookServer;
|
|
692
|
-
}
|
|
693
|
-
async function stopWebhookAndEventRuntime() {
|
|
694
|
-
if (webhookServer) {
|
|
695
|
-
await webhookServer.stop();
|
|
696
|
-
webhookServer = null;
|
|
697
|
-
}
|
|
698
|
-
if (eventPipeline) {
|
|
699
|
-
eventPipeline.stop();
|
|
700
|
-
eventPipeline = null;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
|
|
704
|
-
if (shouldStartEventPipelineRuntime()) {
|
|
705
|
-
const pipeline = ensureEventPipelineRuntime();
|
|
706
|
-
if (reload) {
|
|
707
|
-
pipeline.reloadConfig(config.events, config.channelId);
|
|
708
|
-
wireEventQueueHandlers(pipeline.getQueue());
|
|
709
|
-
}
|
|
710
|
-
pipeline.start();
|
|
711
|
-
} else if (eventPipeline) {
|
|
712
|
-
eventPipeline.stop();
|
|
713
|
-
eventPipeline = null;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
if (config.webhook?.enabled === true) {
|
|
717
|
-
const server = ensureWebhookServerRuntime();
|
|
718
|
-
if (reload) {
|
|
719
|
-
// server.reloadConfig is async (it awaits the current server's
|
|
720
|
-
// close() before re-listening). Chain start() onto its resolution
|
|
721
|
-
// so we don't race the bound port — calling start() synchronously
|
|
722
|
-
// here would re-listen before close() finishes and surface
|
|
723
|
-
// EADDRINUSE on the same port.
|
|
724
|
-
server.reloadConfig(config.webhook, {
|
|
725
|
-
autoStart: false
|
|
726
|
-
}).then(() => {
|
|
727
|
-
// A stopWebhookAndEventRuntime() / deactivate landing during the async
|
|
728
|
-
// close()+reload window nulls out webhookServer (and webhook.enabled may
|
|
729
|
-
// have flipped off). Without this guard the resolved continuation would
|
|
730
|
-
// re-listen and resurrect an orphan listener that no teardown tracks.
|
|
731
|
-
if (webhookServer !== server || config.webhook?.enabled !== true) {
|
|
732
|
-
try { server.stop(); } catch {}
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
wireWebhookHandlers();
|
|
736
|
-
server.start();
|
|
737
|
-
}).catch((err) => {
|
|
738
|
-
process.stderr.write(`mixdog channels: webhook reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
739
|
-
});
|
|
740
|
-
} else {
|
|
741
|
-
server.start();
|
|
742
|
-
}
|
|
743
|
-
} else if (webhookServer) {
|
|
744
|
-
webhookServer.stop();
|
|
745
|
-
webhookServer = null;
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
let _ownedRuntimeSelfHealing = false;
|
|
749
|
-
// Explicit restore path for an already-connected owner. The 3s ownership tick
|
|
750
|
-
// must not run this implicitly: re-binding status on every tick can move the
|
|
751
|
-
// transcript cursor to EOF, and probing the gateway during Discord's own
|
|
752
|
-
// reconnect window can reset a healthy reconnect loop. Keep this for explicit
|
|
753
|
-
// activation/reload recovery only.
|
|
754
|
-
async function selfHealOwnedRuntime(options = {}) {
|
|
755
|
-
const shouldRestoreBinding = options.restoreBinding === true;
|
|
756
|
-
const shouldResetBackend = options.resetBackend === true;
|
|
757
|
-
if (!shouldRestoreBinding && !shouldResetBackend) return;
|
|
758
|
-
if (_ownedRuntimeSelfHealing) return;
|
|
759
|
-
_ownedRuntimeSelfHealing = true;
|
|
760
|
-
try {
|
|
761
|
-
if (shouldRestoreBinding) {
|
|
762
|
-
await bindPersistedTranscriptIfAny().catch((e) => {
|
|
763
|
-
process.stderr.write(`mixdog: self-heal bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
if (shouldResetBackend && typeof backend?._resetClient === "function") {
|
|
767
|
-
await backend._resetClient().catch((e) => {
|
|
768
|
-
process.stderr.write(`mixdog: self-heal backend reset failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
// Nudge the forwarder to drain anything the rebind/reconnect surfaced.
|
|
772
|
-
void forwarder.forwardNewText().catch(() => {});
|
|
773
|
-
} finally {
|
|
774
|
-
_ownedRuntimeSelfHealing = false;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
async function startOwnedRuntime(options = {}) {
|
|
778
|
-
if (bridgeRuntimeConnected) {
|
|
779
|
-
if (!bridgeRuntimeStarting) await selfHealOwnedRuntime(options);
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
if (bridgeRuntimeStarting) return;
|
|
783
|
-
if (!channelBridgeActive) return;
|
|
784
|
-
bridgeRuntimeStarting = true;
|
|
785
|
-
_ownedRuntimeStopRequested = false;
|
|
786
|
-
// Capture the backend instance that THIS start operation will connect. A
|
|
787
|
-
// reloadRuntimeConfig() hot-swap can replace the global `backend` while this
|
|
788
|
-
// start is still awaiting connect(); using the captured instance for both
|
|
789
|
-
// connect() and the bail-path disconnect() guarantees we tear down the
|
|
790
|
-
// backend WE started (not the freshly-swapped one), closing the
|
|
791
|
-
// both-backends-live window.
|
|
792
|
-
const startingBackend = backend;
|
|
793
|
-
const claimAfterReady = options.claimAfterReady === true;
|
|
794
|
-
// Single-holder correctness: the seat is claimed BEFORE backend.connect(),
|
|
795
|
-
// never after. The old make-before-break (claim-after-ready) boot left two
|
|
796
|
-
// gateways connected and contending during the multi-second connect window;
|
|
797
|
-
// claiming first makes the previous owner observe owned=false on its next
|
|
798
|
-
// tick and drain+disconnect, so at most one gateway ever serves.
|
|
799
|
-
// - boot (claimAfterReady): last-wins acquire — this new remote session
|
|
800
|
-
// takes the seat outright.
|
|
801
|
-
// - refresh/owned-path: CAS onlyIfOwned — abort fast if the seat moved.
|
|
802
|
-
// backendReady=false marks the partial state until backend.connect() succeeds.
|
|
803
|
-
// Wrapped so ANY throw in the pre-connect claim (lock contention/error)
|
|
804
|
-
// resets bridgeRuntimeStarting — otherwise a transient lock error would
|
|
805
|
-
// leave it stuck true and permanently block every future ownership attempt.
|
|
806
|
-
try {
|
|
807
|
-
if (claimAfterReady) {
|
|
808
|
-
refreshActiveInstance(INSTANCE_ID, { backendReady: false });
|
|
809
|
-
logOwnership("boot claim (pre-connect, last-wins)");
|
|
810
|
-
} else {
|
|
811
|
-
// Try-once (timeoutMs:0): a refresh-path re-acquire must never block on
|
|
812
|
-
// the active-instance lock. Contention throws → caught below and treated
|
|
813
|
-
// as "seat busy, abort this attempt"; the next 3s tick retries.
|
|
814
|
-
const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true, timeoutMs: 0 });
|
|
815
|
-
// A successful CAS write always sets instanceId to ours; any other result
|
|
816
|
-
// (aborted write returning the stale/foreign/missing prior state) means the
|
|
817
|
-
// seat was not ours to claim — abort here rather than relying on the timer.
|
|
818
|
-
if (casResult?.instanceId !== INSTANCE_ID) {
|
|
819
|
-
bridgeRuntimeStarting = false;
|
|
820
|
-
return;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
// A newer session can claim between our write and this read. If we no longer
|
|
824
|
-
// own the seat, abort fast WITHOUT starting heartbeat/backend/scheduler/
|
|
825
|
-
// webhook/ngrok — ancillary runtime starts only past a confirmed-owned claim.
|
|
826
|
-
if (!currentOwnerState().owned) {
|
|
827
|
-
bridgeRuntimeStarting = false;
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
} catch (e) {
|
|
831
|
-
bridgeRuntimeStarting = false;
|
|
832
|
-
process.stderr.write(`mixdog: pre-connect seat claim aborted (${e instanceof Error ? e.message : String(e)})\n`);
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
// Arm ownership-loss detection BEFORE backend.connect() so a session that is
|
|
836
|
-
// superseded during the (multi-second) connect window is torn down: the 3s
|
|
837
|
-
// tick observes a newer owner and flips _ownedRuntimeStopRequested, and
|
|
838
|
-
// bailIfStopRequested below disconnects the backend WE connected.
|
|
839
|
-
armBridgeOwnershipTimer();
|
|
840
|
-
// Heartbeat is ownership-gated; safe to arm now that we hold the seat.
|
|
841
|
-
startOwnerHeartbeat();
|
|
842
|
-
// Periodic buffer drain: replays memory-buffer/entry-*/ingest-*.json once the
|
|
843
|
-
// memory service publishes its port. Idempotent + reentrancy-guarded inside
|
|
844
|
-
// drainBuffer(); unref'd so it never holds the worker open.
|
|
845
|
-
if (!_memoryDrainTimer) {
|
|
846
|
-
_memoryDrainTimer = setInterval(() => { void memoryDrainBuffer().catch(() => {}); }, 5e3);
|
|
847
|
-
_memoryDrainTimer.unref?.();
|
|
848
|
-
}
|
|
849
|
-
// Re-check after each post-connect await so a stopOwnedRuntime() landing
|
|
850
|
-
// mid-start cannot be overridden by the resuming start (scheduler/snapshot/
|
|
851
|
-
// webhook/binding launches below would revive owner state after stop).
|
|
852
|
-
// Idempotent: stop's sync teardown already ran; re-running disconnect +
|
|
853
|
-
// teardown is safe and covers both the pre-connected window (stop could
|
|
854
|
-
// not disconnect an in-flight backend) and the post-connected window
|
|
855
|
-
// (stop did disconnect; redo to be defensive).
|
|
856
|
-
const bailIfStopRequested = async () => {
|
|
857
|
-
if (!_ownedRuntimeStopRequested) return false;
|
|
858
|
-
try { await startingBackend.disconnect(); } catch {}
|
|
859
|
-
try { stopOwnerHeartbeat(); } catch {}
|
|
860
|
-
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
861
|
-
try { clearActiveInstance(INSTANCE_ID); } catch {}
|
|
862
|
-
bridgeRuntimeConnected = false;
|
|
863
|
-
_ownedRuntimeStopRequested = false;
|
|
864
|
-
return true;
|
|
865
|
-
};
|
|
866
|
-
const restoreBinding = options.restoreBinding !== false;
|
|
867
|
-
const bindPersistedTranscriptTask = restoreBinding
|
|
868
|
-
? bindPersistedTranscriptIfAny().catch((e) => {
|
|
869
|
-
process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
870
|
-
})
|
|
871
|
-
: null;
|
|
872
|
-
// Await backend.connect() so callers (and bindingReady) only resolve after
|
|
873
|
-
// the Discord binding is real. Previously this was fire-and-forget and
|
|
874
|
-
// refreshBridgeOwnership returned immediately, letting bindingReady fire
|
|
875
|
-
// before backend listeners were attached.
|
|
876
|
-
try {
|
|
877
|
-
await startingBackend.connect();
|
|
878
|
-
if (await bailIfStopRequested()) {
|
|
879
|
-
cancelPendingTranscriptRearm();
|
|
880
|
-
try { forwarder.stopWatch(); } catch {}
|
|
881
|
-
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
// Post-connect ownership confirm (CAS). The seat was claimed BEFORE
|
|
885
|
-
// connect; a newer session may have superseded us during the connect
|
|
886
|
-
// window (the 3s tick would already be tearing us down). onlyIfOwned:
|
|
887
|
-
// never re-steal here. If the CAS aborts we LOST the seat — disconnect the
|
|
888
|
-
// backend WE connected and mark the bridge runtime not connected (do NOT
|
|
889
|
-
// clear active-instance; the newer owner holds it).
|
|
890
|
-
let ownConfirm;
|
|
891
|
-
try {
|
|
892
|
-
ownConfirm = refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
|
|
893
|
-
} catch { ownConfirm = null; }
|
|
894
|
-
if (ownConfirm?.instanceId !== INSTANCE_ID) {
|
|
895
|
-
try { await startingBackend.disconnect(); } catch {}
|
|
896
|
-
try { stopOwnerHeartbeat(); } catch {}
|
|
897
|
-
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
898
|
-
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
899
|
-
bridgeRuntimeConnected = false;
|
|
900
|
-
cancelPendingTranscriptRearm();
|
|
901
|
-
try { forwarder.stopWatch(); } catch {}
|
|
902
|
-
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
903
|
-
notifyRemoteSuperseded();
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
bridgeRuntimeConnected = true;
|
|
907
|
-
if (claimAfterReady) {
|
|
908
|
-
// First confirmed ownership at boot — tell the parent it holds the seat
|
|
909
|
-
// exactly once (heartbeat/refresh re-pin ownership but never re-enter).
|
|
910
|
-
notifyRemoteAcquired();
|
|
911
|
-
}
|
|
912
|
-
// initProviders must complete before scheduler.start() — otherwise the
|
|
913
|
-
// scheduler's first fire can land before the registry is populated and
|
|
914
|
-
// return `Provider "<name>" not found or not enabled`. The previous
|
|
915
|
-
// fire-and-forget call let scheduler.start() race ahead of init.
|
|
916
|
-
try {
|
|
917
|
-
const agentCfg = loadAgentConfig();
|
|
918
|
-
await initProviders(agentCfg.providers || {});
|
|
919
|
-
} catch (e) {
|
|
920
|
-
process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
921
|
-
}
|
|
922
|
-
if (await bailIfStopRequested()) {
|
|
923
|
-
cancelPendingTranscriptRearm();
|
|
924
|
-
try { forwarder.stopWatch(); } catch {}
|
|
925
|
-
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
926
|
-
return;
|
|
927
|
-
}
|
|
928
|
-
scheduler.start();
|
|
929
|
-
startSnapshotWriter(scheduler);
|
|
930
|
-
syncOwnedWebhookAndEventRuntime();
|
|
931
|
-
if (restoreBinding) {
|
|
932
|
-
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
933
|
-
const pendingTranscriptPath = forwarder.transcriptPath;
|
|
934
|
-
if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
|
|
935
|
-
// Pre-connect bind may have armed rearm while !bridgeRuntimeConnected;
|
|
936
|
-
// the first tick then exits without rescheduling. Re-arm now that we own.
|
|
937
|
-
schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
|
|
938
|
-
} else {
|
|
939
|
-
void forwarder.forwardNewText().catch((err) => {
|
|
940
|
-
process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
941
|
-
});
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
process.stderr.write(`mixdog: running with ${backend.name} backend\n`);
|
|
945
|
-
logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
|
|
946
|
-
} catch (e) {
|
|
947
|
-
process.stderr.write(`mixdog: backend connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
948
|
-
cancelPendingTranscriptRearm();
|
|
949
|
-
try { forwarder.stopWatch(); } catch {}
|
|
950
|
-
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
951
|
-
// Roll back partial owner-side state advertised before connect() ran:
|
|
952
|
-
// heartbeat and active-instance entry.
|
|
953
|
-
try { stopOwnerHeartbeat(); } catch {}
|
|
954
|
-
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
955
|
-
try { clearActiveInstance(INSTANCE_ID); } catch {}
|
|
956
|
-
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
957
|
-
} finally {
|
|
958
|
-
bridgeRuntimeStarting = false;
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
async function stopOwnedRuntime(reason) {
|
|
962
|
-
// Cancel any pending transcript re-arm poll BEFORE the connected/starting
|
|
963
|
-
// early-return below. Otherwise a poll armed against a not-yet-existing
|
|
964
|
-
// transcript could fire after teardown and reinstall the fs.watch handle
|
|
965
|
-
// (startWatch is not owner-gated), leaking a live watcher past shutdown.
|
|
966
|
-
cancelPendingTranscriptRearm();
|
|
967
|
-
// startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
|
|
968
|
-
// claims channel locks BEFORE awaiting backend.connect(). If shutdown lands
|
|
969
|
-
// during that window (bridgeRuntimeStarting=true, bridgeRuntimeConnected
|
|
970
|
-
// still false) we still need to tear that partial state down — otherwise
|
|
971
|
-
// the port stays bound and active-instance.json stays stale.
|
|
972
|
-
if (!bridgeRuntimeConnected && !bridgeRuntimeStarting) return;
|
|
973
|
-
// If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
|
|
974
|
-
// startOwnedRuntime() to abort right after its backend.connect() resolves.
|
|
975
|
-
// Without this the in-flight start re-marks connected and re-launches
|
|
976
|
-
// scheduler/webhook/heartbeat after we tear them down here.
|
|
977
|
-
if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
|
|
978
|
-
const wasConnected = bridgeRuntimeConnected;
|
|
979
|
-
stopServerTyping();
|
|
980
|
-
// Release the transcript fs.watch handle plus the forwarder's debounce/retry
|
|
981
|
-
// timers on standby. Without this the watcher keeps firing scheduleWatchFlush
|
|
982
|
-
// and the drain/retry timers stay live after ownership is dropped, leaking a
|
|
983
|
-
// file handle + timers for the rest of the process lifetime.
|
|
984
|
-
try { forwarder.stopWatch(); } catch {}
|
|
985
|
-
stopOwnerHeartbeat();
|
|
986
|
-
if (_memoryDrainTimer) { clearInterval(_memoryDrainTimer); _memoryDrainTimer = null; }
|
|
987
|
-
scheduler.stop();
|
|
988
|
-
stopSnapshotWriter();
|
|
989
|
-
await stopWebhookAndEventRuntime();
|
|
990
|
-
releaseOwnedChannelLocks(INSTANCE_ID);
|
|
991
|
-
clearActiveInstance(INSTANCE_ID);
|
|
992
|
-
try {
|
|
993
|
-
// Only disconnect the backend when connect() actually completed; calling
|
|
994
|
-
// disconnect() mid-connect races the connect promise.
|
|
995
|
-
if (wasConnected) {
|
|
996
|
-
// Drain in-flight outbound sends before disconnecting so a handoff
|
|
997
|
-
// (owned=false observed → ownership lost) never cuts off a reply
|
|
998
|
-
// mid-delivery. Bounded inside drainPendingSends so a wedged send can
|
|
999
|
-
// not stall teardown — we still disconnect promptly.
|
|
1000
|
-
try { await backend.drainPendingSends?.(); } catch {}
|
|
1001
|
-
await backend.disconnect();
|
|
1002
|
-
}
|
|
1003
|
-
} finally {
|
|
1004
|
-
bridgeRuntimeConnected = false;
|
|
1005
|
-
logOwnership(`standby: ${reason}`);
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
function refreshBridgeOwnershipSafe(options = {}) {
|
|
1009
|
-
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
1010
|
-
}
|
|
1011
|
-
// Ownership-loss / re-acquire detection timer. Armed BEFORE backend.connect()
|
|
1012
|
-
// (from startOwnedRuntime) so a session superseded during the connect window is
|
|
1013
|
-
// torn down promptly, and re-armed idempotently on every start path.
|
|
1014
|
-
function armBridgeOwnershipTimer() {
|
|
1015
|
-
if (bridgeOwnershipTimer) return;
|
|
1016
|
-
bridgeOwnershipTimer = setInterval(() => {
|
|
1017
|
-
refreshBridgeOwnershipSafe();
|
|
1018
|
-
}, 3e3);
|
|
1019
|
-
bridgeOwnershipTimer.unref?.();
|
|
1020
|
-
}
|
|
1021
|
-
// Tell the parent session that this worker LOST the bridge seat to a newer
|
|
1022
|
-
// remote session (last-wins). The parent flips its remote mode OFF entirely —
|
|
1023
|
-
// exactly one session holds remote; losers fully release, no handover.
|
|
1024
|
-
function notifyRemoteSuperseded() {
|
|
1025
|
-
if (!process.send) return;
|
|
1026
|
-
try {
|
|
1027
|
-
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
|
|
1028
|
-
} catch {}
|
|
1029
|
-
}
|
|
1030
|
-
// Symmetric to notifyRemoteSuperseded: tell the parent session this worker
|
|
1031
|
-
// ACQUIRED the bridge seat so it flips remote mode ON (badge/transcript writer).
|
|
1032
|
-
// Guarded by process.send so a manually-forked worker with no IPC parent is a
|
|
1033
|
-
// no-op instead of crashing. Callers fire this only on a genuine claim
|
|
1034
|
-
// transition (boot make-before-break, activate when not already owned) — never
|
|
1035
|
-
// on a heartbeat refresh — so the parent's idempotent handler sees it once.
|
|
1036
|
-
function notifyRemoteAcquired() {
|
|
1037
|
-
if (!process.send) return;
|
|
1038
|
-
try {
|
|
1039
|
-
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'acquired' } });
|
|
1040
|
-
} catch {}
|
|
1041
|
-
}
|
|
1042
|
-
async function refreshBridgeOwnership(options = {}) {
|
|
1043
|
-
// Coalesce concurrent callers onto the in-flight refresh so backend tool
|
|
1044
|
-
// calls landing during normal login wait for the same connect attempt
|
|
1045
|
-
// instead of returning early and observing spurious auto-connect failure.
|
|
1046
|
-
if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
|
|
1047
|
-
bridgeOwnershipRefreshInFlight = (async () => {
|
|
1048
|
-
// Opt-in remote, single-owner, last-wins. Only a remote session with an
|
|
1049
|
-
// active bridge participates. If this instance is the active owner (its
|
|
1050
|
-
// INSTANCE_ID is the one advertised in active-instance.json) it ensures
|
|
1051
|
-
// the owned runtime is up. If a newer remote session has since claimed
|
|
1052
|
-
// ownership (last-wins overwrite), this instance is no longer owner and
|
|
1053
|
-
// quietly tears its backend down on the next tick. No proxy, no steal.
|
|
1054
|
-
if (!channelBridgeActive) {
|
|
1055
|
-
if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
|
|
1056
|
-
return;
|
|
1057
|
-
}
|
|
1058
|
-
// Non-blocking ownership probe (read-only, no lock): distinguishes a live
|
|
1059
|
-
// owner from a genuinely empty/stale seat from an INDETERMINATE read (a
|
|
1060
|
-
// concurrent atomic rename yields partial content). "Locked/unreadable =
|
|
1061
|
-
// busy/unknown owner, never claimable" — skip the tick on 'unknown' so we
|
|
1062
|
-
// never claim a seat we merely failed to read (double-owner guard).
|
|
1063
|
-
const probe = probeActiveOwner();
|
|
1064
|
-
if (probe.status === 'unknown') return;
|
|
1065
|
-
const owned = probe.status === 'live' && probe.state?.instanceId === INSTANCE_ID;
|
|
1066
|
-
if (owned) {
|
|
1067
|
-
// Try-once CAS refresh (timeoutMs:0): on lock contention treat as busy
|
|
1068
|
-
// and skip the metadata touch — never block the tick. We still own, so
|
|
1069
|
-
// ensure/keep the owned runtime up.
|
|
1070
|
-
try { refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true, timeoutMs: 0 }); } catch {}
|
|
1071
|
-
await startOwnedRuntime(options);
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
if (probe.status === 'live' && probe.state.instanceId && probe.state.instanceId !== INSTANCE_ID) {
|
|
1075
|
-
// A different live remote session holds the seat (last-wins: we lost).
|
|
1076
|
-
// Go quiet (disconnect if connected) and tell the parent to drop remote
|
|
1077
|
-
// mode entirely (single-holder, no handover). Notify UNCONDITIONALLY —
|
|
1078
|
-
// a loser whose backend never connected must still drop its Remote
|
|
1079
|
-
// indicator; the parent handler is idempotent.
|
|
1080
|
-
// Also cover the STARTING phase: a worker stuck in backend.connect() must
|
|
1081
|
-
// get _ownedRuntimeStopRequested set (stopOwnedRuntime does this while
|
|
1082
|
-
// bridgeRuntimeStarting) so bailIfStopRequested tears it down promptly
|
|
1083
|
-
// once connect() resolves — otherwise the superseded connect lingers.
|
|
1084
|
-
if (bridgeRuntimeConnected || bridgeRuntimeStarting) {
|
|
1085
|
-
await stopOwnedRuntime("ownership lost (newer remote session)");
|
|
1086
|
-
}
|
|
1087
|
-
notifyRemoteSuperseded();
|
|
1088
|
-
return;
|
|
1089
|
-
}
|
|
1090
|
-
// Empty (absent) or stale (dead owner) seat — claim it. Try-once
|
|
1091
|
-
// (timeoutMs:0): a contended lock means a concurrent claimant is mid-write,
|
|
1092
|
-
// so treat as busy and skip this tick rather than block.
|
|
1093
|
-
let claimed = false;
|
|
1094
|
-
try { claimed = claimBridgeOwnership("no active owner", { timeoutMs: 0 }); } catch { claimed = false; }
|
|
1095
|
-
if (claimed) {
|
|
1096
|
-
await startOwnedRuntime(options);
|
|
1097
|
-
}
|
|
1098
|
-
})();
|
|
1099
|
-
try {
|
|
1100
|
-
return await bridgeOwnershipRefreshInFlight;
|
|
1101
|
-
} finally {
|
|
1102
|
-
bridgeOwnershipRefreshInFlight = null;
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
async function reloadRuntimeConfig() {
|
|
1107
|
-
const previousBackend = backend;
|
|
1108
|
-
const previousBackendName = previousBackend?.name || "";
|
|
1109
|
-
config = loadConfig();
|
|
1110
|
-
scheduler.reloadConfig(
|
|
1111
|
-
config.nonInteractive ?? [],
|
|
1112
|
-
config.interactive ?? [],
|
|
1113
|
-
// Single resolved main-channel id used for the schedule `channel` flag.
|
|
1114
|
-
config.channelId,
|
|
1115
|
-
{ restart: bridgeRuntimeConnected }
|
|
1116
|
-
);
|
|
1117
|
-
const nextBackend = createBackend(config);
|
|
1118
|
-
const backendChanged = (nextBackend?.name || "") !== previousBackendName;
|
|
1119
|
-
if (backendChanged) {
|
|
1120
|
-
const shouldRestart = bridgeRuntimeConnected || bridgeRuntimeStarting;
|
|
1121
|
-
if (shouldRestart) await stopOwnedRuntime("backend config changed");
|
|
1122
|
-
backend = nextBackend;
|
|
1123
|
-
// The persisted routing channelId belongs to the OLD backend (e.g. a
|
|
1124
|
-
// Discord snowflake) and is meaningless for the new one — sending to it
|
|
1125
|
-
// would 400 "chat not found". There is no id mapping between platforms, so
|
|
1126
|
-
// CLEAR the stale binding: drop the forwarder's context + watcher and wipe
|
|
1127
|
-
// status.channelId/transcriptPath. The next inbound from the new backend
|
|
1128
|
-
// rebinds the correct chat via applyTranscriptBinding(). Only done on
|
|
1129
|
-
// backendChanged — same-backend reloads keep their binding untouched.
|
|
1130
|
-
// (active-instance is cleared by stopOwnedRuntime on the restart path; we
|
|
1131
|
-
// don't re-advertise here to avoid resurrecting a just-cleared entry.)
|
|
1132
|
-
try { forwarder.stopWatch(); } catch {}
|
|
1133
|
-
forwarder.channelId = "";
|
|
1134
|
-
forwarder.transcriptPath = "";
|
|
1135
|
-
try {
|
|
1136
|
-
statusState.update((state) => {
|
|
1137
|
-
state.channelId = "";
|
|
1138
|
-
state.transcriptPath = "";
|
|
1139
|
-
});
|
|
1140
|
-
} catch {}
|
|
1141
|
-
if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
|
|
1142
|
-
} else if (nextBackend !== previousBackend) {
|
|
1143
|
-
try { await nextBackend.disconnect?.(); } catch {}
|
|
1144
|
-
}
|
|
1145
|
-
if (bridgeRuntimeConnected) {
|
|
1146
|
-
syncOwnedWebhookAndEventRuntime({ reload: true });
|
|
1147
|
-
} else {
|
|
1148
|
-
await stopWebhookAndEventRuntime();
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
function injectAndRecord(channelId, name, content, options) {
|
|
1152
|
-
// Strip soft-warn marker blocks (Tool-loop / Repeated-input / legacy
|
|
1153
|
-
// Repeated-tool / Mixed-tool / Tool-budget / Same-file multi-chunk /
|
|
1154
|
-
// Bash file-lookup / Iteration / 0-match advisory) from anywhere in the
|
|
1155
|
-
// outbound body. Markers are
|
|
1156
|
-
// intentionally prepended onto tool RESULTS upstream (tool-loop-guard.mjs
|
|
1157
|
-
// build*Warn) so the model
|
|
1158
|
-
// self-corrects, but agent roles commonly echo them and we don't want them
|
|
1159
|
-
// surfacing in Discord / Lead channel push.
|
|
1160
|
-
if (typeof content === 'string') content = stripSoftWarns(content);
|
|
1161
|
-
// Skip-protocol guard: agents (webhook-handler / scheduler-task)
|
|
1162
|
-
// prefix `[meta:silent]` on the first line to opt out
|
|
1163
|
-
// of Lead inject for genuine no-op results (label-only events, dedup,
|
|
1164
|
-
// "nothing to report"). The body still goes to Discord for audit; only
|
|
1165
|
-
// the Lead-context inject is suppressed. See rules/agent/20-skip-protocol.md.
|
|
1166
|
-
if (typeof content === 'string') {
|
|
1167
|
-
const m = content.match(/^\s*\[meta:silent\][^\n]*\n?([\s\S]*)$/);
|
|
1168
|
-
if (m) {
|
|
1169
|
-
content = m[1];
|
|
1170
|
-
options = { ...(options || {}), silent_to_agent: true };
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
const ts = new Date().toISOString();
|
|
1174
|
-
const now = new Date();
|
|
1175
|
-
const timeLabel = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")} `;
|
|
1176
|
-
const sourceLabel = options?.type ? `${timeLabel}: ${options.type}` : timeLabel;
|
|
1177
|
-
const meta = { chat_id: channelId, user: sourceLabel, user_id: "system", ts };
|
|
1178
|
-
if (options?.instruction) meta.instruction = options.instruction;
|
|
1179
|
-
if (options?.type) meta.type = options.type;
|
|
1180
|
-
// `silent_to_agent` — lifecycle status pings (worker/iter/started echoes)
|
|
1181
|
-
// surface on Discord but should NOT land in Lead's context window. When
|
|
1182
|
-
// set, skip the parent-notify hop but keep the Discord-forward + event-log
|
|
1183
|
-
// record. The meta flag is also propagated downstream so consumers that
|
|
1184
|
-
// still see the notification (e.g. Lead itself if emission changes later)
|
|
1185
|
-
// can recognise and drop it. Default is false → legacy behaviour preserved.
|
|
1186
|
-
if (options?.silent_to_agent) meta.silent_to_agent = true;
|
|
1187
|
-
const silent = options?.silent_to_agent === true;
|
|
1188
|
-
if (!silent) {
|
|
1189
|
-
sendNotifyToParent("notifications/claude/channel", { content, meta });
|
|
1190
|
-
} else {
|
|
1191
|
-
forwardLifecycleToDiscord(channelId, content);
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
// Best-effort direct Discord emission for silent-to-agent lifecycle pings.
|
|
1196
|
-
// Only used when the parent-notify hop is skipped, so the user still sees
|
|
1197
|
-
// the status on Discord even though Lead will never echo it through the
|
|
1198
|
-
// transcript-tail forwarder. Falls back to a no-op when no channel is
|
|
1199
|
-
// resolvable — lifecycle pings are non-critical.
|
|
1200
|
-
function forwardLifecycleToDiscord(channelId, content) {
|
|
1201
|
-
try {
|
|
1202
|
-
// Skip rather than guess: lifecycle callers pass the channelId they own;
|
|
1203
|
-
// falling back to statusState.channelId can route to a stale/unrelated
|
|
1204
|
-
// channel when the caller did not supply one intentionally.
|
|
1205
|
-
const target = channelId || null;
|
|
1206
|
-
dropTrace("send.lifecycle.entry", { channelId: target || "(none)", bindingReadyStatus, backendPresent: !!backend?.sendMessage, preview: preview(content) });
|
|
1207
|
-
if (!target || !backend?.sendMessage) return;
|
|
1208
|
-
void bindingReady.then(() =>
|
|
1209
|
-
backend.sendMessage(target, content)
|
|
1210
|
-
.then(() => dropTrace("send.lifecycle.ok", { channelId: target }))
|
|
1211
|
-
.catch((err) => dropTrace("send.lifecycle.err", { channelId: target, err: String(err) }))
|
|
1212
|
-
).catch(() => {});
|
|
1213
|
-
} catch { /* best-effort */ }
|
|
1214
|
-
}
|
|
1215
|
-
scheduler.setInjectHandler((channelId, name, content, options) => {
|
|
1216
|
-
injectAndRecord(channelId, name, content, options);
|
|
1217
|
-
});
|
|
1218
|
-
scheduler.setSendHandler(async (channelId, text) => {
|
|
1219
|
-
// Skip protocol: a scheduler-task emitting `[meta:silent]` has nothing to
|
|
1220
|
-
// report — suppress the channel send entirely (no noise). Mirrors the
|
|
1221
|
-
// webhook delegate drop and injectAndRecord's silent handling.
|
|
1222
|
-
if (typeof text === "string" && /^\s*\[meta:silent\]/.test(text)) {
|
|
1223
|
-
dropTrace("send.scheduler.silent", { channelId });
|
|
1224
|
-
return;
|
|
1225
|
-
}
|
|
1226
|
-
dropTrace("send.scheduler.entry", { channelId, preview: preview(text) });
|
|
1227
|
-
await bindingReady;
|
|
1228
|
-
dropTrace("send.scheduler.ready", { channelId });
|
|
1229
|
-
try {
|
|
1230
|
-
await backend.sendMessage(channelId, text);
|
|
1231
|
-
dropTrace("send.scheduler.ok", { channelId });
|
|
1232
|
-
} catch (err) {
|
|
1233
|
-
dropTrace("send.scheduler.err", { channelId, err: String(err) });
|
|
1234
|
-
throw err;
|
|
1235
|
-
}
|
|
1236
|
-
});
|
|
1237
|
-
function wireWebhookHandlers() {
|
|
1238
|
-
if (!webhookServer) return;
|
|
1239
|
-
webhookServer.setEventPipeline(eventPipeline);
|
|
1240
|
-
webhookServer.setBridgeDispatch(async ({ role, preset, prompt, cwd, context }) => {
|
|
1241
|
-
// Delegate-mode webhook → bridge orchestrator. Each bridge progress /
|
|
1242
|
-
// final event is forwarded to the Lead via the same channel-notify
|
|
1243
|
-
// path used by schedule & event-queue (injectAndRecord). Silent
|
|
1244
|
-
// lifecycle pings keep routing only to Discord.
|
|
1245
|
-
const agentMod = await import("../agent/index.mjs");
|
|
1246
|
-
const channelId = resolveWebhookChannelId(context?.channel);
|
|
1247
|
-
const endpoint = context?.endpoint || "unknown";
|
|
1248
|
-
const event = context?.event || null;
|
|
1249
|
-
const deliveryId = context?.deliveryId || null;
|
|
1250
|
-
const label = `webhook:${endpoint}`;
|
|
1251
|
-
const instruction = `Webhook review from role=${role} on endpoint "${endpoint}"`
|
|
1252
|
-
+ (event ? ` (event=${event})` : "")
|
|
1253
|
-
+ (deliveryId ? ` (delivery=${deliveryId})` : "")
|
|
1254
|
-
+ ". Relay the finding to the user naturally — summarize clearly, call out any issues, and note what needs a decision.";
|
|
1255
|
-
const notifyFn = (text, meta = {}) => {
|
|
1256
|
-
if (!text) return;
|
|
1257
|
-
// Webhook skip protocol: when the agent worker emits a `[meta:silent]`
|
|
1258
|
-
// marker (optionally behind model/role tag prefixes), the event is a
|
|
1259
|
-
// no-op (label-only, dedup, "nothing to report"). Drop the message
|
|
1260
|
-
// entirely — neither Lead inject nor Discord forward — instead of the
|
|
1261
|
-
// partial `silent_to_agent` semantics that still audit to Discord.
|
|
1262
|
-
const raw = String(text);
|
|
1263
|
-
if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
|
|
1264
|
-
// Deterministic findings-count drop. Code-review handlers emit a
|
|
1265
|
-
// structured `[[findings:N]]` token (N = number of issues). The RELAY —
|
|
1266
|
-
// not the worker's prose — decides: N==0 => clean review, drop entirely
|
|
1267
|
-
// (no Lead inject, no Discord forward). Token absent => fail-safe forward
|
|
1268
|
-
// so a real finding is never silently dropped if the worker omits it.
|
|
1269
|
-
const fc = raw.match(/\[\[findings:(\d+)\]\]/i);
|
|
1270
|
-
if (fc && Number(fc[1]) === 0) return;
|
|
1271
|
-
// Lifecycle pings (started / iter echoes, marked silent_to_agent) are
|
|
1272
|
-
// channel noise for an automated webhook review — drop them entirely so
|
|
1273
|
-
// a skip stays fully silent and only the final answer reaches the
|
|
1274
|
-
// channel. The final [meta:silent] skip result is already dropped above.
|
|
1275
|
-
if (meta?.silent_to_agent === true) return;
|
|
1276
|
-
// Strip the verdict token before surfacing (findings present, N>0).
|
|
1277
|
-
const surfaced = raw.replace(/\[\[findings:\d+\]\]/gi, "").replace(/[^\S\n]{2,}/g, " ").trim();
|
|
1278
|
-
injectAndRecord(channelId, label, surfaced || raw, {
|
|
1279
|
-
type: "webhook",
|
|
1280
|
-
instruction,
|
|
1281
|
-
});
|
|
1282
|
-
};
|
|
1283
|
-
// Per-terminal cwd under the daemon's single channels worker. A webhook
|
|
1284
|
-
// result is injected to ownerConn() — the connection whose session.leadPid
|
|
1285
|
-
// equals active-instance ownerLeadPid — so the worker must run in THAT
|
|
1286
|
-
// owner terminal's cwd. Read the sentinel keyed by ownerLeadPid; cwd-tool
|
|
1287
|
-
// writes session-cwd-<leadPid>.txt per connection, so write and read meet
|
|
1288
|
-
// on the same leadPid key no matter which terminal holds the owner seat.
|
|
1289
|
-
// Falls back to the session entry position; never the plugin CACHE root.
|
|
1290
|
-
const ownerPid = getActiveOwnerPid(readActiveInstance());
|
|
1291
|
-
const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
|
|
1292
|
-
return agentMod.handleToolCall(
|
|
1293
|
-
"bridge",
|
|
1294
|
-
{ role, preset, prompt, cwd: cwd || ownerCwd },
|
|
1295
|
-
{ notifyFn },
|
|
1296
|
-
);
|
|
1297
|
-
});
|
|
1298
|
-
}
|
|
1299
|
-
function resolveWebhookChannelId(channelFlag) {
|
|
1300
|
-
// Single main channel: the endpoint's `channel` frontmatter is a boolean-ish
|
|
1301
|
-
// flag (any non-empty value except "false" → post to the main channel). A raw
|
|
1302
|
-
// channel id is still honored verbatim for owner-authored overrides.
|
|
1303
|
-
const main = config?.channelId || "";
|
|
1304
|
-
if (channelFlag == null) return main;
|
|
1305
|
-
const v = String(channelFlag).trim();
|
|
1306
|
-
if (v === "" || v.toLowerCase() === "false") return "";
|
|
1307
|
-
// A pure-digit / snowflake-looking value is treated as an explicit id;
|
|
1308
|
-
// anything else (legacy labels like "main") maps to the main channel.
|
|
1309
|
-
return /^-?\d+$/.test(v) ? v : main;
|
|
1310
|
-
}
|
|
1311
|
-
function wireEventQueueHandlers(eventQueue) {
|
|
1312
|
-
if (!eventQueue) return;
|
|
1313
|
-
eventQueue.setInjectHandler((channelId, name, content, options) => {
|
|
1314
|
-
injectAndRecord(channelId, name, content, options);
|
|
1315
|
-
});
|
|
1316
|
-
// Defensive ownership probe: the queue tick should only run in the active
|
|
1317
|
-
// owner process. Non-owner instances see bridgeRuntimeConnected=false and
|
|
1318
|
-
// will skip the tick even if an errant start() slipped through.
|
|
1319
|
-
// bridgeRuntimeConnected is a fast-path AND; currentOwnerState().owned is
|
|
1320
|
-
// re-read at probe time so a stale-connected flag cannot mask a lost seat.
|
|
1321
|
-
eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
1322
|
-
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
1323
|
-
}
|
|
1324
|
-
function editDiscordMessage(channelId, messageId, label) {
|
|
1325
|
-
// Behavior-preserving: route through the backend abstraction (which uses
|
|
1326
|
-
// discord.js under the hood) instead of issuing a raw REST PATCH. Errors
|
|
1327
|
-
// are swallowed to stderr to match the prior fire-and-forget shape — the
|
|
1328
|
-
// call site never awaited the HTTPS request either.
|
|
1329
|
-
if (!getDiscordToken()) return;
|
|
1330
|
-
const text = `\u{1F510} **Permission Request** \u2014 ${label}`;
|
|
1331
|
-
void backend.editMessage(channelId, messageId, text, { components: [] }).catch((err) => {
|
|
1332
|
-
process.stderr.write(`mixdog: editDiscordMessage failed: ${err}
|
|
1333
|
-
`);
|
|
1334
|
-
});
|
|
1335
|
-
}
|
|
1336
|
-
backend.onModalRequest = async (rawInteraction) => {
|
|
1337
|
-
if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
|
|
1338
|
-
refreshBridgeOwnershipSafe();
|
|
1339
|
-
return;
|
|
1340
|
-
}
|
|
1341
|
-
const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = await import("discord.js");
|
|
1342
|
-
const customId = rawInteraction.customId;
|
|
1343
|
-
const channelId = rawInteraction.channelId ?? "";
|
|
1344
|
-
pendingSetup.rememberMessage(rawInteraction.user.id, channelId, rawInteraction.message?.id);
|
|
1345
|
-
const modalSpec = buildModalRequestSpec(
|
|
1346
|
-
customId,
|
|
1347
|
-
pendingSetup.get(rawInteraction.user.id, channelId),
|
|
1348
|
-
loadProfileConfig()
|
|
1349
|
-
);
|
|
1350
|
-
if (!modalSpec) return;
|
|
1351
|
-
const modal = new ModalBuilder().setCustomId(modalSpec.customId).setTitle(modalSpec.title);
|
|
1352
|
-
const rows = modalSpec.fields.map(
|
|
1353
|
-
(field) => new ActionRowBuilder().addComponents((() => {
|
|
1354
|
-
const input = new TextInputBuilder().setCustomId(field.id).setLabel(field.label).setStyle(TextInputStyle.Short).setRequired(field.required);
|
|
1355
|
-
if (field.value) input.setValue(field.value);
|
|
1356
|
-
return input;
|
|
1357
|
-
})())
|
|
1358
|
-
);
|
|
1359
|
-
modal.addComponents(...rows);
|
|
1360
|
-
await rawInteraction.showModal(modal);
|
|
1361
|
-
};
|
|
1362
|
-
const pendingPermRequests = new Map();
|
|
1363
|
-
const TOOL_EXEC_CONSUMER_MARKER = path.join(RUNTIME_ROOT, '.tool-exec-consumer');
|
|
1364
|
-
function refreshToolExecConsumerMarker() {
|
|
1365
|
-
try {
|
|
1366
|
-
if (pendingPermRequests.size > 0) {
|
|
1367
|
-
fs.writeFileSync(TOOL_EXEC_CONSUMER_MARKER, String(Date.now()));
|
|
1368
|
-
} else {
|
|
1369
|
-
try { fs.unlinkSync(TOOL_EXEC_CONSUMER_MARKER); } catch {}
|
|
1370
|
-
}
|
|
1371
|
-
} catch {}
|
|
1372
|
-
}
|
|
1373
|
-
// Watch for terminal-approved tool executions. The PostToolUse hook writes a
|
|
1374
|
-
// signal file per tool call; when we see one, find the oldest pending perm
|
|
1375
|
-
// request with a matching tool name and mark its Discord message as
|
|
1376
|
-
// "Allowed (terminal)" so users don't see stale active buttons.
|
|
1377
|
-
try {
|
|
1378
|
-
try { if (!fs.existsSync(RUNTIME_ROOT)) fs.mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
|
|
1379
|
-
const SIGNAL_RE = /^tool-exec-\d+-[0-9a-f]+\.signal$/;
|
|
1380
|
-
fs.watch(RUNTIME_ROOT, { persistent: false }, (eventType, filename) => {
|
|
1381
|
-
if (!filename || !SIGNAL_RE.test(filename)) return;
|
|
1382
|
-
setTimeout(() => {
|
|
1383
|
-
try {
|
|
1384
|
-
const signalPath = path.join(RUNTIME_ROOT, filename);
|
|
1385
|
-
let raw;
|
|
1386
|
-
try { raw = fs.readFileSync(signalPath, 'utf8'); } catch { return; }
|
|
1387
|
-
let payload;
|
|
1388
|
-
try { payload = JSON.parse(raw); } catch { return; }
|
|
1389
|
-
const toolName = payload?.toolName;
|
|
1390
|
-
if (!toolName) return;
|
|
1391
|
-
const sigFilePath = payload?.filePath || '';
|
|
1392
|
-
let oldestKey = null;
|
|
1393
|
-
let oldestEntry = null;
|
|
1394
|
-
for (const [k, v] of pendingPermRequests) {
|
|
1395
|
-
if (v.toolName !== toolName) continue;
|
|
1396
|
-
// Bind on filePath too. If both sides are empty (non-file tools
|
|
1397
|
-
// like Bash), toolName alone is the match. Otherwise both must
|
|
1398
|
-
// equal — prevents two concurrent Edit/Write requests from
|
|
1399
|
-
// cross-approving each other.
|
|
1400
|
-
const vFilePath = v.filePath || '';
|
|
1401
|
-
if (vFilePath || sigFilePath) {
|
|
1402
|
-
if (vFilePath !== sigFilePath) continue;
|
|
1403
|
-
}
|
|
1404
|
-
if (!oldestEntry || v.createdAt < oldestEntry.createdAt) {
|
|
1405
|
-
oldestKey = k;
|
|
1406
|
-
oldestEntry = v;
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
// No matching pending request — leave the signal on disk so a
|
|
1410
|
-
// agent role hook (or other consumer) gets a chance to claim it.
|
|
1411
|
-
if (!oldestKey || !oldestEntry) return;
|
|
1412
|
-
if (oldestEntry.channelId && oldestEntry.messageId) {
|
|
1413
|
-
try {
|
|
1414
|
-
editDiscordMessage(oldestEntry.channelId, oldestEntry.messageId, 'Allowed (terminal)');
|
|
1415
|
-
} catch (err) {
|
|
1416
|
-
try { process.stderr.write(`mixdog channels: tool-exec signal edit failed: ${err && err.message || err}\n`); } catch {}
|
|
1417
|
-
}
|
|
1418
|
-
}
|
|
1419
|
-
pendingPermRequests.delete(oldestKey);
|
|
1420
|
-
refreshToolExecConsumerMarker();
|
|
1421
|
-
// Only unlink once we've confirmed the match and handled it.
|
|
1422
|
-
try { fs.unlinkSync(signalPath); } catch {}
|
|
1423
|
-
} catch (err) {
|
|
1424
|
-
try { process.stderr.write(`mixdog channels: tool-exec signal handler error: ${err && err.message || err}\n`); } catch {}
|
|
1425
|
-
}
|
|
1426
|
-
}, 50);
|
|
1427
|
-
});
|
|
1428
|
-
// Stale-signal sweeper: any signal file older than 60s is removed so
|
|
1429
|
-
// unclaimed files don't accumulate on disk. Runs every 30s.
|
|
1430
|
-
setInterval(() => {
|
|
1431
|
-
try {
|
|
1432
|
-
const now = Date.now();
|
|
1433
|
-
const entries = fs.readdirSync(RUNTIME_ROOT);
|
|
1434
|
-
for (const name of entries) {
|
|
1435
|
-
if (!SIGNAL_RE.test(name)) continue;
|
|
1436
|
-
const p = path.join(RUNTIME_ROOT, name);
|
|
1437
|
-
try {
|
|
1438
|
-
const st = fs.statSync(p);
|
|
1439
|
-
if (now - st.mtimeMs > 60_000) {
|
|
1440
|
-
try { fs.unlinkSync(p); } catch {}
|
|
1441
|
-
}
|
|
1442
|
-
} catch {}
|
|
1443
|
-
}
|
|
1444
|
-
} catch {}
|
|
1445
|
-
}, 30_000)?.unref?.();
|
|
1446
|
-
} catch (err) {
|
|
1447
|
-
try { process.stderr.write(`mixdog channels: tool-exec signal watcher setup failed: ${err && err.message || err}\n`); } catch {}
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
backend.onInteraction = (interaction) => {
|
|
1451
|
-
// Channel-route permission reply. Custom_id format: perm-ch-{request_id}-{allow|session|deny}.
|
|
1452
|
-
// request_id is the 5-letter short ID CC generates via shortRequestId().
|
|
1453
|
-
// Emit notifications/claude/channel/permission back to the MCP host; the race
|
|
1454
|
-
// logic in interactiveHandler.ts resolves the pending request and dismisses
|
|
1455
|
-
// every other racer (local dialog, bridge, hook, classifier).
|
|
1456
|
-
if (interaction.customId?.startsWith("perm-ch-")) {
|
|
1457
|
-
const match = interaction.customId.match(/^perm-ch-([a-km-z]{5})-(allow|session|deny)$/);
|
|
1458
|
-
if (!match) return;
|
|
1459
|
-
const [, requestId, action] = match;
|
|
1460
|
-
const access = config.access;
|
|
1461
|
-
if (access?.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
|
|
1462
|
-
process.stderr.write(`mixdog: perm-ch button rejected — user ${interaction.userId} not in allowFrom\n`);
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
const pending = pendingPermRequests.get(requestId);
|
|
1466
|
-
pendingPermRequests.delete(requestId);
|
|
1467
|
-
refreshToolExecConsumerMarker();
|
|
1468
|
-
const params = { request_id: requestId };
|
|
1469
|
-
if (action === 'deny') {
|
|
1470
|
-
params.behavior = 'deny';
|
|
1471
|
-
} else if (action === 'session') {
|
|
1472
|
-
params.behavior = 'allow';
|
|
1473
|
-
const toolName = pending?.toolName;
|
|
1474
|
-
if (toolName) {
|
|
1475
|
-
params.updatedPermissions = [{ type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'session' }];
|
|
1476
|
-
}
|
|
1477
|
-
} else {
|
|
1478
|
-
params.behavior = 'allow';
|
|
1479
|
-
}
|
|
1480
|
-
sendNotifyToParent('notifications/claude/channel/permission', params);
|
|
1481
|
-
const labels = { allow: 'Approved', session: 'Session Approved', deny: 'Denied' };
|
|
1482
|
-
if (interaction.message?.id && interaction.channelId) {
|
|
1483
|
-
editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
|
|
1484
|
-
}
|
|
1485
|
-
return;
|
|
1486
|
-
}
|
|
1487
|
-
if (interaction.customId?.startsWith("perm-")) {
|
|
1488
|
-
const match = interaction.customId.match(/^perm-([0-9a-f]{32})-(allow|session|deny)$/);
|
|
1489
|
-
if (!match) return;
|
|
1490
|
-
const [, uuid, action] = match;
|
|
1491
|
-
const access = config.access;
|
|
1492
|
-
if (!access) {
|
|
1493
|
-
const _permDropLine = `[${localTimestamp()}] perm interaction dropped: no access config\n`;
|
|
1494
|
-
if (isMixdogDebug()) {
|
|
1495
|
-
fs.appendFileSync(_bootLog, _permDropLine);
|
|
1496
|
-
} else {
|
|
1497
|
-
appendSessionStartCriticalLog(DATA_DIR, `[channels] ${_permDropLine}`);
|
|
1498
|
-
}
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
if (access.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
|
|
1502
|
-
process.stderr.write(`mixdog: perm button rejected \u2014 user ${interaction.userId} not in allowFrom
|
|
1503
|
-
`);
|
|
1504
|
-
return;
|
|
1505
|
-
}
|
|
1506
|
-
const resultPaths = [getPermissionResultPath(INSTANCE_ID, uuid)];
|
|
1507
|
-
const leadInstanceId = String(TERMINAL_LEAD_PID);
|
|
1508
|
-
if (leadInstanceId && leadInstanceId !== INSTANCE_ID) {
|
|
1509
|
-
resultPaths.push(getPermissionResultPath(leadInstanceId, uuid));
|
|
1510
|
-
}
|
|
1511
|
-
for (const resultPath of resultPaths) {
|
|
1512
|
-
try {
|
|
1513
|
-
fs.writeFileSync(resultPath, action, { flag: "wx" });
|
|
1514
|
-
} catch (e) {
|
|
1515
|
-
if (e.code !== "EEXIST") {
|
|
1516
|
-
process.stderr.write(`mixdog: writePermissionResult failed: ${e.message}\n`);
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
const labels = { allow: "Approved", session: "Session Approved", deny: "Denied" };
|
|
1521
|
-
if (interaction.message?.id && interaction.channelId) {
|
|
1522
|
-
editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
|
|
1523
|
-
}
|
|
1524
|
-
return;
|
|
1525
|
-
}
|
|
1526
|
-
if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
|
|
1527
|
-
refreshBridgeOwnershipSafe();
|
|
1528
|
-
return;
|
|
1529
|
-
}
|
|
1530
|
-
scheduler.noteActivity();
|
|
1531
|
-
if (interaction.customId === "stop_task") {
|
|
1532
|
-
controlClaudeSession(INSTANCE_ID, { type: "interrupt" })
|
|
1533
|
-
.catch(err => process.stderr.write(`[channels] controlClaudeSession rejected: ${err?.message || err}\n`));
|
|
1534
|
-
writeTextFile(TURN_END_FILE, String(Date.now()));
|
|
1535
|
-
return;
|
|
1536
|
-
}
|
|
1537
|
-
sendNotifyToParent("notifications/claude/channel", {
|
|
1538
|
-
content: `[interaction] ${interaction.type}: ${interaction.customId}${interaction.values ? " values=" + interaction.values.join(",") : ""}`,
|
|
1539
|
-
meta: {
|
|
1540
|
-
chat_id: interaction.channelId,
|
|
1541
|
-
user: `interaction:${interaction.type}`,
|
|
1542
|
-
user_id: interaction.userId,
|
|
1543
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1544
|
-
interaction_type: interaction.type,
|
|
1545
|
-
custom_id: interaction.customId,
|
|
1546
|
-
...interaction.values ? { values: interaction.values.join(",") } : {},
|
|
1547
|
-
...interaction.message ? { message_id: interaction.message.id } : {}
|
|
1548
|
-
}
|
|
1549
|
-
});
|
|
1550
|
-
};
|
|
1551
|
-
const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
|
|
1552
|
-
getConfig: () => config,
|
|
1553
|
-
dataDir: DATA_DIR,
|
|
1554
|
-
});
|
|
1555
|
-
import { TOOL_DEFS } from './tool-defs.mjs';
|
|
1556
|
-
// Tool dispatch in worker mode goes through the IPC `call` handler at the
|
|
1557
|
-
// bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
|
|
1558
|
-
// orphan worker-level MCP Server: the parent (server.mjs) owns the single
|
|
1559
|
-
// connected transport and routes CallTool through the IPC `call` path.
|
|
1560
|
-
const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
|
|
1561
|
-
// ── Inbound routing / dedup / ownership helpers ─────────────────────────────
|
|
1562
|
-
// Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
|
|
1563
|
-
const {
|
|
1564
|
-
writeChannelOwner,
|
|
1565
|
-
shouldDropDuplicateInbound,
|
|
1566
|
-
resolveInboundRoute,
|
|
1567
|
-
} = createInboundRouting({
|
|
1568
|
-
getConfig: () => config,
|
|
1569
|
-
getInstanceId: () => INSTANCE_ID,
|
|
1570
|
-
getChannelOwnerPath,
|
|
1571
|
-
});
|
|
1572
|
-
// ── Backend-tool dispatch helpers ───────────────────────────────────────────
|
|
1573
|
-
// Each helper dispatches through the local backend (this process is always the
|
|
1574
|
-
// owner in opt-in remote mode). Extracted → lib/backend-dispatch.mjs. Bound to
|
|
1575
|
-
// live config/backend getters so runtime reloads keep the original file-level
|
|
1576
|
-
// reference semantics.
|
|
1577
|
-
const {
|
|
1578
|
-
dispatchReply,
|
|
1579
|
-
dispatchFetch,
|
|
1580
|
-
} = createBackendDispatch({
|
|
1581
|
-
getConfig: () => config,
|
|
1582
|
-
getBackend: () => backend,
|
|
1583
|
-
scheduler,
|
|
1584
|
-
});
|
|
1585
|
-
// ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
|
|
1586
|
-
// handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
|
|
1587
|
-
// lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
|
|
1588
|
-
// functions plus mutable owner state (channelBridgeActive/bridgeRuntimeConnected)
|
|
1589
|
-
// and the forwarder; those are threaded as a lifecycle bag of lazy getters so
|
|
1590
|
-
// the module reads live file-level references at call time (original closure
|
|
1591
|
-
// semantics preserved). Used by the HTTP MCP CallTool path and the worker IPC
|
|
1592
|
-
// `call` handler at the bottom of this file.
|
|
1593
|
-
const {
|
|
1594
|
-
handleToolCall,
|
|
1595
|
-
handleToolCallWithBridgeRetry,
|
|
1596
|
-
} = createToolDispatch({
|
|
1597
|
-
getForwarder: () => forwarder,
|
|
1598
|
-
BACKEND_TOOLS,
|
|
1599
|
-
isChannelsDegraded,
|
|
1600
|
-
dispatchReply,
|
|
1601
|
-
dispatchFetch,
|
|
1602
|
-
lifecycle: {
|
|
1603
|
-
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
1604
|
-
getChannelBridgeActive: () => channelBridgeActive,
|
|
1605
|
-
getOwned: () => getBridgeOwnershipSnapshot().owned,
|
|
1606
|
-
setChannelBridgeActive: (v) => { channelBridgeActive = v; },
|
|
1607
|
-
writeBridgeState,
|
|
1608
|
-
stopServerTyping,
|
|
1609
|
-
claimBridgeOwnership,
|
|
1610
|
-
notifyRemoteAcquired,
|
|
1611
|
-
refreshBridgeOwnership,
|
|
1612
|
-
bindPersistedTranscriptIfAny,
|
|
1613
|
-
stopOwnedRuntime,
|
|
1614
|
-
reloadRuntimeConfig,
|
|
1615
|
-
},
|
|
1616
|
-
});
|
|
1617
|
-
const inboundQueue = (() => {
|
|
1618
|
-
let tail = Promise.resolve();
|
|
1619
|
-
let _iqDepth = 0;
|
|
1620
|
-
const _IQ_MAX_DEPTH = 1000;
|
|
1621
|
-
return (fn) => {
|
|
1622
|
-
if (_iqDepth >= _IQ_MAX_DEPTH) {
|
|
1623
|
-
try { process.stderr.write(`mixdog: inboundQueue overflow (depth=${_iqDepth}), dropping message\n`); } catch {}
|
|
1624
|
-
return;
|
|
1625
|
-
}
|
|
1626
|
-
_iqDepth++;
|
|
1627
|
-
tail = Promise.resolve(tail).then(fn).catch((err) => {
|
|
1628
|
-
try { process.stderr.write(`mixdog: inboundQueue error: ${err && err.message || err}\n`); } catch {}
|
|
1629
|
-
}).finally(() => { _iqDepth--; });
|
|
1630
|
-
};
|
|
1631
|
-
})();
|
|
1632
|
-
|
|
1633
|
-
backend.onMessage = (msg) => {
|
|
1634
|
-
const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
|
|
1635
|
-
const onMessageAtMs = Date.now();
|
|
1636
|
-
if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
|
|
1637
|
-
refreshBridgeOwnershipSafe();
|
|
1638
|
-
return;
|
|
1639
|
-
}
|
|
1640
|
-
if (!channelBridgeActive) return;
|
|
1641
|
-
if (shouldDropDuplicateInbound(msg)) return;
|
|
1642
|
-
// No label concept anymore: the channel id IS the identifier.
|
|
1643
|
-
recordFetchedMessages(msg.chatId, msg.chatId, [{ id: msg.messageId }], { markRead: true });
|
|
1644
|
-
if (!writeChannelOwner(msg.chatId)) return;
|
|
1645
|
-
const route = resolveInboundRoute(msg.chatId, msg.parentChatId);
|
|
1646
|
-
scheduler.noteActivity();
|
|
1647
|
-
startServerTyping(route.targetChatId);
|
|
1648
|
-
backend.resetSendCount();
|
|
1649
|
-
// Pin the prior turn's bound channel before this fire-and-forget flush so the
|
|
1650
|
-
// imminent rebind below (which mutates forwarder.channelId synchronously)
|
|
1651
|
-
// cannot redirect the previous turn's final output to the new channel.
|
|
1652
|
-
const priorForwardChannelId = forwarder.channelId || null;
|
|
1653
|
-
void forwarder.forwardFinalText(0, priorForwardChannelId).catch((err) => {
|
|
1654
|
-
try { process.stderr.write(`mixdog: forwardFinalText rejection: ${err?.stack || err}\n`); } catch {}
|
|
1655
|
-
}).finally(() => forwarder.reset());
|
|
1656
|
-
const previousPath = getPersistedTranscriptPath();
|
|
1657
|
-
let boundTranscript = null;
|
|
1658
|
-
let stoleSelfTranscript = false;
|
|
1659
|
-
let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
|
|
1660
|
-
let needsStealPoll = false;
|
|
1661
|
-
// Reuse the current binding only while it still points at THIS owner's own
|
|
1662
|
-
// session. discoverSessionBoundTranscript() now ranks the live parent-chain
|
|
1663
|
-
// session (the one that forked this worker and receives injected input)
|
|
1664
|
-
// above a more-recently-touched neighbour, so when a co-located session
|
|
1665
|
-
// owns the stale binding we steal it back here instead of tailing the wrong
|
|
1666
|
-
// transcript for the rest of the process lifetime. Steal whenever the live
|
|
1667
|
-
// parent-chain (self) candidate resolves to a different path — even when its
|
|
1668
|
-
// transcript is not on disk yet: we keep selfBound.exists=false so the
|
|
1669
|
-
// downstream `!boundTranscript?.exists` branch routes through
|
|
1670
|
-
// rebindTranscriptContext()'s pending-bind + re-arm poll, which forwards the
|
|
1671
|
-
// first assistant reply once the self transcript is created. Marking the
|
|
1672
|
-
// stale neighbour path as exists=true here would suppress that rearm and
|
|
1673
|
-
// keep tailing the wrong session for the whole turn.
|
|
1674
|
-
if (transcriptPath) {
|
|
1675
|
-
const selfBound = discoverSessionBoundTranscript();
|
|
1676
|
-
const shouldStealBoundTranscript = Boolean(
|
|
1677
|
-
selfBound?.transcriptPath &&
|
|
1678
|
-
!sameResolvedPath(selfBound.transcriptPath, transcriptPath) &&
|
|
1679
|
-
selfBound.active === true &&
|
|
1680
|
-
(selfBound.parentChain === true || selfBound.cwdMatches === true)
|
|
1681
|
-
);
|
|
1682
|
-
if (shouldStealBoundTranscript) {
|
|
1683
|
-
process.stderr.write(`mixdog: inbound rebind: stealing transcript ${transcriptPath} -> ${selfBound.transcriptPath} (source=${selfBound.source || "unknown"}, exists=${selfBound.exists})\n`);
|
|
1684
|
-
transcriptPath = selfBound.transcriptPath;
|
|
1685
|
-
boundTranscript = selfBound;
|
|
1686
|
-
stoleSelfTranscript = true;
|
|
1687
|
-
} else {
|
|
1688
|
-
boundTranscript = {
|
|
1689
|
-
sessionId: sessionIdFromTranscriptPath(transcriptPath),
|
|
1690
|
-
sessionCwd: statusState.read().sessionCwd ?? null,
|
|
1691
|
-
transcriptPath,
|
|
1692
|
-
exists: true
|
|
1693
|
-
};
|
|
1694
|
-
// Fast path skips the poll below (zero added latency) unless we lack a
|
|
1695
|
-
// confident, currently-active self-bound candidate — that's the
|
|
1696
|
-
// ~ms race window right after activate, before the parent-chain
|
|
1697
|
-
// session record is published, where the steal gate above fails on
|
|
1698
|
-
// the very first inbound even though a real self session exists.
|
|
1699
|
-
if (!selfBound || selfBound.active !== true) needsStealPoll = true;
|
|
1700
|
-
}
|
|
1701
|
-
} else {
|
|
1702
|
-
boundTranscript = discoverSessionBoundTranscript();
|
|
1703
|
-
transcriptPath = pickUsableTranscriptPath(boundTranscript, previousPath);
|
|
1704
|
-
// Only fall back to latest-by-mtime when discovery did NOT produce a
|
|
1705
|
-
// confident, existing current-session transcript. detectCurrentSessionTranscript()
|
|
1706
|
-
// already weighs mtime (with a 30s decisive threshold) against active-pid /
|
|
1707
|
-
// cwd affinity, so overriding a real detected binding with the raw newest
|
|
1708
|
-
// file would clobber the current session with an unrelated, more-recently
|
|
1709
|
-
// touched transcript (wrong-session forward).
|
|
1710
|
-
if (!boundTranscript?.exists) {
|
|
1711
|
-
const latestByMtime = findLatestTranscriptByMtime(boundTranscript?.sessionCwd);
|
|
1712
|
-
if (latestByMtime && latestByMtime !== transcriptPath) {
|
|
1713
|
-
transcriptPath = latestByMtime;
|
|
1714
|
-
}
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
// Binding-settled signal: resolves once the queued binding task below
|
|
1718
|
-
// (poll-if-needed + apply-bind + rebind) has run, so the react/status
|
|
1719
|
-
// IIFE can read the FINAL transcriptPath/boundTranscript instead of the
|
|
1720
|
-
// pre-poll snapshot. onMessage itself stays synchronous — nothing here
|
|
1721
|
-
// blocks message ordering or delays the enqueue calls.
|
|
1722
|
-
let bindingDoneResolve;
|
|
1723
|
-
const bindingDone = new Promise((resolve) => { bindingDoneResolve = resolve; });
|
|
1724
|
-
const queuedAtMs = Date.now();
|
|
1725
|
-
const preQueueMs = queuedAtMs - onMessageAtMs;
|
|
1726
|
-
const gatewayToQueueMs = queuedAtMs - receivedAtMs;
|
|
1727
|
-
if (preQueueMs > 250 || gatewayToQueueMs > 500) {
|
|
1728
|
-
process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
|
|
1729
|
-
}
|
|
1730
|
-
// ONE queued task per message: binding (poll-if-needed + bind + rebind)
|
|
1731
|
-
// first, then handleInbound. Keeping both phases in a single task preserves
|
|
1732
|
-
// the queue's per-message depth accounting — the overflow guard drops a
|
|
1733
|
-
// whole message, never just its handleInbound half — and guarantees
|
|
1734
|
-
// bindingDone/stopServerTyping always settle even when the binding phase
|
|
1735
|
-
// throws. FIFO is preserved: inboundQueue chains tasks in call order, so
|
|
1736
|
-
// this message's poll delay (if any) defers only its own delivery.
|
|
1737
|
-
inboundQueue(async () => {
|
|
1738
|
-
try {
|
|
1739
|
-
if (needsStealPoll) {
|
|
1740
|
-
const POLL_INTERVAL_MS = 50;
|
|
1741
|
-
const POLL_TIMEOUT_MS = 500;
|
|
1742
|
-
const pollStart = Date.now();
|
|
1743
|
-
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
|
|
1744
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
1745
|
-
// fresh: bypass the negative parent-pid cache inside the walk —
|
|
1746
|
-
// a transient parent-lookup miss cached just before this poll
|
|
1747
|
-
// would otherwise pin every retry to null until its TTL expires,
|
|
1748
|
-
// defeating the whole first-inbound recovery window.
|
|
1749
|
-
const retryBound = discoverSessionBoundTranscript({ fresh: true });
|
|
1750
|
-
const retryShouldSteal = Boolean(
|
|
1751
|
-
retryBound?.transcriptPath &&
|
|
1752
|
-
!sameResolvedPath(retryBound.transcriptPath, transcriptPath) &&
|
|
1753
|
-
retryBound.active === true &&
|
|
1754
|
-
(retryBound.parentChain === true || retryBound.cwdMatches === true)
|
|
1755
|
-
);
|
|
1756
|
-
if (retryShouldSteal) {
|
|
1757
|
-
process.stderr.write(`mixdog: inbound rebind (poll +${Date.now() - pollStart}ms): stealing transcript ${transcriptPath} -> ${retryBound.transcriptPath} (source=${retryBound.source || "unknown"}, exists=${retryBound.exists})\n`);
|
|
1758
|
-
transcriptPath = retryBound.transcriptPath;
|
|
1759
|
-
boundTranscript = retryBound;
|
|
1760
|
-
stoleSelfTranscript = true;
|
|
1761
|
-
break;
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
if (transcriptPath) {
|
|
1766
|
-
applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
|
|
1767
|
-
} else {
|
|
1768
|
-
refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId }, { onlyIfOwned: true });
|
|
1769
|
-
}
|
|
1770
|
-
if (!boundTranscript?.exists) {
|
|
1771
|
-
await rebindTranscriptContext(route.targetChatId, {
|
|
1772
|
-
// For a stolen self transcript (not yet on disk) the sync bind above
|
|
1773
|
-
// persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
|
|
1774
|
-
// setContext resume from offset 0 once the file appears — forwarding
|
|
1775
|
-
// the first assistant reply. Relying on replayFromStart instead would
|
|
1776
|
-
// race: the discovery loop only sets replayFromStart when it first saw
|
|
1777
|
-
// the transcript as PENDING, so a file that already exists on the first
|
|
1778
|
-
// loop iteration would bind at EOF and skip the reply. Non-steal keeps
|
|
1779
|
-
// the original catch-up-from-cursor behaviour.
|
|
1780
|
-
previousPath: transcriptPath,
|
|
1781
|
-
catchUp: true,
|
|
1782
|
-
catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
|
|
1783
|
-
persistStatus: true
|
|
1784
|
-
});
|
|
1785
|
-
}
|
|
1786
|
-
} catch (err) {
|
|
1787
|
-
try { process.stderr.write(`mixdog: inbound binding error: ${err}\n`); } catch {}
|
|
1788
|
-
} finally {
|
|
1789
|
-
bindingDoneResolve();
|
|
1790
|
-
}
|
|
1791
|
-
try {
|
|
1792
|
-
await handleInbound(msg, route, {
|
|
1793
|
-
sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
|
|
1794
|
-
receivedAtMs,
|
|
1795
|
-
queuedAtMs
|
|
1796
|
-
});
|
|
1797
|
-
} catch (err) {
|
|
1798
|
-
process.stderr.write(`mixdog: handleInbound error: ${err}\n`);
|
|
1799
|
-
} finally {
|
|
1800
|
-
stopServerTyping();
|
|
1801
|
-
}
|
|
1802
|
-
});
|
|
1803
|
-
void (async () => {
|
|
1804
|
-
try {
|
|
1805
|
-
await backend.react(msg.chatId, msg.messageId, "\u{1F914}");
|
|
1806
|
-
} catch {
|
|
1807
|
-
}
|
|
1808
|
-
await bindingDone;
|
|
1809
|
-
statusState.update((state) => {
|
|
1810
|
-
state.channelId = route.targetChatId;
|
|
1811
|
-
state.userMessageId = msg.messageId;
|
|
1812
|
-
state.emoji = "\u{1F914}";
|
|
1813
|
-
state.sentCount = 0;
|
|
1814
|
-
state.sessionIdle = false;
|
|
1815
|
-
if (transcriptPath) state.transcriptPath = transcriptPath;
|
|
1816
|
-
else delete state.transcriptPath;
|
|
1817
|
-
state.sessionCwd = boundTranscript?.sessionCwd ?? null;
|
|
1818
|
-
});
|
|
1819
|
-
})();
|
|
1820
|
-
};
|
|
1821
|
-
const NETWORK_ERR_RE = /fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|timeout|aborted|TimeoutError/i;
|
|
1822
|
-
function isNetworkError(err) {
|
|
1823
|
-
const s = `${err?.code ?? ""} ${err?.name ?? ""} ${err?.message ?? err ?? ""}`;
|
|
1824
|
-
return NETWORK_ERR_RE.test(s);
|
|
1825
|
-
}
|
|
1826
|
-
async function retryOnNetwork(fn, { attempts = 3, baseDelayMs = 300, label = "op" } = {}) {
|
|
1827
|
-
let lastErr;
|
|
1828
|
-
for (let i = 0; i < attempts; i++) {
|
|
1829
|
-
try {
|
|
1830
|
-
return await fn();
|
|
1831
|
-
} catch (err) {
|
|
1832
|
-
lastErr = err;
|
|
1833
|
-
if (i === attempts - 1 || !isNetworkError(err)) throw err;
|
|
1834
|
-
const delay = baseDelayMs * (i + 1);
|
|
1835
|
-
process.stderr.write(`mixdog: ${label} network failure (attempt ${i + 1}/${attempts}), retrying in ${delay}ms: ${err?.message || err}\n`);
|
|
1836
|
-
await new Promise((r) => setTimeout(r, delay));
|
|
1837
|
-
}
|
|
1838
|
-
}
|
|
1839
|
-
throw lastErr;
|
|
1840
|
-
}
|
|
1841
|
-
async function handleInbound(msg, route, options = {}) {
|
|
1842
|
-
const handleStartMs = Date.now();
|
|
1843
|
-
let text = msg.text;
|
|
1844
|
-
const voiceAtts = msg.attachments.filter((a) => isVoiceAttachment(a.contentType));
|
|
1845
|
-
if (voiceAtts.length > 0) {
|
|
1846
|
-
if (config.voice?.enabled === false) {
|
|
1847
|
-
process.stderr.write(`mixdog: voice.transcription skipped — voice.enabled=false\n`);
|
|
1848
|
-
text = text || "[voice message]";
|
|
1849
|
-
} else {
|
|
1850
|
-
try {
|
|
1851
|
-
const files = await retryOnNetwork(
|
|
1852
|
-
// Short per-attempt timeout (vs the 180s default) bounds worst-case
|
|
1853
|
-
// blockage of the serial inboundQueue on a bad voice message.
|
|
1854
|
-
() => backend.downloadAttachment(msg.chatId, msg.messageId, { timeoutMs: 20_000 }),
|
|
1855
|
-
{ label: "voice.download" }
|
|
1856
|
-
);
|
|
1857
|
-
// concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
|
|
1858
|
-
for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
|
|
1859
|
-
const _t0 = Date.now();
|
|
1860
|
-
const transcript = await retryOnNetwork(
|
|
1861
|
-
() => transcribeVoice(f.path, { attachmentId: f.id }),
|
|
1862
|
-
{ label: "voice.transcription" }
|
|
1863
|
-
);
|
|
1864
|
-
const _elapsed = Date.now() - _t0;
|
|
1865
|
-
if (transcript) {
|
|
1866
|
-
text = transcript;
|
|
1867
|
-
process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
|
|
1868
|
-
} else {
|
|
1869
|
-
process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
|
|
1870
|
-
text = text || "[voice message \u2014 transcription failed]";
|
|
1871
|
-
}
|
|
1872
|
-
}
|
|
1873
|
-
} catch (err) {
|
|
1874
|
-
const netFail = isNetworkError(err);
|
|
1875
|
-
process.stderr.write(`mixdog: voice.transcription error${netFail ? " (network, retries exhausted)" : ""}: ${err}\n`);
|
|
1876
|
-
const marker = netFail
|
|
1877
|
-
? "[attachment: voice transcription failed (network)]"
|
|
1878
|
-
: `[voice message \u2014 transcription error: ${err?.message || err}]`;
|
|
1879
|
-
text = text ? `${text} ${marker}` : marker;
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
}
|
|
1883
|
-
const hasVoiceAtt = voiceAtts.length > 0;
|
|
1884
|
-
const attMeta = msg.attachments.length > 0 && !hasVoiceAtt ? {
|
|
1885
|
-
attachment_count: String(msg.attachments.length),
|
|
1886
|
-
attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
|
|
1887
|
-
} : {};
|
|
1888
|
-
const messageBody = route.sourceMode === "monitor" && route.sourceLabel ? `[monitor:${route.sourceLabel}] ${text}` : text;
|
|
1889
|
-
const notificationMeta = {
|
|
1890
|
-
chat_id: route.targetChatId,
|
|
1891
|
-
message_id: msg.messageId,
|
|
1892
|
-
user: msg.user,
|
|
1893
|
-
user_id: msg.userId,
|
|
1894
|
-
ts: msg.ts,
|
|
1895
|
-
...route.sourceMode === "monitor" ? {
|
|
1896
|
-
source_chat_id: route.sourceChatId,
|
|
1897
|
-
source_mode: route.sourceMode,
|
|
1898
|
-
...route.sourceLabel ? { source_label: route.sourceLabel } : {}
|
|
1899
|
-
} : {},
|
|
1900
|
-
...attMeta,
|
|
1901
|
-
...msg.imagePath ? { image_path: msg.imagePath } : {}
|
|
1902
|
-
};
|
|
1903
|
-
const notificationContent = messageBody;
|
|
1904
|
-
sendNotifyToParent("notifications/claude/channel", {
|
|
1905
|
-
content: notificationContent,
|
|
1906
|
-
meta: notificationMeta
|
|
1907
|
-
});
|
|
1908
|
-
const notifiedAtMs = Date.now();
|
|
1909
|
-
const receivedAtMs = Number.isFinite(options.receivedAtMs) ? options.receivedAtMs : handleStartMs;
|
|
1910
|
-
const queuedAtMs = Number.isFinite(options.queuedAtMs) ? options.queuedAtMs : handleStartMs;
|
|
1911
|
-
const queueMs = handleStartMs - queuedAtMs;
|
|
1912
|
-
const handleMs = notifiedAtMs - handleStartMs;
|
|
1913
|
-
const totalMs = notifiedAtMs - receivedAtMs;
|
|
1914
|
-
if (queueMs > 250 || handleMs > 250 || totalMs > 500) {
|
|
1915
|
-
process.stderr.write(`mixdog: inbound latency delivered total=${totalMs}ms queue=${queueMs}ms handle=${handleMs}ms channel=${route.targetChatId} attachments=${msg.attachments.length}\n`);
|
|
1916
|
-
}
|
|
1917
|
-
void memoryAppendEntry({
|
|
1918
|
-
ts: msg.ts,
|
|
1919
|
-
role: "user",
|
|
1920
|
-
content: messageBody,
|
|
1921
|
-
sourceRef: `discord:${route.targetChatId}#${msg.messageId}`,
|
|
1922
|
-
sessionId: `discord:${route.targetChatId}`,
|
|
1923
|
-
cwd: statusState.read().sessionCwd,
|
|
1924
|
-
});
|
|
1925
|
-
}
|
|
1926
|
-
async function init(_sharedMcp) {
|
|
1927
|
-
// _sharedMcp is no longer used. Notifications now flow via IPC to the parent
|
|
1928
|
-
// (sendNotifyToParent above). The parameter is retained for backward
|
|
1929
|
-
// compatibility with any caller that still passes a Server reference.
|
|
1930
|
-
scheduler.setInjectHandler((channelId, name, content, options) => {
|
|
1931
|
-
injectAndRecord(channelId, name, content, options);
|
|
1932
|
-
});
|
|
1933
|
-
}
|
|
1934
|
-
async function start() {
|
|
1935
|
-
channelBridgeActive = true;
|
|
1936
|
-
writeBridgeState(true);
|
|
1937
|
-
// Opt-in remote, single-owner, last-wins, MAKE-BEFORE-BREAK. Do NOT claim the
|
|
1938
|
-
// seat up front: connect our own gateway first and claim only after it reports
|
|
1939
|
-
// READY (inside startOwnedRuntime, claimAfterReady). Until then the previous
|
|
1940
|
-
// owner keeps serving, so a boot causes no send/receive gap. This is also the
|
|
1941
|
-
// SINGLE boot claim — the old "remote start" pre-claim + "re-activate
|
|
1942
|
-
// takeover" second claim (double reconnect) is gone.
|
|
1943
|
-
const _bindingReadyStart = Date.now();
|
|
1944
|
-
try {
|
|
1945
|
-
await startOwnedRuntime({ restoreBinding: true, claimAfterReady: true });
|
|
1946
|
-
bindingReadyStatus = "resolved";
|
|
1947
|
-
dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
|
|
1948
|
-
_bindingReadyResolve(true);
|
|
1949
|
-
} catch (e) {
|
|
1950
|
-
bindingReadyStatus = "rejected";
|
|
1951
|
-
dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
|
|
1952
|
-
_bindingReadyResolve(e);
|
|
1953
|
-
}
|
|
1954
|
-
// Ownership timer: keep checking whether a newer remote session has taken
|
|
1955
|
-
// over (last-wins) so a superseded owner disconnects promptly. Normally
|
|
1956
|
-
// already armed by startOwnedRuntime before connect(); this is the fallback
|
|
1957
|
-
// for a start path that aborted pre-connect (idempotent).
|
|
1958
|
-
armBridgeOwnershipTimer();
|
|
1959
|
-
// Hot-reload config on file change (schedules/webhooks/events).
|
|
1960
|
-
if (!_configWatcher) {
|
|
1961
|
-
try {
|
|
1962
|
-
_configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
|
|
1963
|
-
if (_reloadDebounce) clearTimeout(_reloadDebounce);
|
|
1964
|
-
_reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
|
|
1965
|
-
});
|
|
1966
|
-
} catch {}
|
|
1967
|
-
}
|
|
1968
|
-
// Pre-warm the whisper-server manager once at owner startup so the first
|
|
1969
|
-
// voice transcription does not pay cold-start cost. Non-blocking: failures
|
|
1970
|
-
// (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
|
|
1971
|
-
void (async () => {
|
|
1972
|
-
try {
|
|
1973
|
-
if (config.voice?.enabled === false) return;
|
|
1974
|
-
const runtime = resolveVoiceRuntime(DATA_DIR);
|
|
1975
|
-
if (!runtime?.installed) return;
|
|
1976
|
-
const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
|
|
1977
|
-
const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
|
|
1978
|
-
await ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
|
|
1979
|
-
} catch (err) {
|
|
1980
|
-
try { process.stderr.write(`mixdog: voice.transcription pre-warm skipped: ${err}\n`); } catch {}
|
|
1981
|
-
}
|
|
1982
|
-
})();
|
|
1983
|
-
}
|
|
1984
|
-
async function stop() {
|
|
1985
|
-
try { await stopVoiceWhisperServer(); } catch {}
|
|
1986
|
-
await stopOwnedRuntime("unified server stop");
|
|
1987
|
-
cleanupInstanceRuntimeFiles(INSTANCE_ID);
|
|
1988
|
-
if (bridgeOwnershipTimer) {
|
|
1989
|
-
clearInterval(bridgeOwnershipTimer);
|
|
1990
|
-
bridgeOwnershipTimer = null;
|
|
1991
|
-
}
|
|
1992
|
-
if (_reloadDebounce) { clearTimeout(_reloadDebounce); _reloadDebounce = null; }
|
|
1993
|
-
if (_configWatcher) { try { _configWatcher.close(); } catch {} _configWatcher = null; }
|
|
1994
|
-
if (turnEndWatcher) {
|
|
1995
|
-
try { turnEndWatcher.close(); } catch {}
|
|
1996
|
-
turnEndWatcher = null;
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
// ── IPC worker mode ──────────────────────────────────────────────
|
|
2000
|
-
if (_isWorkerMode && process.send) {
|
|
2001
|
-
// SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
|
|
2002
|
-
// Cleans up in-progress webhook/scheduler state, removes runtime files, then exits.
|
|
2003
|
-
let _channelsStopInFlight = false
|
|
2004
|
-
let _channelsForceExitTimer = null
|
|
2005
|
-
const _channelsShutdownHandler = async (sig) => {
|
|
2006
|
-
if (_channelsStopInFlight) {
|
|
2007
|
-
process.stderr.write(`[channels-worker] ${sig} — shutdown already in flight, ignoring\n`)
|
|
2008
|
-
return
|
|
2009
|
-
}
|
|
2010
|
-
_channelsStopInFlight = true
|
|
2011
|
-
process.stderr.write(`[channels-worker] received ${sig} — shutting down cleanly\n`)
|
|
2012
|
-
_channelsForceExitTimer = setTimeout(() => {
|
|
2013
|
-
process.stderr.write(`[channels-worker] stop() timed out after 6000ms — forcing exit(2)\n`)
|
|
2014
|
-
process.exit(2)
|
|
2015
|
-
}, 6000)
|
|
2016
|
-
try { await stopVoiceWhisperServer() } catch (e) {
|
|
2017
|
-
process.stderr.write(`[channels-worker] stopVoiceWhisperServer() error on ${sig}: ${e && (e.message || e)}\n`)
|
|
2018
|
-
}
|
|
2019
|
-
try { await stop() } catch (e) {
|
|
2020
|
-
process.stderr.write(`[channels-worker] stop() error on ${sig}: ${e && (e.message || e)}\n`)
|
|
2021
|
-
}
|
|
2022
|
-
if (_channelsForceExitTimer) clearTimeout(_channelsForceExitTimer)
|
|
2023
|
-
try { cleanupInstanceRuntimeFiles(INSTANCE_ID) } catch {}
|
|
2024
|
-
try { clearServerPid() } catch {}
|
|
2025
|
-
process.exit(0)
|
|
2026
|
-
}
|
|
2027
|
-
process.on('SIGTERM', () => _channelsShutdownHandler('SIGTERM'))
|
|
2028
|
-
process.on('SIGINT', () => _channelsShutdownHandler('SIGINT'))
|
|
2029
|
-
|
|
2030
|
-
// Map of callId → AbortController for in-flight IPC calls.
|
|
2031
|
-
const _inFlightChannelCalls = new Map()
|
|
2032
|
-
|
|
2033
|
-
process.on('message', async (msg) => {
|
|
2034
|
-
// Parent-initiated graceful shutdown — mirrors memory worker IPC pattern.
|
|
2035
|
-
if (msg && msg.type === 'shutdown') {
|
|
2036
|
-
process.stderr.write('[channels-worker] received IPC shutdown — calling stop()\n')
|
|
2037
|
-
_channelsShutdownHandler('IPC:shutdown')
|
|
2038
|
-
return
|
|
2039
|
-
}
|
|
2040
|
-
// Silent-to-agent lifecycle forward — parent (server.mjs) asks the
|
|
2041
|
-
// channels worker to post status pings to the active bridge Discord
|
|
2042
|
-
// channel without the Lead-notify hop. Best-effort: unknown channel or
|
|
2043
|
-
// backend failure is swallowed; lifecycle pings are non-critical.
|
|
2044
|
-
if (msg && msg.type === 'forward_to_discord') {
|
|
2045
|
-
try {
|
|
2046
|
-
const target = msg.channelId
|
|
2047
|
-
|| (statusState?.read?.().channelId)
|
|
2048
|
-
|| null;
|
|
2049
|
-
if (target && backend?.sendMessage && typeof msg.content === 'string' && msg.content) {
|
|
2050
|
-
await backend.sendMessage(target, msg.content).catch(() => {});
|
|
2051
|
-
}
|
|
2052
|
-
} catch { /* best-effort */ }
|
|
2053
|
-
return;
|
|
2054
|
-
}
|
|
2055
|
-
// Host permission request → Discord Allow/Deny prompt.
|
|
2056
|
-
// Parent (server.mjs) receives notifications/claude/channel/permission_request
|
|
2057
|
-
// from the MCP host and forwards the params here. We post a buttoned message;
|
|
2058
|
-
// button clicks are handled in backend.onInteraction and sent back to CC as
|
|
2059
|
-
// notifications/claude/channel/permission via sendNotifyToParent.
|
|
2060
|
-
if (msg && msg.type === 'permission_request_inbound') {
|
|
2061
|
-
try {
|
|
2062
|
-
const { request_id, tool_name, description, input_preview } = msg.params || {};
|
|
2063
|
-
// tool_input arrives via the passthrough() schema in server.mjs when
|
|
2064
|
-
// The host includes it in the permission_request notification.
|
|
2065
|
-
// Used to bind the pendingPermRequest to a specific file so two
|
|
2066
|
-
// concurrent Edit/Write requests cannot cross-approve via the
|
|
2067
|
-
// terminal signal.
|
|
2068
|
-
const toolInputParam = (msg.params && (msg.params.tool_input || msg.params.toolInput)) || {};
|
|
2069
|
-
const filePathParam = toolInputParam.file_path || '';
|
|
2070
|
-
if (!request_id || !tool_name) return;
|
|
2071
|
-
if (pendingPermRequests.size > 100) {
|
|
2072
|
-
const cutoff = Date.now() - 30 * 60 * 1000;
|
|
2073
|
-
for (const [k, v] of pendingPermRequests) {
|
|
2074
|
-
if (v.createdAt < cutoff) pendingPermRequests.delete(k);
|
|
2075
|
-
}
|
|
2076
|
-
refreshToolExecConsumerMarker();
|
|
2077
|
-
}
|
|
2078
|
-
const target = (statusState?.read?.().channelId)
|
|
2079
|
-
|| config?.channelId
|
|
2080
|
-
|| null;
|
|
2081
|
-
if (!target || !backend?.sendMessage) {
|
|
2082
|
-
process.stderr.write(`mixdog channels: permission_request dropped, no target channel (request_id=${request_id})\n`);
|
|
2083
|
-
return;
|
|
2084
|
-
}
|
|
2085
|
-
const lines = [`🔐 **Permission Request**`, `Tool: \`${tool_name}\``];
|
|
2086
|
-
if (description) lines.push(description);
|
|
2087
|
-
if (input_preview) lines.push('```\n' + String(input_preview).slice(0, 800) + '\n```');
|
|
2088
|
-
const content = lines.join('\n');
|
|
2089
|
-
const components = [{
|
|
2090
|
-
type: 1,
|
|
2091
|
-
components: [
|
|
2092
|
-
{ type: 2, style: 3, label: 'Allow', custom_id: `perm-ch-${request_id}-allow` },
|
|
2093
|
-
{ type: 2, style: 1, label: 'Session Allow', custom_id: `perm-ch-${request_id}-session` },
|
|
2094
|
-
{ type: 2, style: 4, label: 'Deny', custom_id: `perm-ch-${request_id}-deny` },
|
|
2095
|
-
],
|
|
2096
|
-
}];
|
|
2097
|
-
let sentIds = null;
|
|
2098
|
-
try {
|
|
2099
|
-
const sendResult = await backend.sendMessage(target, content, { components });
|
|
2100
|
-
sentIds = sendResult?.sentIds;
|
|
2101
|
-
} catch (err) {
|
|
2102
|
-
process.stderr.write(`mixdog channels: permission_request Discord send failed: ${err && err.message || err}\n`);
|
|
2103
|
-
return;
|
|
2104
|
-
}
|
|
2105
|
-
const messageId = Array.isArray(sentIds) && sentIds.length > 0 ? sentIds[0] : null;
|
|
2106
|
-
pendingPermRequests.set(request_id, {
|
|
2107
|
-
toolName: tool_name,
|
|
2108
|
-
filePath: filePathParam,
|
|
2109
|
-
createdAt: Date.now(),
|
|
2110
|
-
channelId: target,
|
|
2111
|
-
messageId,
|
|
2112
|
-
});
|
|
2113
|
-
refreshToolExecConsumerMarker();
|
|
2114
|
-
} catch (err) {
|
|
2115
|
-
try { process.stderr.write(`mixdog channels: permission_request handler error: ${err && err.message || err}\n`); } catch {}
|
|
2116
|
-
}
|
|
2117
|
-
return;
|
|
2118
|
-
}
|
|
2119
|
-
if (handleMemoryCallResponse(msg)) return;
|
|
2120
|
-
if (msg.type === 'cancel' && msg.callId) {
|
|
2121
|
-
const entry = _inFlightChannelCalls.get(msg.callId)
|
|
2122
|
-
if (entry) {
|
|
2123
|
-
entry.abort()
|
|
2124
|
-
_inFlightChannelCalls.delete(msg.callId)
|
|
2125
|
-
}
|
|
2126
|
-
process.send({ type: 'result', callId: msg.callId, error: 'cancelled' })
|
|
2127
|
-
return
|
|
2128
|
-
}
|
|
2129
|
-
if (msg.type !== 'call' || !msg.callId) return
|
|
2130
|
-
try {
|
|
2131
|
-
const ac = new AbortController()
|
|
2132
|
-
_inFlightChannelCalls.set(msg.callId, ac)
|
|
2133
|
-
let result
|
|
2134
|
-
try {
|
|
2135
|
-
result = await handleToolCallWithBridgeRetry(msg.name, msg.args || {}, ac.signal)
|
|
2136
|
-
} finally {
|
|
2137
|
-
_inFlightChannelCalls.delete(msg.callId)
|
|
2138
|
-
}
|
|
2139
|
-
process.send({ type: 'result', callId: msg.callId, result })
|
|
2140
|
-
} catch (e) {
|
|
2141
|
-
process.send({ type: 'result', callId: msg.callId, error: e.message })
|
|
2142
|
-
}
|
|
2143
|
-
})
|
|
2144
|
-
void (async () => {
|
|
2145
|
-
const startedAt = performance.now()
|
|
2146
|
-
const MAX_START_ATTEMPTS = 3
|
|
2147
|
-
const BASE_BACKOFF_MS = 250
|
|
2148
|
-
const isTransientStartErr = (err) =>
|
|
2149
|
-
err?.code === 'ELOCKTIMEOUT' || /atomic lock timeout/i.test(err?.message || '')
|
|
2150
|
-
let lastErr
|
|
2151
|
-
for (let attempt = 1; attempt <= MAX_START_ATTEMPTS; attempt++) {
|
|
2152
|
-
if (_channelsStopInFlight) return
|
|
2153
|
-
try {
|
|
2154
|
-
await start()
|
|
2155
|
-
bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1), attempt })
|
|
2156
|
-
process.send({ type: 'ready' })
|
|
2157
|
-
return
|
|
2158
|
-
} catch (e) {
|
|
2159
|
-
lastErr = e
|
|
2160
|
-
const transient = isTransientStartErr(e)
|
|
2161
|
-
bootProfile("worker:start-failed", { attempt, transient, error: e?.message || String(e) })
|
|
2162
|
-
process.stderr.write(`[channels-worker] start() failed (attempt ${attempt}/${MAX_START_ATTEMPTS}, transient=${transient}): ${e && (e.message || e)}\n`)
|
|
2163
|
-
if (!transient || attempt >= MAX_START_ATTEMPTS) break
|
|
2164
|
-
const backoff = BASE_BACKOFF_MS * attempt + Math.floor(Math.random() * BASE_BACKOFF_MS)
|
|
2165
|
-
await new Promise((r) => setTimeout(r, backoff))
|
|
2166
|
-
if (_channelsStopInFlight) return
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
// A stop landed while we were failing — let clean shutdown proceed, never exit over it.
|
|
2170
|
-
if (_channelsStopInFlight) return
|
|
2171
|
-
// Terminal failure: do NOT mask as a (degraded) ready. Exit non-zero so the
|
|
2172
|
-
// parent's exit-before-ready path respawns or rejects startRemote instead of
|
|
2173
|
-
// silently losing remote output forwarding.
|
|
2174
|
-
bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: lastErr?.message || String(lastErr) })
|
|
2175
|
-
process.stderr.write(`[channels-worker] start() giving up after ${MAX_START_ATTEMPTS} attempts: ${lastErr && (lastErr.message || lastErr)}\n`)
|
|
2176
|
-
// Exit 2 = terminal (non-transient) start failure: parent must reject, not respawn.
|
|
2177
|
-
// Exit 1 = exhausted transient retries: parent may respawn.
|
|
2178
|
-
process.exit(isTransientStartErr(lastErr) ? 1 : 2)
|
|
2179
|
-
})()
|
|
2180
|
-
}
|
|
2181
|
-
|
|
1
|
+
// Thin facade for the channels worker runtime. The implementation was split
|
|
2
|
+
// out into ./lib/worker-main.mjs plus focused lib/ modules; this file preserves
|
|
3
|
+
// the exact public API and boot side-effects by re-exporting from worker-main.
|
|
4
|
+
// Kept as the worker fork entrypoint (mixdog-session-runtime CHANNEL_WORKER_ENTRY).
|
|
2182
5
|
export {
|
|
2183
6
|
TOOL_DEFS,
|
|
2184
7
|
handleToolCall,
|
|
2185
8
|
handleToolCallWithBridgeRetry,
|
|
2186
9
|
init,
|
|
2187
|
-
|
|
10
|
+
instructions,
|
|
2188
11
|
isChannelBridgeActive,
|
|
2189
12
|
isChannelsDegraded,
|
|
2190
13
|
start,
|
|
2191
14
|
stop
|
|
2192
|
-
};
|
|
15
|
+
} from "./lib/worker-main.mjs";
|