mixdog 0.9.1 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -1
- package/scripts/_bench-cwc.json +20 -0
- package/scripts/agent-loop-policy-test.mjs +37 -0
- package/scripts/agent-parallel-smoke.mjs +54 -10
- package/scripts/background-task-meta-smoke.mjs +1 -1
- package/scripts/bench-run.mjs +262 -0
- package/scripts/compact-smoke.mjs +12 -0
- package/scripts/compact-trigger-migration-smoke.mjs +67 -1
- package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
- package/scripts/internal-comms-bench.mjs +727 -0
- package/scripts/internal-comms-smoke.mjs +75 -0
- package/scripts/lead-workflow-smoke.mjs +4 -4
- package/scripts/live-worker-smoke.mjs +9 -9
- package/scripts/output-style-bench.mjs +285 -0
- package/scripts/output-style-smoke.mjs +13 -10
- package/scripts/patch-replay.mjs +90 -0
- package/scripts/provider-stream-stall-test.mjs +276 -0
- package/scripts/provider-toolcall-test.mjs +599 -1
- package/scripts/routing-corpus.mjs +281 -0
- package/scripts/session-bench.mjs +1526 -0
- package/scripts/session-diag.mjs +595 -0
- package/scripts/task-bench.mjs +207 -0
- package/scripts/tool-failures.mjs +6 -6
- package/scripts/tool-smoke.mjs +306 -66
- package/scripts/toolcall-args-test.mjs +81 -0
- package/src/agents/debugger/AGENT.md +4 -4
- package/src/agents/heavy-worker/AGENT.md +4 -2
- package/src/agents/reviewer/AGENT.md +4 -4
- package/src/agents/worker/AGENT.md +4 -2
- package/src/app.mjs +10 -6
- package/src/defaults/{hidden-roles.json → agents.json} +7 -7
- package/src/examples/schedules/SCHEDULE.example.md +32 -0
- package/src/examples/webhooks/WEBHOOK.example.md +40 -0
- package/src/headless-role.mjs +14 -14
- package/src/help.mjs +1 -0
- package/src/lib/rules-builder.cjs +32 -54
- package/src/mixdog-session-runtime.mjs +710 -318
- package/src/output-styles/default.md +12 -7
- package/src/output-styles/minimal.md +25 -0
- package/src/output-styles/oneline.md +21 -0
- package/src/output-styles/simple.md +10 -9
- package/src/repl.mjs +12 -2
- package/src/rules/agent/00-common.md +7 -5
- package/src/rules/agent/30-explorer.md +7 -8
- package/src/rules/lead/01-general.md +3 -1
- package/src/rules/lead/lead-tool.md +7 -0
- package/src/rules/shared/01-tool.md +17 -12
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
- package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
- package/src/runtime/agent/orchestrator/config.mjs +3 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
- package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
- package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
- package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
- package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
- package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
- package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
- package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
- package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
- package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
- package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
- package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
- package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
- package/src/runtime/channels/backends/discord.mjs +99 -9
- package/src/runtime/channels/backends/telegram.mjs +501 -0
- package/src/runtime/channels/index.mjs +441 -1224
- package/src/runtime/channels/lib/config.mjs +54 -2
- package/src/runtime/channels/lib/format.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
- package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
- package/src/runtime/channels/lib/scheduler.mjs +1 -1
- package/src/runtime/channels/lib/telegram-format.mjs +283 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
- package/src/runtime/channels/lib/webhook.mjs +59 -31
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +184 -19
- package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
- package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
- package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
- package/src/runtime/memory/lib/memory.mjs +101 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
- package/src/runtime/memory/lib/session-ingest.mjs +107 -0
- package/src/runtime/memory/lib/trace-store.mjs +69 -22
- package/src/runtime/memory/tool-defs.mjs +6 -3
- package/src/runtime/shared/channel-notification-routing.mjs +12 -0
- package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
- package/src/runtime/shared/config.mjs +9 -0
- package/src/runtime/shared/llm/http-agent.mjs +12 -5
- package/src/runtime/shared/schedules-store.mjs +21 -19
- package/src/runtime/shared/tool-surface.mjs +98 -13
- package/src/runtime/shared/transcript-writer.mjs +129 -0
- package/src/runtime/shared/update-checker.mjs +214 -0
- package/src/standalone/agent-tool.mjs +255 -109
- package/src/standalone/channel-admin.mjs +133 -40
- package/src/standalone/channel-worker.mjs +8 -291
- package/src/standalone/explore-tool.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +3 -1
- package/src/standalone/provider-admin.mjs +11 -0
- package/src/standalone/usage-dashboard.mjs +1 -1
- package/src/tui/App.jsx +2096 -732
- package/src/tui/components/ConfirmBar.jsx +47 -0
- package/src/tui/components/ContextPanel.jsx +5 -3
- package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
- package/src/tui/components/Markdown.jsx +22 -98
- package/src/tui/components/Message.jsx +14 -35
- package/src/tui/components/Picker.jsx +87 -12
- package/src/tui/components/PromptInput.jsx +83 -7
- package/src/tui/components/QueuedCommands.jsx +1 -1
- package/src/tui/components/SlashCommandPalette.jsx +8 -5
- package/src/tui/components/Spinner.jsx +7 -7
- package/src/tui/components/StatusLine.jsx +40 -21
- package/src/tui/components/TextEntryPanel.jsx +51 -7
- package/src/tui/components/ToolExecution.jsx +170 -98
- package/src/tui/components/TurnDone.jsx +4 -4
- package/src/tui/components/UsagePanel.jsx +1 -1
- package/src/tui/components/tool-output-format.mjs +159 -21
- package/src/tui/components/tool-output-format.test.mjs +87 -0
- package/src/tui/display-width.mjs +69 -0
- package/src/tui/display-width.test.mjs +35 -0
- package/src/tui/dist/index.mjs +6965 -2391
- package/src/tui/engine.mjs +287 -126
- package/src/tui/index.jsx +117 -7
- package/src/tui/keyboard-protocol.mjs +42 -0
- package/src/tui/lib/voice-recorder.mjs +453 -0
- package/src/tui/markdown/format-token.mjs +129 -76
- package/src/tui/markdown/format-token.test.mjs +61 -19
- package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
- package/src/tui/markdown/render-ansi.test.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +167 -0
- package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
- package/src/tui/markdown/table-layout.mjs +9 -9
- package/src/tui/paste-attachments.mjs +0 -11
- package/src/tui/prompt-history-store.mjs +129 -0
- package/src/tui/prompt-history-store.test.mjs +52 -0
- package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
- package/src/tui/theme.mjs +41 -657
- package/src/tui/themes/base.mjs +86 -0
- package/src/tui/themes/basic.mjs +85 -0
- package/src/tui/themes/catppuccin.mjs +72 -0
- package/src/tui/themes/dracula.mjs +70 -0
- package/src/tui/themes/everforest.mjs +71 -0
- package/src/tui/themes/gruvbox.mjs +71 -0
- package/src/tui/themes/index.mjs +71 -0
- package/src/tui/themes/indigo.mjs +78 -0
- package/src/tui/themes/kanagawa.mjs +80 -0
- package/src/tui/themes/light.mjs +81 -0
- package/src/tui/themes/nord.mjs +72 -0
- package/src/tui/themes/onedark.mjs +16 -0
- package/src/tui/themes/rosepine.mjs +70 -0
- package/src/tui/themes/teal.mjs +81 -0
- package/src/tui/themes/tokyonight.mjs +79 -0
- package/src/tui/themes/utils.mjs +106 -0
- package/src/tui/themes/warm.mjs +79 -0
- package/src/tui/transcript-tool-failures.mjs +13 -2
- package/src/ui/markdown.mjs +1 -1
- package/src/ui/model-display.mjs +2 -2
- package/src/ui/statusline.mjs +26 -27
- package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
- package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
- package/src/workflows/default/WORKFLOW.md +39 -12
- package/src/workflows/sequential/WORKFLOW.md +46 -0
- package/src/workflows/solo/WORKFLOW.md +7 -0
- package/vendor/ink/build/display-width.js +62 -0
- package/vendor/ink/build/ink.js +100 -12
- package/vendor/ink/build/measure-text.js +4 -1
- package/vendor/ink/build/output.js +115 -9
- package/vendor/ink/build/render-node-to-output.js +4 -1
- package/vendor/ink/build/render.js +4 -0
- package/src/output-styles/extreme-simple.md +0 -20
- package/src/rules/lead/04-workflow.md +0 -51
- package/src/workflows/default/workflow.json +0 -13
- package/src/workflows/solo/workflow.json +0 -7
|
@@ -4,8 +4,7 @@ import {
|
|
|
4
4
|
ListToolsRequestSchema,
|
|
5
5
|
CallToolRequestSchema
|
|
6
6
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
-
import { spawn
|
|
8
|
-
import * as crypto from "crypto";
|
|
7
|
+
import { spawn } from "child_process";
|
|
9
8
|
import * as fs from "fs";
|
|
10
9
|
import * as http from "http";
|
|
11
10
|
import * as os from "os";
|
|
@@ -33,7 +32,8 @@ import { startCliWorker } from "./lib/cli-worker-host.mjs";
|
|
|
33
32
|
import {
|
|
34
33
|
OutputForwarder,
|
|
35
34
|
discoverSessionBoundTranscript,
|
|
36
|
-
findLatestTranscriptByMtime
|
|
35
|
+
findLatestTranscriptByMtime,
|
|
36
|
+
sameResolvedPath
|
|
37
37
|
} from "./lib/output-forwarder.mjs";
|
|
38
38
|
import { controlClaudeSession } from "./lib/session-control.mjs";
|
|
39
39
|
import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./lib/state-file.mjs";
|
|
@@ -163,15 +163,58 @@ ${err instanceof Error ? err.stack : ""}
|
|
|
163
163
|
}
|
|
164
164
|
crashLogging = false;
|
|
165
165
|
}
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
// Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
|
|
167
|
+
// after an unhandled rejection whose async state was already corrupted
|
|
168
|
+
// (observed: EPERM on active-instance.json rename retry), so it spun
|
|
169
|
+
// forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
|
|
170
|
+
// Benign whitelist: transient EPERM/EACCES/EBUSY on the active-instance
|
|
171
|
+
// rename path is expected under Windows file-lock contention and is
|
|
172
|
+
// already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
|
|
173
|
+
// occurrence must NOT be fatal, only a run of 3+ in a row without an
|
|
174
|
+
// intervening distinct/successful event.
|
|
175
|
+
const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
176
|
+
const BENIGN_CRASH_FATAL_THRESHOLD = 3;
|
|
177
|
+
// "In a row" needs a time dimension: benign errors minutes/hours apart are
|
|
178
|
+
// independent contention events, not a corrupted-state run. Only count a
|
|
179
|
+
// streak when hits land within this window of the previous one.
|
|
180
|
+
const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
|
|
181
|
+
let _benignCrashStreak = 0;
|
|
182
|
+
let _lastBenignCrashAt = 0;
|
|
183
|
+
function _isBenignCrash(err) {
|
|
184
|
+
const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
|
|
185
|
+
return BENIGN_CRASH_CODES.has(code);
|
|
186
|
+
}
|
|
187
|
+
function _fatalCrash(label, err) {
|
|
188
|
+
logCrash(label, err);
|
|
189
|
+
const benign = _isBenignCrash(err);
|
|
190
|
+
if (benign) {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
_benignCrashStreak = (now - _lastBenignCrashAt) <= BENIGN_CRASH_STREAK_WINDOW_MS
|
|
193
|
+
? _benignCrashStreak + 1
|
|
194
|
+
: 1;
|
|
195
|
+
_lastBenignCrashAt = now;
|
|
196
|
+
if (_benignCrashStreak < BENIGN_CRASH_FATAL_THRESHOLD) return;
|
|
197
|
+
} else {
|
|
198
|
+
_benignCrashStreak = 0;
|
|
199
|
+
}
|
|
200
|
+
Promise.resolve()
|
|
201
|
+
.then(() => (typeof stop === "function" ? stop(`fatal:${label}`) : null))
|
|
202
|
+
.catch(() => {})
|
|
203
|
+
.finally(() => {
|
|
204
|
+
try { process.exitCode = 1; } catch {}
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
207
|
+
// Best-effort stop() may itself hang (e.g. IPC to a dead child) — a bare
|
|
208
|
+
// .finally() would then never fire and we're back to a zombie. Force the
|
|
209
|
+
// exit unconditionally after a short grace window regardless of outcome.
|
|
210
|
+
setTimeout(() => { try { process.exit(1); } catch {} }, 3000).unref?.();
|
|
211
|
+
}
|
|
212
|
+
process.on("unhandledRejection", (err) => _fatalCrash("unhandled rejection", err));
|
|
213
|
+
process.on("uncaughtException", (err) => _fatalCrash("uncaught exception", err));
|
|
168
214
|
if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
|
|
169
215
|
process.exit(0);
|
|
170
216
|
}
|
|
171
217
|
const _isWorkerMode = process.env.MIXDOG_WORKER_MODE === '1'
|
|
172
|
-
const _isCliOwnedMode = process.env.MIXDOG_CLI_OWNED === '1'
|
|
173
|
-
const _isChannelDaemonMode = process.env.MIXDOG_CHANNEL_DAEMON === '1'
|
|
174
|
-
const CHANNEL_DAEMON_IDLE_TTL_MS = Math.max(0, Number(process.env.MIXDOG_CHANNEL_IDLE_TTL_MS) || 60_000)
|
|
175
218
|
const _bootLogEarly = path.join(
|
|
176
219
|
DATA_DIR || path.join(os.tmpdir(), "mixdog"),
|
|
177
220
|
"boot.log"
|
|
@@ -329,10 +372,6 @@ const INSTRUCTIONS = "";
|
|
|
329
372
|
// never `connect()`ed to any transport, so `.notification()` silently
|
|
330
373
|
// threw 'Not connected' inside the SDK and every call was dropped by an
|
|
331
374
|
// outer `.catch(() => {})`. That regression is what this path replaces.
|
|
332
|
-
let _channelNotifyBusSeq = 0;
|
|
333
|
-
const CHANNEL_NOTIFY_BUS_FILE = path.join(RUNTIME_ROOT, 'channel-notifications.jsonl');
|
|
334
|
-
const CHANNEL_NOTIFY_BUS_MAX_BYTES = 5 * 1024 * 1024;
|
|
335
|
-
|
|
336
375
|
function normalizeChannelNotifyParams(method, params) {
|
|
337
376
|
if (method === 'notifications/claude/channel' && params && params.meta) {
|
|
338
377
|
const m = {};
|
|
@@ -345,29 +384,6 @@ function normalizeChannelNotifyParams(method, params) {
|
|
|
345
384
|
return params;
|
|
346
385
|
}
|
|
347
386
|
|
|
348
|
-
function publishChannelNotify(method, params) {
|
|
349
|
-
try {
|
|
350
|
-
fs.mkdirSync(RUNTIME_ROOT, { recursive: true });
|
|
351
|
-
try {
|
|
352
|
-
const st = fs.statSync(CHANNEL_NOTIFY_BUS_FILE);
|
|
353
|
-
if (st.size > CHANNEL_NOTIFY_BUS_MAX_BYTES) {
|
|
354
|
-
try { fs.unlinkSync(CHANNEL_NOTIFY_BUS_FILE + '.1'); } catch {}
|
|
355
|
-
try { fs.renameSync(CHANNEL_NOTIFY_BUS_FILE, CHANNEL_NOTIFY_BUS_FILE + '.1'); } catch {}
|
|
356
|
-
}
|
|
357
|
-
} catch {}
|
|
358
|
-
const item = {
|
|
359
|
-
id: `${Date.now()}-${process.pid}-${++_channelNotifyBusSeq}`,
|
|
360
|
-
ts: Date.now(),
|
|
361
|
-
pid: process.pid,
|
|
362
|
-
method,
|
|
363
|
-
params,
|
|
364
|
-
};
|
|
365
|
-
fs.appendFileSync(CHANNEL_NOTIFY_BUS_FILE, JSON.stringify(item) + '\n');
|
|
366
|
-
} catch (err) {
|
|
367
|
-
try { process.stderr.write(`mixdog channels: notify bus write failed: ${err && err.message || err}\n`); } catch {}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
387
|
function sendNotifyToParent(method, params) {
|
|
372
388
|
// CC channel schema requires meta: Record<string,string> (channelNotification.ts).
|
|
373
389
|
// Coerce every meta value to string so a non-string (e.g. a Discord
|
|
@@ -375,9 +391,8 @@ function sendNotifyToParent(method, params) {
|
|
|
375
391
|
// silent_to_agent stays boolean — an internal routing flag the daemon
|
|
376
392
|
// router / agentNotify consume (=== true) before the CC zod boundary.
|
|
377
393
|
const outParams = normalizeChannelNotifyParams(method, params);
|
|
378
|
-
publishChannelNotify(method, outParams);
|
|
379
394
|
if (!process.send) {
|
|
380
|
-
try { process.stderr.write(`mixdog channels: notify
|
|
395
|
+
try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
|
|
381
396
|
return;
|
|
382
397
|
}
|
|
383
398
|
try {
|
|
@@ -454,6 +469,9 @@ const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
|
|
|
454
469
|
const TURN_END_BASENAME = path.basename(TURN_END_FILE);
|
|
455
470
|
const TURN_END_DIR = path.dirname(TURN_END_FILE);
|
|
456
471
|
let turnEndWatcher = null;
|
|
472
|
+
// Config hot-reload watcher (installed by start(); torn down by stop()).
|
|
473
|
+
let _configWatcher = null;
|
|
474
|
+
let _reloadDebounce = null;
|
|
457
475
|
if (!_isWorkerMode) {
|
|
458
476
|
removeFileIfExists(TURN_END_FILE);
|
|
459
477
|
turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
|
|
@@ -488,12 +506,13 @@ function pickUsableTranscriptPath(bound, previousPath) {
|
|
|
488
506
|
return sessionIdFromTranscriptPath(previousPath) === bound.sessionId ? previousPath : "";
|
|
489
507
|
}
|
|
490
508
|
const forwarder = new OutputForwarder({
|
|
491
|
-
send: async (ch, text) => {
|
|
509
|
+
send: async (ch, text, opts) => {
|
|
492
510
|
if (!channelBridgeActive) {
|
|
493
511
|
throw new Error("send() called while channel bridge is inactive");
|
|
494
512
|
}
|
|
495
|
-
await backend.sendMessage(ch, text);
|
|
513
|
+
await backend.sendMessage(ch, text, opts);
|
|
496
514
|
},
|
|
515
|
+
formatOutgoing: (text) => backend.formatOutgoing ? backend.formatOutgoing(text) : text,
|
|
497
516
|
recordAssistantTurn: async () => {
|
|
498
517
|
},
|
|
499
518
|
react: (ch, mid, emoji) => {
|
|
@@ -513,9 +532,9 @@ forwarder.setOnIdle(() => {
|
|
|
513
532
|
// also sets this, but that path only runs when the event pipeline starts
|
|
514
533
|
// (webhook enabled or event rules present). Without an event pipeline the
|
|
515
534
|
// forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
|
|
516
|
-
// non-owner
|
|
517
|
-
//
|
|
518
|
-
forwarder.setOwnerGetter(() => bridgeRuntimeConnected
|
|
535
|
+
// non-owner process forward transcript output (duplicate Discord sends).
|
|
536
|
+
// The closure reads bridgeRuntimeConnected at call time.
|
|
537
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
|
|
519
538
|
function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
520
539
|
if (!transcriptPath) return;
|
|
521
540
|
forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
|
|
@@ -536,9 +555,68 @@ function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
|
536
555
|
});
|
|
537
556
|
}
|
|
538
557
|
}
|
|
558
|
+
// ── Pending-transcript re-arm ────────────────────────────────────────
|
|
559
|
+
// fs.watch cannot watch a file that does not exist yet, so when we bind a
|
|
560
|
+
// session's known-but-not-yet-written transcript path, OutputForwarder.
|
|
561
|
+
// startWatch() silently fails (watch.start.catch) and the file's later
|
|
562
|
+
// creation is never observed. This bounded poll bridges that gap: once the
|
|
563
|
+
// transcript-writer creates the file, we install the watch and forward the
|
|
564
|
+
// backlog. It self-cancels on success, on timeout, and whenever a fresh
|
|
565
|
+
// (re)bind supersedes it — so no timers leak and no double-forward occurs.
|
|
566
|
+
let _pendingRearmTimer = null;
|
|
567
|
+
const PENDING_REARM_INTERVAL_MS = 250;
|
|
568
|
+
const PENDING_REARM_MAX_MS = 60_000;
|
|
569
|
+
function cancelPendingTranscriptRearm() {
|
|
570
|
+
if (_pendingRearmTimer) {
|
|
571
|
+
clearTimeout(_pendingRearmTimer);
|
|
572
|
+
_pendingRearmTimer = null;
|
|
573
|
+
dropTrace("rebind.rearm.cancel");
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
function schedulePendingTranscriptRearm(channelId, boundPath) {
|
|
577
|
+
cancelPendingTranscriptRearm();
|
|
578
|
+
if (!boundPath) return;
|
|
579
|
+
const deadline = Date.now() + PENDING_REARM_MAX_MS;
|
|
580
|
+
dropTrace("rebind.rearm.schedule", { channelId, path: boundPath });
|
|
581
|
+
const tick = () => {
|
|
582
|
+
_pendingRearmTimer = null;
|
|
583
|
+
// A different transcript got bound in the meantime — abandon this poll.
|
|
584
|
+
if (forwarder.transcriptPath !== boundPath) {
|
|
585
|
+
dropTrace("rebind.rearm.superseded", { path: boundPath, now: forwarder.transcriptPath || "(none)" });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// Ownership may have been lost (bridge deactivated / superseded owner)
|
|
589
|
+
// while this poll was pending. Do not reinstall the fs.watch handle after
|
|
590
|
+
// teardown; startWatch() is not owner-gated so guard it here.
|
|
591
|
+
if (!bridgeRuntimeConnected) {
|
|
592
|
+
dropTrace("rebind.rearm.not-owner", { path: boundPath });
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (fs.existsSync(boundPath)) {
|
|
596
|
+
dropTrace("rebind.rearm.fire", { channelId, path: boundPath });
|
|
597
|
+
forwarder.startWatch();
|
|
598
|
+
void forwarder.forwardNewText().catch((err) => {
|
|
599
|
+
try { process.stderr.write(`mixdog: rearm forwardNewText rejection: ${err?.stack || err}\n`); } catch {}
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (Date.now() >= deadline) {
|
|
604
|
+
dropTrace("rebind.rearm.timeout", { channelId, path: boundPath });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
_pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
|
|
608
|
+
_pendingRearmTimer?.unref?.();
|
|
609
|
+
};
|
|
610
|
+
_pendingRearmTimer = setTimeout(tick, PENDING_REARM_INTERVAL_MS);
|
|
611
|
+
_pendingRearmTimer?.unref?.();
|
|
612
|
+
}
|
|
539
613
|
async function rebindTranscriptContext(channelId, options = {}) {
|
|
540
614
|
const previousPath = options.previousPath ?? "";
|
|
541
615
|
const mode = options.mode ?? "same";
|
|
616
|
+
// A new (re)bind supersedes any pending re-arm poll left over from a prior
|
|
617
|
+
// bind of a not-yet-existing transcript, so we never leak timers or
|
|
618
|
+
// double-forward once the fresh bind takes over.
|
|
619
|
+
cancelPendingTranscriptRearm();
|
|
542
620
|
const explicitTranscriptPath = typeof options.transcriptPath === "string" ? options.transcriptPath.trim() : "";
|
|
543
621
|
if (explicitTranscriptPath) {
|
|
544
622
|
let explicitExists = false;
|
|
@@ -561,7 +639,17 @@ async function rebindTranscriptContext(channelId, options = {}) {
|
|
|
561
639
|
}
|
|
562
640
|
let sawPendingTranscript = false;
|
|
563
641
|
let pendingSessionId = "";
|
|
564
|
-
|
|
642
|
+
// Distinct from sawPendingTranscript/pendingSessionId (which only track the
|
|
643
|
+
// sessionId): remember the FULL not-yet-on-disk candidate — its concrete
|
|
644
|
+
// transcriptPath (from the session record) + cwd — so we can bind it after
|
|
645
|
+
// the loop even though the `.jsonl` does not exist yet. This breaks the
|
|
646
|
+
// chicken-and-egg deadlock where the first assistant reply is only written
|
|
647
|
+
// seconds after inbound time.
|
|
648
|
+
let pendingTranscriptPath = "";
|
|
649
|
+
let pendingTranscriptCwd = null;
|
|
650
|
+
const maxAttempts = Number.isFinite(options.maxAttempts) ? Math.max(1, Math.floor(options.maxAttempts)) : 30;
|
|
651
|
+
const pollMs = Number.isFinite(options.pollMs) ? Math.max(25, Math.floor(options.pollMs)) : 150;
|
|
652
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
565
653
|
const bound = discoverSessionBoundTranscript();
|
|
566
654
|
if (bound?.exists) {
|
|
567
655
|
const acceptable = mode === "same" || !previousPath || bound.transcriptPath !== previousPath;
|
|
@@ -583,12 +671,63 @@ async function rebindTranscriptContext(channelId, options = {}) {
|
|
|
583
671
|
} else if (bound?.sessionId) {
|
|
584
672
|
sawPendingTranscript = true;
|
|
585
673
|
pendingSessionId = bound.sessionId;
|
|
674
|
+
if (bound.transcriptPath) {
|
|
675
|
+
pendingTranscriptPath = bound.transcriptPath;
|
|
676
|
+
pendingTranscriptCwd = bound.sessionCwd ?? null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
680
|
+
}
|
|
681
|
+
// No existing transcript surfaced during the loop, but the session record
|
|
682
|
+
// named a concrete transcript path that simply is not on disk yet. Bind it
|
|
683
|
+
// now: applyTranscriptBinding persists state.transcriptPath (so the
|
|
684
|
+
// getPersistedTranscriptPath() fallback works for future rebinds) and calls
|
|
685
|
+
// forwarder.startWatch(). Because fs.watch cannot watch a missing file, we
|
|
686
|
+
// also schedule a bounded re-arm poll that installs the watch + catches up
|
|
687
|
+
// once the transcript-writer creates the file.
|
|
688
|
+
if (pendingTranscriptPath) {
|
|
689
|
+
// Same wrong-session guard as the exists:true path: refuse to bind a
|
|
690
|
+
// candidate that conflicts with an explicit previousPath when this is a
|
|
691
|
+
// switch (mode!=="same").
|
|
692
|
+
const acceptable = mode === "same" || !previousPath || pendingTranscriptPath !== previousPath;
|
|
693
|
+
if (acceptable) {
|
|
694
|
+
dropTrace("rebind.pending.bind", { channelId, path: pendingTranscriptPath, sessionId: pendingSessionId });
|
|
695
|
+
// If the persisted cursor belongs to THIS transcript, resume from it;
|
|
696
|
+
// otherwise this is a freshly-discovered session transcript that was
|
|
697
|
+
// never bound before (the chicken-and-egg case), so forward from the
|
|
698
|
+
// start of file. Binding with catchUpFromPersisted against a
|
|
699
|
+
// non-matching persisted path would set the read cursor to EOF and
|
|
700
|
+
// silently skip the first reply (output-forwarder setContext).
|
|
701
|
+
// Resume from the persisted cursor only when it belongs to THIS
|
|
702
|
+
// transcript; otherwise forward from the start of file (see comment
|
|
703
|
+
// above). sameResolvedPath handles Windows case-insensitive paths.
|
|
704
|
+
const samePersisted = sameResolvedPath(getPersistedTranscriptPath(), pendingTranscriptPath);
|
|
705
|
+
applyTranscriptBinding(channelId, pendingTranscriptPath, {
|
|
706
|
+
replayFromStart: !samePersisted,
|
|
707
|
+
catchUpFromPersisted: samePersisted,
|
|
708
|
+
cwd: pendingTranscriptCwd,
|
|
709
|
+
persistStatus: options.persistStatus,
|
|
710
|
+
});
|
|
711
|
+
const boundPath = forwarder.transcriptPath || pendingTranscriptPath;
|
|
712
|
+
if (fs.existsSync(boundPath)) {
|
|
713
|
+
// Raced: file appeared between discovery and bind — forward immediately.
|
|
714
|
+
await forwarder.forwardNewText();
|
|
715
|
+
} else {
|
|
716
|
+
schedulePendingTranscriptRearm(channelId, boundPath);
|
|
717
|
+
}
|
|
718
|
+
process.stderr.write(`mixdog: rebind pending: bound not-yet-existing transcript ${boundPath}\n`);
|
|
719
|
+
return pendingTranscriptPath;
|
|
586
720
|
}
|
|
587
|
-
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
588
721
|
}
|
|
589
|
-
if (previousPath) {
|
|
722
|
+
if (previousPath && options.fallbackPrevious !== false) {
|
|
590
723
|
applyTranscriptBinding(channelId, previousPath, { catchUpFromPersisted: true, cwd: statusState.read().sessionCwd });
|
|
591
|
-
|
|
724
|
+
if (fs.existsSync(previousPath)) {
|
|
725
|
+
await forwarder.forwardNewText();
|
|
726
|
+
} else {
|
|
727
|
+
// Same not-yet-on-disk situation as the pending branch: arm a poll so
|
|
728
|
+
// forwarding starts when the file is created.
|
|
729
|
+
schedulePendingTranscriptRearm(channelId, forwarder.transcriptPath || previousPath);
|
|
730
|
+
}
|
|
592
731
|
process.stderr.write(`mixdog: rebind fallback: bound previous transcript ${previousPath}\n`);
|
|
593
732
|
return previousPath;
|
|
594
733
|
}
|
|
@@ -636,688 +775,15 @@ const ACTIVE_OWNER_STALE_MS = 1e4;
|
|
|
636
775
|
// never blocks process exit. Single JSON atomic write, no measurable load.
|
|
637
776
|
const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
638
777
|
let ownerHeartbeatTimer = null;
|
|
639
|
-
let channelDaemonIdleTimer = null;
|
|
640
|
-
let channelDaemonLastClientAt = Date.now();
|
|
641
|
-
let channelDaemonBackgroundLogAt = 0;
|
|
642
|
-
const CHANNEL_CLIENT_DIR = path.join(RUNTIME_ROOT, 'channel-clients');
|
|
643
|
-
|
|
644
|
-
function pidAlive(pid) {
|
|
645
|
-
const n = Number(pid);
|
|
646
|
-
if (!Number.isInteger(n) || n <= 0) return false;
|
|
647
|
-
try {
|
|
648
|
-
process.kill(n, 0);
|
|
649
|
-
return true;
|
|
650
|
-
} catch (e) {
|
|
651
|
-
return e?.code === 'EPERM';
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function countLiveChannelClients() {
|
|
656
|
-
let live = 0;
|
|
657
|
-
const now = Date.now();
|
|
658
|
-
try {
|
|
659
|
-
fs.mkdirSync(CHANNEL_CLIENT_DIR, { recursive: true });
|
|
660
|
-
for (const file of fs.readdirSync(CHANNEL_CLIENT_DIR)) {
|
|
661
|
-
if (!file.endsWith('.json')) continue;
|
|
662
|
-
const full = path.join(CHANNEL_CLIENT_DIR, file);
|
|
663
|
-
let item = null;
|
|
664
|
-
let st = null;
|
|
665
|
-
try {
|
|
666
|
-
st = fs.statSync(full);
|
|
667
|
-
item = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
668
|
-
} catch {
|
|
669
|
-
try { fs.unlinkSync(full); } catch {}
|
|
670
|
-
continue;
|
|
671
|
-
}
|
|
672
|
-
const pid = Number(item?.pid ?? file.replace(/\.json$/, ''));
|
|
673
|
-
const fresh = now - Math.max(Number(item?.updatedAt) || 0, st.mtimeMs) < 20_000;
|
|
674
|
-
if (pidAlive(pid) && fresh) {
|
|
675
|
-
live += 1;
|
|
676
|
-
} else {
|
|
677
|
-
try { fs.unlinkSync(full); } catch {}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
} catch {}
|
|
681
|
-
return live;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function hasChannelBackgroundWork() {
|
|
685
|
-
if (!channelBridgeActive || !bridgeRuntimeConnected) return false;
|
|
686
|
-
if (config.webhook?.enabled === true) return true;
|
|
687
|
-
if (Array.isArray(config.events?.rules) && config.events.rules.length > 0) return true;
|
|
688
|
-
if (Array.isArray(config.nonInteractive) && config.nonInteractive.length > 0) return true;
|
|
689
|
-
if (Array.isArray(config.interactive) && config.interactive.length > 0) return true;
|
|
690
|
-
return false;
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
function checkChannelDaemonIdle() {
|
|
694
|
-
if (!_isChannelDaemonMode || CHANNEL_DAEMON_IDLE_TTL_MS <= 0) return;
|
|
695
|
-
const liveClients = countLiveChannelClients();
|
|
696
|
-
if (liveClients > 0) {
|
|
697
|
-
channelDaemonLastClientAt = Date.now();
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
if (hasChannelBackgroundWork()) {
|
|
701
|
-
const now = Date.now();
|
|
702
|
-
if (now - channelDaemonBackgroundLogAt > 60_000) {
|
|
703
|
-
channelDaemonBackgroundLogAt = now;
|
|
704
|
-
try { process.stderr.write('[channels-worker] daemon idle: keeping alive for configured background work\n'); } catch {}
|
|
705
|
-
}
|
|
706
|
-
channelDaemonLastClientAt = Date.now();
|
|
707
|
-
return;
|
|
708
|
-
}
|
|
709
|
-
if (Date.now() - channelDaemonLastClientAt < CHANNEL_DAEMON_IDLE_TTL_MS) return;
|
|
710
|
-
try { process.stderr.write(`[channels-worker] daemon idle TTL elapsed (${CHANNEL_DAEMON_IDLE_TTL_MS}ms) — shutting down\n`); } catch {}
|
|
711
|
-
stop()
|
|
712
|
-
.then(() => process.exit(0))
|
|
713
|
-
.catch((e) => {
|
|
714
|
-
try { process.stderr.write(`[channels-worker] daemon idle shutdown failed: ${e?.message || e}\n`); } catch {}
|
|
715
|
-
process.exit(1);
|
|
716
|
-
});
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
function startChannelDaemonIdleMonitor() {
|
|
720
|
-
if (!_isChannelDaemonMode || CHANNEL_DAEMON_IDLE_TTL_MS <= 0 || channelDaemonIdleTimer) return;
|
|
721
|
-
channelDaemonLastClientAt = Date.now();
|
|
722
|
-
channelDaemonIdleTimer = setInterval(checkChannelDaemonIdle, 5000);
|
|
723
|
-
channelDaemonIdleTimer.unref?.();
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function stopChannelDaemonIdleMonitor() {
|
|
727
|
-
if (!channelDaemonIdleTimer) return;
|
|
728
|
-
clearInterval(channelDaemonIdleTimer);
|
|
729
|
-
channelDaemonIdleTimer = null;
|
|
730
|
-
}
|
|
731
778
|
// Owner gating here is multi-process runtime coordination: only the active
|
|
732
779
|
// bindingReady gates all send paths until the boot-time refreshBridgeOwnership
|
|
733
780
|
// ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
|
|
734
781
|
// emissions fired within the first ~few hundred ms after restart drop because
|
|
735
782
|
// the Discord backend binding has not yet been established.
|
|
736
783
|
let bindingReadyStatus = "pending";
|
|
737
|
-
// Channel-flag detection result, stored at module scope so the worker-mode
|
|
738
|
-
// ready IPC can forward it to the daemon for caching across respawns.
|
|
739
|
-
let _channelFlagDetected = false;
|
|
740
784
|
let _bindingReadyResolve;
|
|
741
785
|
const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
|
|
742
786
|
dropTrace("bindingReady.create", { status: bindingReadyStatus });
|
|
743
|
-
// owner runs webhook/event ticks. It is not webhook HTTP authentication.
|
|
744
|
-
let proxyMode = false;
|
|
745
|
-
let ownerHttpPort = 0;
|
|
746
|
-
let ownerHttpServer = null;
|
|
747
|
-
const PROXY_PORT_MIN = 3460;
|
|
748
|
-
const PROXY_PORT_MAX = 3467;
|
|
749
|
-
// Per-owner-process auth secret. Generated once at HTTP server start and
|
|
750
|
-
// published into runtime/owner-secret-<instanceId>.json with 0o600 perms so
|
|
751
|
-
// only the owner UID can read it back. requireOwnerToken below checks THIS
|
|
752
|
-
// secret (not the public-by-/ping instanceId) so any local caller that
|
|
753
|
-
// scrapes /ping cannot forge owner-side calls. The file is keyed on the
|
|
754
|
-
// owner's INSTANCE_ID — the SAME identifier published into active-instance
|
|
755
|
-
// as `instanceId` and validated by requireOwnerToken's x-owner-instance
|
|
756
|
-
// header check — so proxy readers can resolve the path off readActiveInstance()
|
|
757
|
-
// without depending on getActiveOwnerPid(), which prefers ownerLeadPid/
|
|
758
|
-
// terminalLeadPid/supervisor_pid and would diverge from process.pid in
|
|
759
|
-
// supervisor-backed sessions.
|
|
760
|
-
let OWNER_SECRET = "";
|
|
761
|
-
function getOwnerSecretPath(instanceId) {
|
|
762
|
-
return path.join(RUNTIME_ROOT, `owner-secret-${String(instanceId)}.json`);
|
|
763
|
-
}
|
|
764
|
-
function publishOwnerSecret(secret) {
|
|
765
|
-
const file = getOwnerSecretPath(INSTANCE_ID);
|
|
766
|
-
try { ensureDir(RUNTIME_ROOT); } catch {}
|
|
767
|
-
// Best-effort restrictive write: O_CREAT|O_TRUNC|O_WRONLY with mode 0o600.
|
|
768
|
-
// On Windows mode bits are largely ignored, but the file still lives in
|
|
769
|
-
// the per-user tmp dir; an attacker without the same UID cannot read it.
|
|
770
|
-
try { fs.unlinkSync(file); } catch {}
|
|
771
|
-
const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY, 0o600);
|
|
772
|
-
try {
|
|
773
|
-
fs.writeSync(fd, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, secret, updatedAt: Date.now() }));
|
|
774
|
-
} finally {
|
|
775
|
-
try { fs.closeSync(fd); } catch {}
|
|
776
|
-
}
|
|
777
|
-
try { fs.chmodSync(file, 0o600); } catch {}
|
|
778
|
-
}
|
|
779
|
-
function clearOwnerSecret() {
|
|
780
|
-
try { fs.unlinkSync(getOwnerSecretPath(INSTANCE_ID)); } catch {}
|
|
781
|
-
}
|
|
782
|
-
function readOwnerSecretFor(ownerInstanceId) {
|
|
783
|
-
if (!ownerInstanceId) return "";
|
|
784
|
-
try {
|
|
785
|
-
const raw = fs.readFileSync(getOwnerSecretPath(ownerInstanceId), "utf8");
|
|
786
|
-
const parsed = JSON.parse(raw);
|
|
787
|
-
return typeof parsed?.secret === "string" ? parsed.secret : "";
|
|
788
|
-
} catch {
|
|
789
|
-
return "";
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
async function proxyRequest(endpoint, method, body) {
|
|
793
|
-
return new Promise((resolve) => {
|
|
794
|
-
const url = new URL(`http://127.0.0.1:${ownerHttpPort}${endpoint}`);
|
|
795
|
-
// Auth: read the owner's per-process secret from the restricted
|
|
796
|
-
// owner-secret file (0o600). The instanceId header is kept only as a
|
|
797
|
-
// secondary diagnostic — requireOwnerToken on the owner side checks
|
|
798
|
-
// the secret, not the instanceId.
|
|
799
|
-
const active = readActiveInstance();
|
|
800
|
-
const ownerInstanceId = active?.instanceId || INSTANCE_ID;
|
|
801
|
-
// Key the secret-file lookup on the owner's published instanceId — the
|
|
802
|
-
// SAME identifier the owner used when writing owner-secret-<instanceId>.json
|
|
803
|
-
// (publishOwnerSecret above) and what requireOwnerToken's x-owner-instance
|
|
804
|
-
// header check compares against. Do NOT route this through
|
|
805
|
-
// getActiveOwnerPid(active): that helper prefers ownerLeadPid /
|
|
806
|
-
// terminalLeadPid / supervisor_pid, which in a supervisor-backed session
|
|
807
|
-
// diverge from the owner-HTTP process.pid (== owner's INSTANCE_ID),
|
|
808
|
-
// causing the proxy to read owner-secret-<supervisorPid>.json while the
|
|
809
|
-
// owner wrote owner-secret-<process.pid>.json → empty secret → 401.
|
|
810
|
-
const ownerSecret = readOwnerSecretFor(ownerInstanceId);
|
|
811
|
-
if (!ownerSecret) {
|
|
812
|
-
resolve({ ok: false, error: "owner secret unavailable (file missing or unreadable)" });
|
|
813
|
-
return;
|
|
814
|
-
}
|
|
815
|
-
const reqOpts = {
|
|
816
|
-
hostname: "127.0.0.1",
|
|
817
|
-
port: ownerHttpPort,
|
|
818
|
-
path: url.pathname + url.search,
|
|
819
|
-
method,
|
|
820
|
-
headers: {
|
|
821
|
-
"Content-Type": "application/json",
|
|
822
|
-
"x-owner-token": ownerSecret,
|
|
823
|
-
"x-owner-instance": ownerInstanceId,
|
|
824
|
-
},
|
|
825
|
-
timeout: 3e4
|
|
826
|
-
};
|
|
827
|
-
const req = http.request(reqOpts, (res) => {
|
|
828
|
-
let data = "";
|
|
829
|
-
res.on("data", (chunk) => {
|
|
830
|
-
data += chunk;
|
|
831
|
-
});
|
|
832
|
-
res.on("end", () => {
|
|
833
|
-
try {
|
|
834
|
-
const parsed = JSON.parse(data);
|
|
835
|
-
resolve({ ok: res.statusCode === 200, data: parsed, error: parsed.error });
|
|
836
|
-
} catch {
|
|
837
|
-
resolve({ ok: false, error: `invalid response from owner: ${data.slice(0, 200)}` });
|
|
838
|
-
}
|
|
839
|
-
});
|
|
840
|
-
});
|
|
841
|
-
req.on("error", (err) => {
|
|
842
|
-
resolve({ ok: false, error: `proxy request failed: ${err.message}` });
|
|
843
|
-
});
|
|
844
|
-
req.on("timeout", () => {
|
|
845
|
-
req.destroy();
|
|
846
|
-
resolve({ ok: false, error: "proxy request timed out" });
|
|
847
|
-
});
|
|
848
|
-
if (body) req.write(JSON.stringify(body));
|
|
849
|
-
req.end();
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
async function pingOwner(port) {
|
|
853
|
-
return new Promise((resolve) => {
|
|
854
|
-
const req = http.request({
|
|
855
|
-
hostname: "127.0.0.1",
|
|
856
|
-
port,
|
|
857
|
-
path: "/ping",
|
|
858
|
-
method: "GET",
|
|
859
|
-
timeout: 3e3
|
|
860
|
-
}, (res) => {
|
|
861
|
-
res.resume();
|
|
862
|
-
resolve(res.statusCode === 200);
|
|
863
|
-
});
|
|
864
|
-
req.on("error", () => resolve(false));
|
|
865
|
-
req.on("timeout", () => {
|
|
866
|
-
req.destroy();
|
|
867
|
-
resolve(false);
|
|
868
|
-
});
|
|
869
|
-
req.end();
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
function tryListenPort(server, port) {
|
|
873
|
-
return new Promise((resolve) => {
|
|
874
|
-
server.once("error", () => resolve(false));
|
|
875
|
-
server.listen(port, "127.0.0.1", () => resolve(true));
|
|
876
|
-
});
|
|
877
|
-
}
|
|
878
|
-
// Owner-token auth gate. Compares x-owner-token against the per-process
|
|
879
|
-
// OWNER_SECRET generated at startOwnerHttpServer time. The secret is NOT
|
|
880
|
-
// returned by /ping (only the public instanceId is) so a local caller that
|
|
881
|
-
// scrapes /ping still cannot forge owner-side calls. Constant-time compare
|
|
882
|
-
// to avoid trivial timing leakage on the local socket. Optional secondary
|
|
883
|
-
// instanceId check via x-owner-instance: when present it must match this
|
|
884
|
-
// process's INSTANCE_ID, catching stale clients targeting an old owner.
|
|
885
|
-
function requireOwnerToken(req, res) {
|
|
886
|
-
const token = req.headers["x-owner-token"];
|
|
887
|
-
if (!OWNER_SECRET || typeof token !== "string" || token.length !== OWNER_SECRET.length) {
|
|
888
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
889
|
-
res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
|
|
890
|
-
return false;
|
|
891
|
-
}
|
|
892
|
-
let ok = false;
|
|
893
|
-
try {
|
|
894
|
-
ok = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(OWNER_SECRET));
|
|
895
|
-
} catch {
|
|
896
|
-
ok = false;
|
|
897
|
-
}
|
|
898
|
-
if (!ok) {
|
|
899
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
900
|
-
res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
|
|
901
|
-
return false;
|
|
902
|
-
}
|
|
903
|
-
const instanceHeader = req.headers["x-owner-instance"];
|
|
904
|
-
if (instanceHeader && instanceHeader !== INSTANCE_ID) {
|
|
905
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
906
|
-
res.end(JSON.stringify({ error: "unauthorized: instance mismatch" }));
|
|
907
|
-
return false;
|
|
908
|
-
}
|
|
909
|
-
return true;
|
|
910
|
-
}
|
|
911
|
-
// Per-route handler table. Each handler matches the original switch-case
|
|
912
|
-
// behavior byte-for-byte (auth checks, status codes, response shapes); the
|
|
913
|
-
// outer dispatch loop just looks up the entry instead of running a long
|
|
914
|
-
// switch. `methods` mirrors any pre-existing 405 guard.
|
|
915
|
-
const OWNER_ROUTES = {
|
|
916
|
-
"/ping": async (req, res /*, body, url*/) => {
|
|
917
|
-
res.writeHead(200);
|
|
918
|
-
res.end(JSON.stringify({ ok: true, instanceId: INSTANCE_ID, pid: process.pid }));
|
|
919
|
-
},
|
|
920
|
-
"/send": async (req, res, body) => {
|
|
921
|
-
if (!requireOwnerToken(req, res)) return;
|
|
922
|
-
// Pre/post-send activity bumps keep idle gating consistent across
|
|
923
|
-
// slow network / attachment / rate-limited sends; double bump is
|
|
924
|
-
// harmless.
|
|
925
|
-
scheduler.noteActivity();
|
|
926
|
-
const sendResult = await backend.sendMessage(body.chatId, body.text, body.opts);
|
|
927
|
-
scheduler.noteActivity();
|
|
928
|
-
res.writeHead(200);
|
|
929
|
-
res.end(JSON.stringify({ sentIds: sendResult.sentIds }));
|
|
930
|
-
},
|
|
931
|
-
"/react": async (req, res, body) => {
|
|
932
|
-
if (!requireOwnerToken(req, res)) return;
|
|
933
|
-
await backend.react(body.chatId, body.messageId, body.emoji);
|
|
934
|
-
res.writeHead(200);
|
|
935
|
-
res.end(JSON.stringify({ ok: true }));
|
|
936
|
-
},
|
|
937
|
-
"/edit": async (req, res, body) => {
|
|
938
|
-
if (!requireOwnerToken(req, res)) return;
|
|
939
|
-
const editId = await backend.editMessage(body.chatId, body.messageId, body.text, body.opts);
|
|
940
|
-
res.writeHead(200);
|
|
941
|
-
res.end(JSON.stringify({ id: editId }));
|
|
942
|
-
},
|
|
943
|
-
"/fetch": async (req, res, body, url) => {
|
|
944
|
-
if (!requireOwnerToken(req, res)) return;
|
|
945
|
-
const channelId = url.searchParams.get("channel") ?? "";
|
|
946
|
-
const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
|
|
947
|
-
const msgs = await backend.fetchMessages(channelId, limit);
|
|
948
|
-
recordFetchedMessages(channelId, labelForChannelId(channelId), msgs);
|
|
949
|
-
res.writeHead(200);
|
|
950
|
-
res.end(JSON.stringify({ messages: msgs }));
|
|
951
|
-
},
|
|
952
|
-
"/download": async (req, res, body) => {
|
|
953
|
-
if (!requireOwnerToken(req, res)) return;
|
|
954
|
-
const files = await backend.downloadAttachment(body.chatId, body.messageId);
|
|
955
|
-
res.writeHead(200);
|
|
956
|
-
res.end(JSON.stringify({ files }));
|
|
957
|
-
},
|
|
958
|
-
"/typing/start": async (req, res, body) => {
|
|
959
|
-
if (!requireOwnerToken(req, res)) return;
|
|
960
|
-
backend.startTyping(body.channelId);
|
|
961
|
-
res.writeHead(200);
|
|
962
|
-
res.end(JSON.stringify({ ok: true }));
|
|
963
|
-
},
|
|
964
|
-
"/typing/stop": async (req, res, body) => {
|
|
965
|
-
if (!requireOwnerToken(req, res)) return;
|
|
966
|
-
backend.stopTyping(body.channelId);
|
|
967
|
-
res.writeHead(200);
|
|
968
|
-
res.end(JSON.stringify({ ok: true }));
|
|
969
|
-
},
|
|
970
|
-
"/inject": async (req, res, body) => {
|
|
971
|
-
// Require owner-token header to prevent unauthenticated local injection.
|
|
972
|
-
if (!requireOwnerToken(req, res)) return;
|
|
973
|
-
const content = body.content;
|
|
974
|
-
if (!content) {
|
|
975
|
-
res.writeHead(400);
|
|
976
|
-
res.end(JSON.stringify({ error: "content required" }));
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
const source = body.source || "mixdog-agent";
|
|
980
|
-
const injMeta = { user: source, user_id: "system", ts: (/* @__PURE__ */ new Date()).toISOString() };
|
|
981
|
-
if (body.instruction) injMeta.instruction = body.instruction;
|
|
982
|
-
if (body.type) injMeta.type = body.type;
|
|
983
|
-
sendNotifyToParent("notifications/claude/channel", { content, meta: injMeta });
|
|
984
|
-
res.writeHead(200);
|
|
985
|
-
res.end(JSON.stringify({ ok: true }));
|
|
986
|
-
},
|
|
987
|
-
"/trigger-schedule": async (req, res, body) => {
|
|
988
|
-
// Native fallback for `mcp__trigger_schedule` so out-of-band
|
|
989
|
-
// verification works when the MCP stdio bridge is down (host agent
|
|
990
|
-
// disconnected, supervisor restart pending, etc.). Same authz as
|
|
991
|
-
// /inject — x-owner-token must equal INSTANCE_ID.
|
|
992
|
-
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
|
|
993
|
-
if (!requireOwnerToken(req, res)) return;
|
|
994
|
-
const triggerName = body.name;
|
|
995
|
-
if (!triggerName) {
|
|
996
|
-
res.writeHead(400);
|
|
997
|
-
res.end(JSON.stringify({ error: "name required" }));
|
|
998
|
-
return;
|
|
999
|
-
}
|
|
1000
|
-
try {
|
|
1001
|
-
const r = await scheduler.triggerManual(triggerName);
|
|
1002
|
-
res.writeHead(200);
|
|
1003
|
-
res.end(JSON.stringify({ ok: true, result: r ?? null }));
|
|
1004
|
-
} catch (e) {
|
|
1005
|
-
res.writeHead(500);
|
|
1006
|
-
res.end(JSON.stringify({ error: e?.message || String(e) }));
|
|
1007
|
-
}
|
|
1008
|
-
},
|
|
1009
|
-
"/schedule-status": async (req, res) => {
|
|
1010
|
-
// Owner-side schedule_status so standby/proxy sessions read the LIVE
|
|
1011
|
-
// scheduler instead of their own stale local state. Mirrors the MCP
|
|
1012
|
-
// schedule_status handler's formatting (kept byte-identical via the
|
|
1013
|
-
// shared scheduleStatusResult() helper).
|
|
1014
|
-
if (!requireOwnerToken(req, res)) return;
|
|
1015
|
-
try {
|
|
1016
|
-
const r = scheduleStatusResult();
|
|
1017
|
-
res.writeHead(200);
|
|
1018
|
-
res.end(JSON.stringify({ ok: true, result: r }));
|
|
1019
|
-
} catch (e) {
|
|
1020
|
-
res.writeHead(500);
|
|
1021
|
-
res.end(JSON.stringify({ error: e?.message || String(e) }));
|
|
1022
|
-
}
|
|
1023
|
-
},
|
|
1024
|
-
"/schedule-control": async (req, res, body) => {
|
|
1025
|
-
// Owner-side schedule_control so standby/proxy sessions mutate the LIVE
|
|
1026
|
-
// scheduler (defer/skip_today) instead of their own stale local state.
|
|
1027
|
-
// Validation lives here because the proxy side's scheduler.nonInteractive/
|
|
1028
|
-
// interactive lists are not authoritative.
|
|
1029
|
-
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
|
|
1030
|
-
if (!requireOwnerToken(req, res)) return;
|
|
1031
|
-
try {
|
|
1032
|
-
const r = scheduleControlResult(body || {});
|
|
1033
|
-
res.writeHead(200);
|
|
1034
|
-
res.end(JSON.stringify({ ok: true, result: r }));
|
|
1035
|
-
} catch (e) {
|
|
1036
|
-
res.writeHead(500);
|
|
1037
|
-
res.end(JSON.stringify({ error: e?.message || String(e) }));
|
|
1038
|
-
}
|
|
1039
|
-
},
|
|
1040
|
-
"/bridge": async (req, res, body) => {
|
|
1041
|
-
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
|
|
1042
|
-
if (!requireOwnerToken(req, res)) return;
|
|
1043
|
-
const bridgeFile = body.file;
|
|
1044
|
-
const bridgePrompt = body.prompt;
|
|
1045
|
-
const bridgeRef = body.ref;
|
|
1046
|
-
const bridgeRole = body.role;
|
|
1047
|
-
const bridgeContext = body.context;
|
|
1048
|
-
let bridgePromptFinal = bridgePrompt;
|
|
1049
|
-
if (!bridgePromptFinal && bridgeFile) {
|
|
1050
|
-
try { bridgePromptFinal = fs.readFileSync(bridgeFile, "utf-8").trim(); } catch (e) {
|
|
1051
|
-
res.writeHead(400); res.end(JSON.stringify({ error: `Cannot read file: ${e.message}` })); return;
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
if (!bridgePromptFinal && !bridgeRef) { res.writeHead(400); res.end(JSON.stringify({ error: "prompt, file, or ref required" })); return; }
|
|
1055
|
-
try {
|
|
1056
|
-
const agentMod = await import(pathToFileURL(path.join(path.dirname(import.meta.url.replace("file:///", "").replace(/\//g, path.sep)), "..", "agent", "index.mjs")).href);
|
|
1057
|
-
if (agentMod.init) await agentMod.init();
|
|
1058
|
-
const toolArgs = {};
|
|
1059
|
-
if (bridgePromptFinal) toolArgs.prompt = bridgePromptFinal;
|
|
1060
|
-
if (bridgeRef) toolArgs.ref = bridgeRef;
|
|
1061
|
-
if (bridgeRole) toolArgs.role = bridgeRole;
|
|
1062
|
-
if (bridgeContext) toolArgs.context = bridgeContext;
|
|
1063
|
-
const notifyFn = (text, extraMeta) => {
|
|
1064
|
-
sendNotifyToParent("notifications/claude/channel", {
|
|
1065
|
-
content: text,
|
|
1066
|
-
meta: {
|
|
1067
|
-
user: "mixdog-agent",
|
|
1068
|
-
user_id: "system",
|
|
1069
|
-
ts: new Date().toISOString(),
|
|
1070
|
-
...(extraMeta || {})
|
|
1071
|
-
}
|
|
1072
|
-
});
|
|
1073
|
-
};
|
|
1074
|
-
const BRIDGE_HTTP_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
|
1075
|
-
const bridgeAbort = new AbortController();
|
|
1076
|
-
const bridgeTimer = setTimeout(() => bridgeAbort.abort(new Error("bridge HTTP timeout")), BRIDGE_HTTP_TIMEOUT_MS);
|
|
1077
|
-
const onReqClose = () => bridgeAbort.abort(new Error("client disconnected"));
|
|
1078
|
-
req.on("close", onReqClose);
|
|
1079
|
-
let result;
|
|
1080
|
-
try {
|
|
1081
|
-
result = await Promise.race([
|
|
1082
|
-
agentMod.handleToolCall("bridge", toolArgs, { notifyFn, requestSignal: bridgeAbort.signal }),
|
|
1083
|
-
new Promise((_, reject) => bridgeAbort.signal.addEventListener("abort", () => reject(bridgeAbort.signal.reason), { once: true })),
|
|
1084
|
-
]);
|
|
1085
|
-
} finally {
|
|
1086
|
-
clearTimeout(bridgeTimer);
|
|
1087
|
-
req.removeListener("close", onReqClose);
|
|
1088
|
-
}
|
|
1089
|
-
res.writeHead(200);
|
|
1090
|
-
res.end(JSON.stringify(result));
|
|
1091
|
-
} catch (e) {
|
|
1092
|
-
res.writeHead(500); res.end(JSON.stringify({ error: e.message })); return;
|
|
1093
|
-
}
|
|
1094
|
-
},
|
|
1095
|
-
"/bridge/activate": async (req, res, body) => {
|
|
1096
|
-
if (!requireOwnerToken(req, res)) return;
|
|
1097
|
-
const active = Boolean(body.active);
|
|
1098
|
-
const wasActive = channelBridgeActive;
|
|
1099
|
-
channelBridgeActive = active;
|
|
1100
|
-
writeBridgeState(active);
|
|
1101
|
-
if (!active && wasActive) {
|
|
1102
|
-
// Mirror the MCP activate_channel_bridge deactivate path: tear down
|
|
1103
|
-
// owner-side runtime (Discord/scheduler/webhook/event/owner-HTTP/
|
|
1104
|
-
// heartbeat) so a deactivated bridge doesn't keep running and this
|
|
1105
|
-
// owner can't later proxyMode against its own port.
|
|
1106
|
-
stopServerTyping();
|
|
1107
|
-
try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
|
|
1108
|
-
process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
res.writeHead(200);
|
|
1112
|
-
res.end(JSON.stringify({ ok: true, active: channelBridgeActive }));
|
|
1113
|
-
},
|
|
1114
|
-
"/mcp": async (req, res, body) => {
|
|
1115
|
-
if (req.method === "POST") {
|
|
1116
|
-
// Require owner-token header to prevent unauthenticated local MCP dispatch.
|
|
1117
|
-
if (!requireOwnerToken(req, res)) return;
|
|
1118
|
-
const httpMcp = createHttpMcpServer();
|
|
1119
|
-
const httpTransport = new StreamableHTTPServerTransport({
|
|
1120
|
-
sessionIdGenerator: void 0,
|
|
1121
|
-
enableJsonResponse: true
|
|
1122
|
-
});
|
|
1123
|
-
res.on("close", () => {
|
|
1124
|
-
httpTransport.close();
|
|
1125
|
-
void httpMcp.close();
|
|
1126
|
-
});
|
|
1127
|
-
await httpMcp.connect(httpTransport);
|
|
1128
|
-
await httpTransport.handleRequest(req, res, body);
|
|
1129
|
-
} else {
|
|
1130
|
-
res.writeHead(405);
|
|
1131
|
-
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
1132
|
-
}
|
|
1133
|
-
},
|
|
1134
|
-
"/cycle1": async (req, res, body) => {
|
|
1135
|
-
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ ok: false, reason: "method-not-allowed", error: "POST required" })); return; }
|
|
1136
|
-
if (!requireOwnerToken(req, res)) return;
|
|
1137
|
-
const tCycleEntry = Date.now();
|
|
1138
|
-
const timeoutMs = Number(body?.timeout_ms) > 0 ? Math.min(60000, Number(body.timeout_ms)) : 15000;
|
|
1139
|
-
// IPC timer must outlive the worker-side deadline so a graceful
|
|
1140
|
-
// {timedOutWaiting:true} resolve has time to traverse IPC before
|
|
1141
|
-
// the channel timer rejects with memory-timeout. Without the
|
|
1142
|
-
// buffer, the worker resolves at deadline-0ms and the local
|
|
1143
|
-
// setTimeout fires at deadline+0ms in the same tick — race won by
|
|
1144
|
-
// whichever scheduler ordering wins, turning intended 200 flags
|
|
1145
|
-
// into 503 responses.
|
|
1146
|
-
const ipcTimeoutMs = timeoutMs + 2000;
|
|
1147
|
-
try {
|
|
1148
|
-
// Carry the caller deadline through to the memory worker so a
|
|
1149
|
-
// pending cycle1 in-flight is awaited under the same budget.
|
|
1150
|
-
// Without this, when the previous cycle1's LLM call lives past
|
|
1151
|
-
// 60s, every later SessionStart slot stacks another full 60s
|
|
1152
|
-
// wait behind the same zombie promise.
|
|
1153
|
-
const result = await callMemoryAction(
|
|
1154
|
-
'cycle1',
|
|
1155
|
-
{ ...(body?.args || {}), _callerDeadlineMs: timeoutMs },
|
|
1156
|
-
ipcTimeoutMs,
|
|
1157
|
-
);
|
|
1158
|
-
// A successful IPC round-trip can still carry a nested MCP error
|
|
1159
|
-
// envelope ({ isError: true }) when the memory worker served the
|
|
1160
|
-
// call but the action failed — e.g. a promoted fork-proxy whose
|
|
1161
|
-
// local `db` is still null. Surfacing that as outer { ok: true }
|
|
1162
|
-
// masks the failure and makes session-start log a phantom success.
|
|
1163
|
-
// Return a transient 503 so the hook's 503-retry path (which gates
|
|
1164
|
-
// only on statusCode===503) re-polls instead of trusting it.
|
|
1165
|
-
if (result && typeof result === 'object' && result.isError === true) {
|
|
1166
|
-
const nestedText = Array.isArray(result.content)
|
|
1167
|
-
? result.content.map(c => (c && c.text) || '').join(' ').trim()
|
|
1168
|
-
: '';
|
|
1169
|
-
try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry} nestedError=1\n`); } catch {}
|
|
1170
|
-
res.writeHead(503);
|
|
1171
|
-
res.end(JSON.stringify({ ok: false, reason: 'memory-not-ready', error: nestedText || 'memory cycle1 returned isError' }));
|
|
1172
|
-
} else {
|
|
1173
|
-
try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry}\n`); } catch {}
|
|
1174
|
-
res.writeHead(200);
|
|
1175
|
-
res.end(JSON.stringify({ ok: true, result }));
|
|
1176
|
-
}
|
|
1177
|
-
} catch (e) {
|
|
1178
|
-
// Classify transient/unavailable failures so the session-start hook
|
|
1179
|
-
// (and other 503-retry callers) can distinguish boot-time races from
|
|
1180
|
-
// IPC-layer faults and timeouts. All four reasons stay on 503 to
|
|
1181
|
-
// preserve the hook retry contract (hooks/session-start.cjs:516
|
|
1182
|
-
// gates only on statusCode===503); only the `reason` label changes.
|
|
1183
|
-
//
|
|
1184
|
-
// Source → reason mapping (upstream messages from server.mjs
|
|
1185
|
-
// callWorker at 457-490 and local callMemoryAction at 169-187):
|
|
1186
|
-
// server.mjs:470 "not ready (still booting)" → memory-not-ready
|
|
1187
|
-
// server.mjs:464/467 "not available (...)" → worker-unavailable
|
|
1188
|
-
// server.mjs:435 "exited unexpectedly" → worker-unavailable
|
|
1189
|
-
// local "not a worker process" guard → worker-unavailable
|
|
1190
|
-
// server.mjs:483 "IPC channel full or closed" → ipc-error
|
|
1191
|
-
// server.mjs:488 "send failed: ..." → ipc-error
|
|
1192
|
-
// server.mjs:475 "worker ... call ... timed out" → memory-timeout
|
|
1193
|
-
// local "memory_call <action> timed out after Nms" → memory-timeout
|
|
1194
|
-
const msg = e?.message || String(e);
|
|
1195
|
-
let reason;
|
|
1196
|
-
if (/worker memory not ready/i.test(msg)) {
|
|
1197
|
-
reason = 'memory-not-ready';
|
|
1198
|
-
} else if (/worker memory (IPC channel|send failed)/i.test(msg)) {
|
|
1199
|
-
reason = 'ipc-error';
|
|
1200
|
-
} else if (/timed out/i.test(msg)) {
|
|
1201
|
-
reason = 'memory-timeout';
|
|
1202
|
-
} else if (msg.includes('restart cap exceeded') || msg.includes('degraded')) {
|
|
1203
|
-
// Permanent degraded state: restart cap hit or boot-time init failure.
|
|
1204
|
-
// Use a distinct reason so callers can fail-fast without retrying.
|
|
1205
|
-
// NOTE: checked before 'not available' — the error message
|
|
1206
|
-
// "worker memory not available (restart cap exceeded)" contains both
|
|
1207
|
-
// substrings and must land in 'memory-degraded', not 'worker-unavailable'.
|
|
1208
|
-
reason = 'memory-degraded';
|
|
1209
|
-
} else if (msg.includes('worker memory not available') || msg.includes('worker memory exited unexpectedly') || msg.includes('not a worker process')) {
|
|
1210
|
-
reason = 'worker-unavailable';
|
|
1211
|
-
}
|
|
1212
|
-
const transient = Boolean(reason);
|
|
1213
|
-
res.writeHead(transient ? 503 : 500);
|
|
1214
|
-
res.end(JSON.stringify({ ok: false, reason, error: msg }));
|
|
1215
|
-
}
|
|
1216
|
-
},
|
|
1217
|
-
"/rebind": async (req, res, body) => {
|
|
1218
|
-
if (!requireOwnerToken(req, res)) return;
|
|
1219
|
-
const channelId = statusState.read().channelId;
|
|
1220
|
-
if (!channelId) {
|
|
1221
|
-
res.writeHead(200);
|
|
1222
|
-
res.end(JSON.stringify({ rebound: false, reason: "no channelId" }));
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
const previousPath = getPersistedTranscriptPath();
|
|
1226
|
-
const explicitTranscriptPath = typeof body?.transcriptPath === "string" ? body.transcriptPath.trim() : "";
|
|
1227
|
-
const bound = await rebindTranscriptContext(channelId, {
|
|
1228
|
-
previousPath,
|
|
1229
|
-
persistStatus: true,
|
|
1230
|
-
catchUp: true,
|
|
1231
|
-
...(explicitTranscriptPath ? { transcriptPath: explicitTranscriptPath } : {})
|
|
1232
|
-
});
|
|
1233
|
-
const reboundChanged = Boolean(bound) && bound !== previousPath;
|
|
1234
|
-
res.writeHead(200);
|
|
1235
|
-
res.end(JSON.stringify({ rebound: reboundChanged, path: bound || null }));
|
|
1236
|
-
},
|
|
1237
|
-
};
|
|
1238
|
-
const BACKEND_DEPENDENT_PATHS = new Set([
|
|
1239
|
-
"/send",
|
|
1240
|
-
"/react",
|
|
1241
|
-
"/edit",
|
|
1242
|
-
"/fetch",
|
|
1243
|
-
"/download",
|
|
1244
|
-
"/typing/start",
|
|
1245
|
-
"/typing/stop",
|
|
1246
|
-
"/mcp"
|
|
1247
|
-
]);
|
|
1248
|
-
async function ownerRequestHandler(req, res) {
|
|
1249
|
-
res.setHeader("Content-Type", "application/json");
|
|
1250
|
-
let body = {};
|
|
1251
|
-
if (req.method === "POST") {
|
|
1252
|
-
const chunks = [];
|
|
1253
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
1254
|
-
try {
|
|
1255
|
-
const rawBody = Buffer.concat(chunks).toString();
|
|
1256
|
-
body = rawBody.trim() ? JSON.parse(rawBody) : {};
|
|
1257
|
-
} catch {
|
|
1258
|
-
res.writeHead(400);
|
|
1259
|
-
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
1260
|
-
return;
|
|
1261
|
-
}
|
|
1262
|
-
}
|
|
1263
|
-
try {
|
|
1264
|
-
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
1265
|
-
if (BACKEND_DEPENDENT_PATHS.has(url.pathname) && !bridgeRuntimeConnected) {
|
|
1266
|
-
res.writeHead(503);
|
|
1267
|
-
res.end(JSON.stringify({ ok: false, reason: "backend-not-ready" }));
|
|
1268
|
-
return;
|
|
1269
|
-
}
|
|
1270
|
-
const handler = OWNER_ROUTES[url.pathname];
|
|
1271
|
-
if (handler) {
|
|
1272
|
-
await handler(req, res, body, url);
|
|
1273
|
-
return;
|
|
1274
|
-
}
|
|
1275
|
-
res.writeHead(404);
|
|
1276
|
-
res.end(JSON.stringify({ error: "not found" }));
|
|
1277
|
-
} catch (err) {
|
|
1278
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1279
|
-
res.writeHead(500);
|
|
1280
|
-
res.end(JSON.stringify({ error: msg }));
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
async function startOwnerHttpServer() {
|
|
1284
|
-
if (ownerHttpServer) return ownerHttpServer.address().port;
|
|
1285
|
-
// Generate a fresh cryptographic owner-secret BEFORE the listener accepts
|
|
1286
|
-
// traffic so requireOwnerToken always has a real secret to compare. Stored
|
|
1287
|
-
// in a 0o600 sidecar file (owner-secret-<pid>.json) under RUNTIME_ROOT so
|
|
1288
|
-
// only the same UID + same active owner pid can read it back. /ping does
|
|
1289
|
-
// NOT return this value — only the public instanceId.
|
|
1290
|
-
if (!OWNER_SECRET) {
|
|
1291
|
-
OWNER_SECRET = crypto.randomBytes(32).toString("hex");
|
|
1292
|
-
try { publishOwnerSecret(OWNER_SECRET); }
|
|
1293
|
-
catch (e) {
|
|
1294
|
-
process.stderr.write(`mixdog: failed to publish owner secret: ${e?.message || e}\n`);
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
const server = http.createServer(ownerRequestHandler);
|
|
1298
|
-
for (let port = PROXY_PORT_MIN; port <= PROXY_PORT_MAX; port++) {
|
|
1299
|
-
if (await tryListenPort(server, port)) {
|
|
1300
|
-
ownerHttpServer = server;
|
|
1301
|
-
process.stderr.write(`mixdog: owner HTTP server listening on 127.0.0.1:${port}
|
|
1302
|
-
`);
|
|
1303
|
-
return port;
|
|
1304
|
-
}
|
|
1305
|
-
server.removeAllListeners("error");
|
|
1306
|
-
}
|
|
1307
|
-
throw new Error(`no available port in range ${PROXY_PORT_MIN}-${PROXY_PORT_MAX}`);
|
|
1308
|
-
}
|
|
1309
|
-
function stopOwnerHttpServer() {
|
|
1310
|
-
if (!ownerHttpServer) return;
|
|
1311
|
-
ownerHttpServer.close();
|
|
1312
|
-
ownerHttpServer = null;
|
|
1313
|
-
// Drop the per-process secret + sidecar file. A future startOwnerHttpServer()
|
|
1314
|
-
// call regenerates a fresh one, so a stale standby that read the old secret
|
|
1315
|
-
// before the restart cannot authenticate against the new owner.
|
|
1316
|
-
OWNER_SECRET = "";
|
|
1317
|
-
try { clearOwnerSecret(); } catch {}
|
|
1318
|
-
globalThis.__mixdogBeaconRealHandler = null;
|
|
1319
|
-
globalThis.__mixdogBeacon = null;
|
|
1320
|
-
}
|
|
1321
787
|
function logOwnership(note) {
|
|
1322
788
|
if (lastOwnershipNote === note) return;
|
|
1323
789
|
lastOwnershipNote = note;
|
|
@@ -1328,47 +794,24 @@ function currentOwnerState() {
|
|
|
1328
794
|
const active = readActiveInstance();
|
|
1329
795
|
return {
|
|
1330
796
|
active,
|
|
1331
|
-
|
|
797
|
+
// Strict last-wins: this process owns the bridge ONLY when active-instance
|
|
798
|
+
// names exactly this INSTANCE_ID. A newer remote session that claims the
|
|
799
|
+
// seat overwrites instanceId, so the old owner immediately reads owned=false
|
|
800
|
+
// and disconnects on its next refresh tick. No PID/terminal fallback —
|
|
801
|
+
// that used to let a co-terminal worker wrongly self-claim.
|
|
802
|
+
owned: active?.instanceId === INSTANCE_ID
|
|
1332
803
|
};
|
|
1333
804
|
}
|
|
1334
805
|
function getBridgeOwnershipSnapshot() {
|
|
1335
806
|
return currentOwnerState();
|
|
1336
807
|
}
|
|
1337
|
-
// MIXDOG_PIN_OWNER=1 in the owning process writes `pinned:true` into
|
|
1338
|
-
// active-instance.json. Pinned owners ignore the 10 s stale window — they
|
|
1339
|
-
// only relinquish ownership when their OS process actually dies. Set per
|
|
1340
|
-
// session (env var on the host agent shell) to lock that Lead as the
|
|
1341
|
-
// schedule/webhook receiver across multi-session use.
|
|
1342
|
-
function canStealOwnership(active) {
|
|
1343
|
-
if (!active) return true;
|
|
1344
|
-
if (active.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID) return true;
|
|
1345
|
-
if (active.pinned) {
|
|
1346
|
-
const pinnedPid = getActiveOwnerPid(active);
|
|
1347
|
-
if (!pinnedPid) return true;
|
|
1348
|
-
try { process.kill(pinnedPid, 0); return false; }
|
|
1349
|
-
catch { return true; }
|
|
1350
|
-
}
|
|
1351
|
-
if (Date.now() - active.updatedAt > ACTIVE_OWNER_STALE_MS) return true;
|
|
1352
|
-
const ownerPid = getActiveOwnerPid(active);
|
|
1353
|
-
try {
|
|
1354
|
-
if (!ownerPid) throw new Error("missing owner pid");
|
|
1355
|
-
process.kill(ownerPid, 0);
|
|
1356
|
-
return false;
|
|
1357
|
-
} catch {
|
|
1358
|
-
return true;
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
808
|
function claimBridgeOwnership(reason) {
|
|
1362
809
|
refreshActiveInstance(INSTANCE_ID);
|
|
1363
810
|
logOwnership(`claimed owner (${reason})`);
|
|
1364
811
|
}
|
|
1365
|
-
function noteStartupHandoff(previous) {
|
|
1366
|
-
if (!previous) return;
|
|
1367
|
-
if (previous.instanceId === INSTANCE_ID) return;
|
|
1368
|
-
if (getActiveOwnerPid(previous) === TERMINAL_LEAD_PID) return;
|
|
1369
|
-
logOwnership(`startup handoff from ${previous.instanceId}`);
|
|
1370
|
-
}
|
|
1371
812
|
async function bindPersistedTranscriptIfAny() {
|
|
813
|
+
// Main-channel fallback requires channelBridgeActive (set in start() before
|
|
814
|
+
// refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
|
|
1372
815
|
// Resolve channelId first from persisted status; fall back to the most
|
|
1373
816
|
// recent status-*.json snapshot, then to the configured main channel when
|
|
1374
817
|
// the bridge is active. No exists-gate here — once we have a channelId,
|
|
@@ -1505,17 +948,17 @@ async function startOwnedRuntime(options = {}) {
|
|
|
1505
948
|
if (!channelBridgeActive) return;
|
|
1506
949
|
bridgeRuntimeStarting = true;
|
|
1507
950
|
_ownedRuntimeStopRequested = false;
|
|
1508
|
-
//
|
|
1509
|
-
//
|
|
1510
|
-
//
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
refreshActiveInstance(INSTANCE_ID, {
|
|
951
|
+
// Capture the backend instance that THIS start operation will connect. A
|
|
952
|
+
// reloadRuntimeConfig() hot-swap can replace the global `backend` while this
|
|
953
|
+
// start is still awaiting connect(); using the captured instance for both
|
|
954
|
+
// connect() and the bail-path disconnect() guarantees we tear down the
|
|
955
|
+
// backend WE started (not the freshly-swapped one), closing the
|
|
956
|
+
// both-backends-live window.
|
|
957
|
+
const startingBackend = backend;
|
|
958
|
+
// Advertise active-instance.json BEFORE backend connect so a newer remote
|
|
959
|
+
// session's last-wins claim is visible immediately. backendReady=false
|
|
960
|
+
// marks the partial state until backend.connect() succeeds.
|
|
961
|
+
refreshActiveInstance(INSTANCE_ID, { backendReady: false });
|
|
1519
962
|
startOwnerHeartbeat();
|
|
1520
963
|
// Re-check after each post-connect await so a stopOwnedRuntime() landing
|
|
1521
964
|
// mid-start cannot be overridden by the resuming start (scheduler/snapshot/
|
|
@@ -1526,8 +969,7 @@ async function startOwnedRuntime(options = {}) {
|
|
|
1526
969
|
// (stop did disconnect; redo to be defensive).
|
|
1527
970
|
const bailIfStopRequested = async () => {
|
|
1528
971
|
if (!_ownedRuntimeStopRequested) return false;
|
|
1529
|
-
try { await
|
|
1530
|
-
try { stopOwnerHttpServer(); } catch {}
|
|
972
|
+
try { await startingBackend.disconnect(); } catch {}
|
|
1531
973
|
try { stopOwnerHeartbeat(); } catch {}
|
|
1532
974
|
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
1533
975
|
try { clearActiveInstance(INSTANCE_ID); } catch {}
|
|
@@ -1535,16 +977,26 @@ async function startOwnedRuntime(options = {}) {
|
|
|
1535
977
|
_ownedRuntimeStopRequested = false;
|
|
1536
978
|
return true;
|
|
1537
979
|
};
|
|
980
|
+
const restoreBinding = options.restoreBinding !== false;
|
|
981
|
+
const bindPersistedTranscriptTask = restoreBinding
|
|
982
|
+
? bindPersistedTranscriptIfAny().catch((e) => {
|
|
983
|
+
process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
984
|
+
})
|
|
985
|
+
: null;
|
|
1538
986
|
// Await backend.connect() so callers (and bindingReady) only resolve after
|
|
1539
987
|
// the Discord binding is real. Previously this was fire-and-forget and
|
|
1540
988
|
// refreshBridgeOwnership returned immediately, letting bindingReady fire
|
|
1541
989
|
// before backend listeners were attached.
|
|
1542
990
|
try {
|
|
1543
|
-
await
|
|
1544
|
-
if (await bailIfStopRequested())
|
|
991
|
+
await startingBackend.connect();
|
|
992
|
+
if (await bailIfStopRequested()) {
|
|
993
|
+
cancelPendingTranscriptRearm();
|
|
994
|
+
try { forwarder.stopWatch(); } catch {}
|
|
995
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
1545
998
|
bridgeRuntimeConnected = true;
|
|
1546
|
-
refreshActiveInstance(INSTANCE_ID, {
|
|
1547
|
-
proxyMode = false;
|
|
999
|
+
refreshActiveInstance(INSTANCE_ID, { backendReady: true });
|
|
1548
1000
|
// initProviders must complete before scheduler.start() — otherwise the
|
|
1549
1001
|
// scheduler's first fire can land before the registry is populated and
|
|
1550
1002
|
// return `Provider "<name>" not found or not enabled`. The previous
|
|
@@ -1555,22 +1007,37 @@ async function startOwnedRuntime(options = {}) {
|
|
|
1555
1007
|
} catch (e) {
|
|
1556
1008
|
process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1557
1009
|
}
|
|
1558
|
-
if (await bailIfStopRequested())
|
|
1010
|
+
if (await bailIfStopRequested()) {
|
|
1011
|
+
cancelPendingTranscriptRearm();
|
|
1012
|
+
try { forwarder.stopWatch(); } catch {}
|
|
1013
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1559
1016
|
scheduler.start();
|
|
1560
1017
|
startSnapshotWriter(scheduler);
|
|
1561
1018
|
syncOwnedWebhookAndEventRuntime();
|
|
1562
|
-
if (
|
|
1563
|
-
|
|
1564
|
-
|
|
1019
|
+
if (restoreBinding) {
|
|
1020
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
1021
|
+
const pendingTranscriptPath = forwarder.transcriptPath;
|
|
1022
|
+
if (pendingTranscriptPath && !fs.existsSync(pendingTranscriptPath)) {
|
|
1023
|
+
// Pre-connect bind may have armed rearm while !bridgeRuntimeConnected;
|
|
1024
|
+
// the first tick then exits without rescheduling. Re-arm now that we own.
|
|
1025
|
+
schedulePendingTranscriptRearm(statusState.read().channelId, pendingTranscriptPath);
|
|
1026
|
+
} else {
|
|
1027
|
+
void forwarder.forwardNewText().catch((err) => {
|
|
1028
|
+
process.stderr.write(`mixdog: post-connect forwardNewText failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1565
1032
|
process.stderr.write(`mixdog: running with ${backend.name} backend\n`);
|
|
1566
1033
|
logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
|
|
1567
1034
|
} catch (e) {
|
|
1568
1035
|
process.stderr.write(`mixdog: backend connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1036
|
+
cancelPendingTranscriptRearm();
|
|
1037
|
+
try { forwarder.stopWatch(); } catch {}
|
|
1038
|
+
if (bindPersistedTranscriptTask) await bindPersistedTranscriptTask;
|
|
1569
1039
|
// Roll back partial owner-side state advertised before connect() ran:
|
|
1570
|
-
//
|
|
1571
|
-
// stopOwnedRuntime() at shutdown will short-circuit on !bridgeRuntimeConnected
|
|
1572
|
-
// and leave the port bound + active-instance.json stale.
|
|
1573
|
-
try { stopOwnerHttpServer(); } catch {}
|
|
1040
|
+
// heartbeat and active-instance entry.
|
|
1574
1041
|
try { stopOwnerHeartbeat(); } catch {}
|
|
1575
1042
|
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
1576
1043
|
try { clearActiveInstance(INSTANCE_ID); } catch {}
|
|
@@ -1578,59 +1045,12 @@ async function startOwnedRuntime(options = {}) {
|
|
|
1578
1045
|
bridgeRuntimeStarting = false;
|
|
1579
1046
|
}
|
|
1580
1047
|
}
|
|
1581
|
-
async function startCliOwnedRuntime(options = {}) {
|
|
1582
|
-
if (bridgeRuntimeConnected) return;
|
|
1583
|
-
if (bridgeRuntimeStarting) return;
|
|
1584
|
-
if (!channelBridgeActive) return;
|
|
1585
|
-
const startedAt = performance.now();
|
|
1586
|
-
bootProfile("cli-owned:start");
|
|
1587
|
-
bridgeRuntimeStarting = true;
|
|
1588
|
-
_ownedRuntimeStopRequested = false;
|
|
1589
|
-
try {
|
|
1590
|
-
const backendStartedAt = performance.now();
|
|
1591
|
-
await backend.connect();
|
|
1592
|
-
bootProfile("backend:connected", { ms: (performance.now() - backendStartedAt).toFixed(1), backend: backend.name });
|
|
1593
|
-
if (_ownedRuntimeStopRequested) {
|
|
1594
|
-
try { await backend.disconnect(); } catch {}
|
|
1595
|
-
bridgeRuntimeConnected = false;
|
|
1596
|
-
_ownedRuntimeStopRequested = false;
|
|
1597
|
-
return;
|
|
1598
|
-
}
|
|
1599
|
-
bridgeRuntimeConnected = true;
|
|
1600
|
-
proxyMode = false;
|
|
1601
|
-
ownerHttpPort = 0;
|
|
1602
|
-
try {
|
|
1603
|
-
const providersStartedAt = performance.now();
|
|
1604
|
-
const agentCfg = loadAgentConfig();
|
|
1605
|
-
await initProviders(agentCfg.providers || {});
|
|
1606
|
-
bootProfile("providers:ready", { ms: (performance.now() - providersStartedAt).toFixed(1) });
|
|
1607
|
-
} catch (e) {
|
|
1608
|
-
bootProfile("providers:failed", { error: e instanceof Error ? e.message : String(e) });
|
|
1609
|
-
process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1610
|
-
}
|
|
1611
|
-
if (_ownedRuntimeStopRequested) {
|
|
1612
|
-
await stopOwnedRuntime("cli-owned start cancelled");
|
|
1613
|
-
return;
|
|
1614
|
-
}
|
|
1615
|
-
scheduler.start();
|
|
1616
|
-
startSnapshotWriter(scheduler);
|
|
1617
|
-
bootProfile("scheduler:started");
|
|
1618
|
-
syncOwnedWebhookAndEventRuntime();
|
|
1619
|
-
bootProfile("webhook-event:ready");
|
|
1620
|
-
if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
|
|
1621
|
-
process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1622
|
-
});
|
|
1623
|
-
bootProfile("cli-owned:ready", { ms: (performance.now() - startedAt).toFixed(1) });
|
|
1624
|
-
process.stderr.write(`mixdog: running with ${backend.name} backend (cli-owned)\n`);
|
|
1625
|
-
} catch (e) {
|
|
1626
|
-
bootProfile("cli-owned:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e instanceof Error ? e.message : String(e) });
|
|
1627
|
-
process.stderr.write(`mixdog: backend connect failed (non-fatal, cli-owned): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1628
|
-
try { await stopOwnedRuntime("cli-owned start failed"); } catch {}
|
|
1629
|
-
} finally {
|
|
1630
|
-
bridgeRuntimeStarting = false;
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
1048
|
async function stopOwnedRuntime(reason) {
|
|
1049
|
+
// Cancel any pending transcript re-arm poll BEFORE the connected/starting
|
|
1050
|
+
// early-return below. Otherwise a poll armed against a not-yet-existing
|
|
1051
|
+
// transcript could fire after teardown and reinstall the fs.watch handle
|
|
1052
|
+
// (startWatch is not owner-gated), leaking a live watcher past shutdown.
|
|
1053
|
+
cancelPendingTranscriptRearm();
|
|
1634
1054
|
// startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
|
|
1635
1055
|
// claims channel locks BEFORE awaiting backend.connect(). If shutdown lands
|
|
1636
1056
|
// during that window (bridgeRuntimeStarting=true, bridgeRuntimeConnected
|
|
@@ -1649,7 +1069,6 @@ async function stopOwnedRuntime(reason) {
|
|
|
1649
1069
|
// and the drain/retry timers stay live after ownership is dropped, leaking a
|
|
1650
1070
|
// file handle + timers for the rest of the process lifetime.
|
|
1651
1071
|
try { forwarder.stopWatch(); } catch {}
|
|
1652
|
-
stopOwnerHttpServer();
|
|
1653
1072
|
stopOwnerHeartbeat();
|
|
1654
1073
|
scheduler.stop();
|
|
1655
1074
|
stopSnapshotWriter();
|
|
@@ -1671,8 +1090,14 @@ function refreshBridgeOwnershipSafe(options = {}) {
|
|
|
1671
1090
|
function startOwnerHeartbeat() {
|
|
1672
1091
|
if (ownerHeartbeatTimer) return;
|
|
1673
1092
|
ownerHeartbeatTimer = setInterval(() => {
|
|
1674
|
-
try {
|
|
1675
|
-
|
|
1093
|
+
try {
|
|
1094
|
+
// Last-wins guard: only refresh the seat if we STILL own it. If a newer
|
|
1095
|
+
// remote session claimed active-instance.json since our last tick, do
|
|
1096
|
+
// NOT overwrite it back — that would re-steal ownership and cause
|
|
1097
|
+
// ping-pong / double backend connections. The bridgeOwnershipTimer's
|
|
1098
|
+
// refreshBridgeOwnership() will observe owned=false and disconnect us.
|
|
1099
|
+
if (currentOwnerState().owned) refreshActiveInstance(INSTANCE_ID);
|
|
1100
|
+
} catch (e) {
|
|
1676
1101
|
process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1677
1102
|
}
|
|
1678
1103
|
}, OWNER_HEARTBEAT_INTERVAL_MS);
|
|
@@ -1689,98 +1114,41 @@ async function refreshBridgeOwnership(options = {}) {
|
|
|
1689
1114
|
// instead of returning early and observing spurious auto-connect failure.
|
|
1690
1115
|
if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
|
|
1691
1116
|
bridgeOwnershipRefreshInFlight = (async () => {
|
|
1117
|
+
// Opt-in remote, single-owner, last-wins. Only a remote session with an
|
|
1118
|
+
// active bridge participates. If this instance is the active owner (its
|
|
1119
|
+
// INSTANCE_ID is the one advertised in active-instance.json) it ensures
|
|
1120
|
+
// the owned runtime is up. If a newer remote session has since claimed
|
|
1121
|
+
// ownership (last-wins overwrite), this instance is no longer owner and
|
|
1122
|
+
// quietly tears its backend down on the next tick. No proxy, no steal.
|
|
1692
1123
|
if (!channelBridgeActive) {
|
|
1693
|
-
|
|
1694
|
-
if (active2?.httpPort && !proxyMode) {
|
|
1695
|
-
const alive = await pingOwner(active2.httpPort);
|
|
1696
|
-
if (alive) {
|
|
1697
|
-
proxyMode = true;
|
|
1698
|
-
ownerHttpPort = active2.httpPort;
|
|
1699
|
-
logOwnership(`non-channel session \u2014 proxy mode via ${active2.instanceId}`);
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1124
|
+
if (bridgeRuntimeConnected) await stopOwnedRuntime("bridge inactive");
|
|
1702
1125
|
return;
|
|
1703
1126
|
}
|
|
1704
1127
|
const { active, owned } = currentOwnerState();
|
|
1705
|
-
|
|
1706
|
-
let activeHttpChecked = false;
|
|
1707
|
-
let activeHttpAlive = false;
|
|
1708
|
-
const checkActiveHttp = async () => {
|
|
1709
|
-
if (!activeHttpPort) return false;
|
|
1710
|
-
if (!activeHttpChecked) {
|
|
1711
|
-
activeHttpAlive = await pingOwner(activeHttpPort);
|
|
1712
|
-
activeHttpChecked = true;
|
|
1713
|
-
}
|
|
1714
|
-
return activeHttpAlive;
|
|
1715
|
-
};
|
|
1716
|
-
const enterProxyMode = (note) => {
|
|
1717
|
-
proxyMode = true;
|
|
1718
|
-
ownerHttpPort = activeHttpPort;
|
|
1719
|
-
if (note) logOwnership(note);
|
|
1720
|
-
};
|
|
1721
|
-
if (proxyMode && !owned && activeHttpPort) {
|
|
1722
|
-
const alive = await checkActiveHttp();
|
|
1723
|
-
if (!alive) {
|
|
1724
|
-
process.stderr.write(`[ownership] owner ping failed, attempting takeover
|
|
1725
|
-
`);
|
|
1726
|
-
proxyMode = false;
|
|
1727
|
-
ownerHttpPort = 0;
|
|
1728
|
-
claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
|
|
1729
|
-
const next2 = currentOwnerState();
|
|
1730
|
-
if (next2.owned) {
|
|
1731
|
-
refreshActiveInstance(INSTANCE_ID);
|
|
1732
|
-
await startOwnedRuntime(options);
|
|
1733
|
-
}
|
|
1734
|
-
return;
|
|
1735
|
-
}
|
|
1736
|
-
// Active owner is alive but may have rebound to a new port since the
|
|
1737
|
-
// previous refresh (owner restart on a different PROXY_PORT). Sync
|
|
1738
|
-
// ownerHttpPort so subsequent proxyRequest() hits the new port instead
|
|
1739
|
-
// of the stale value cached at proxy-mode entry.
|
|
1740
|
-
if (ownerHttpPort !== activeHttpPort) {
|
|
1741
|
-
ownerHttpPort = activeHttpPort;
|
|
1742
|
-
logOwnership(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
|
|
1743
|
-
}
|
|
1744
|
-
return;
|
|
1745
|
-
}
|
|
1746
|
-
if (!owned && activeHttpPort) {
|
|
1747
|
-
const alive = await checkActiveHttp();
|
|
1748
|
-
if (alive) {
|
|
1749
|
-
enterProxyMode(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
|
|
1750
|
-
return;
|
|
1751
|
-
}
|
|
1752
|
-
const updatedAt = Number(active?.updatedAt);
|
|
1753
|
-
const activeAgeMs = Number.isFinite(updatedAt) ? Date.now() - updatedAt : Number.POSITIVE_INFINITY;
|
|
1754
|
-
if (active?.backendReady === true || activeAgeMs > ACTIVE_OWNER_STALE_MS) {
|
|
1755
|
-
logOwnership(`owner ${active.instanceId} port ${activeHttpPort} unreachable`);
|
|
1756
|
-
claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
if (!owned && canStealOwnership(active)) {
|
|
1760
|
-
claimBridgeOwnership(active ? `takeover from ${active.instanceId}` : "startup");
|
|
1761
|
-
}
|
|
1762
|
-
const next = currentOwnerState();
|
|
1763
|
-
if (next.owned) {
|
|
1128
|
+
if (owned) {
|
|
1764
1129
|
refreshActiveInstance(INSTANCE_ID);
|
|
1765
1130
|
await startOwnedRuntime(options);
|
|
1766
1131
|
return;
|
|
1767
1132
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1133
|
+
// Not the owner. Two sub-cases:
|
|
1134
|
+
// (a) A live remote session holds the seat (active-instance names a
|
|
1135
|
+
// different, non-stale instance) → last-wins: we lost, go quiet
|
|
1136
|
+
// (disconnect if we were connected).
|
|
1137
|
+
// (b) There is NO live owner (active is null/stale — e.g. our own entry
|
|
1138
|
+
// was cleared after a backend-connect failure or a bridge
|
|
1139
|
+
// deactivate/reactivate) → this remote session claims the empty seat
|
|
1140
|
+
// and starts the owned runtime. Without this, a remote session could
|
|
1141
|
+
// never (re)acquire ownership once its active entry was cleared.
|
|
1142
|
+
if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
|
|
1143
|
+
if (bridgeRuntimeConnected) {
|
|
1144
|
+
await stopOwnedRuntime("ownership lost (newer remote session)");
|
|
1780
1145
|
}
|
|
1146
|
+
return;
|
|
1781
1147
|
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1148
|
+
// No live owner — claim the empty seat and start.
|
|
1149
|
+
claimBridgeOwnership("no active owner");
|
|
1150
|
+
if (currentOwnerState().owned) {
|
|
1151
|
+
await startOwnedRuntime(options);
|
|
1784
1152
|
}
|
|
1785
1153
|
})();
|
|
1786
1154
|
try {
|
|
@@ -1809,6 +1177,24 @@ async function reloadRuntimeConfig() {
|
|
|
1809
1177
|
const shouldRestart = bridgeRuntimeConnected || bridgeRuntimeStarting;
|
|
1810
1178
|
if (shouldRestart) await stopOwnedRuntime("backend config changed");
|
|
1811
1179
|
backend = nextBackend;
|
|
1180
|
+
// The persisted routing channelId belongs to the OLD backend (e.g. a
|
|
1181
|
+
// Discord snowflake) and is meaningless for the new one — sending to it
|
|
1182
|
+
// would 400 "chat not found". There is no id mapping between platforms, so
|
|
1183
|
+
// CLEAR the stale binding: drop the forwarder's context + watcher and wipe
|
|
1184
|
+
// status.channelId/transcriptPath. The next inbound from the new backend
|
|
1185
|
+
// rebinds the correct chat via applyTranscriptBinding(). Only done on
|
|
1186
|
+
// backendChanged — same-backend reloads keep their binding untouched.
|
|
1187
|
+
// (active-instance is cleared by stopOwnedRuntime on the restart path; we
|
|
1188
|
+
// don't re-advertise here to avoid resurrecting a just-cleared entry.)
|
|
1189
|
+
try { forwarder.stopWatch(); } catch {}
|
|
1190
|
+
forwarder.channelId = "";
|
|
1191
|
+
forwarder.transcriptPath = "";
|
|
1192
|
+
try {
|
|
1193
|
+
statusState.update((state) => {
|
|
1194
|
+
state.channelId = "";
|
|
1195
|
+
state.transcriptPath = "";
|
|
1196
|
+
});
|
|
1197
|
+
} catch {}
|
|
1812
1198
|
if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
|
|
1813
1199
|
} else if (nextBackend !== previousBackend) {
|
|
1814
1200
|
try { await nextBackend.disconnect?.(); } catch {}
|
|
@@ -1983,11 +1369,10 @@ function wireEventQueueHandlers(eventQueue) {
|
|
|
1983
1369
|
injectAndRecord(channelId, name, content, options);
|
|
1984
1370
|
});
|
|
1985
1371
|
// Defensive ownership probe: the queue tick should only run in the active
|
|
1986
|
-
// owner process.
|
|
1987
|
-
//
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
|
|
1372
|
+
// owner process. Non-owner instances see bridgeRuntimeConnected=false and
|
|
1373
|
+
// will skip the tick even if an errant start() slipped through.
|
|
1374
|
+
eventQueue.setOwnerGetter(() => bridgeRuntimeConnected);
|
|
1375
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
|
|
1991
1376
|
}
|
|
1992
1377
|
function editDiscordMessage(channelId, messageId, label) {
|
|
1993
1378
|
// Behavior-preserving: route through the backend abstraction (which uses
|
|
@@ -2423,10 +1808,9 @@ function createHttpMcpServer() {
|
|
|
2423
1808
|
// `createHttpMcpServer()` above. There is no orphan worker-level Server.
|
|
2424
1809
|
const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
|
|
2425
1810
|
// ── Backend-tool dispatch helpers ───────────────────────────────────────────
|
|
2426
|
-
// Each helper
|
|
2427
|
-
//
|
|
2428
|
-
//
|
|
2429
|
-
// shared so both branches produce byte-identical output.
|
|
1811
|
+
// Each helper dispatches through the local backend (this process is always the
|
|
1812
|
+
// owner in opt-in remote mode). The MCP-result formatting (text shape, cache
|
|
1813
|
+
// invalidation, isError flag) is kept here so results stay consistent.
|
|
2430
1814
|
// schedule_status / schedule_control share their result-formatting between
|
|
2431
1815
|
// the local (owner) MCP case handlers and the owner-side HTTP routes that
|
|
2432
1816
|
// serve proxied standby sessions. Keeping the body here makes both paths
|
|
@@ -2473,24 +1857,11 @@ async function dispatchReply(args) {
|
|
|
2473
1857
|
components: args.components ?? []
|
|
2474
1858
|
};
|
|
2475
1859
|
let ids;
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
});
|
|
2482
|
-
if (!proxyResult.ok) {
|
|
2483
|
-
return { content: [{ type: "text", text: `proxy reply failed: ${proxyResult.error}` }], isError: true };
|
|
2484
|
-
}
|
|
2485
|
-
ids = proxyResult.data?.sentIds ?? [];
|
|
2486
|
-
} else {
|
|
2487
|
-
// Pre-send activity bump keeps idle gating consistent during the await.
|
|
2488
|
-
scheduler.noteActivity();
|
|
2489
|
-
const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
|
|
2490
|
-
// Lead-originated reply via proxy-mode MCP — bump activity.
|
|
2491
|
-
scheduler.noteActivity();
|
|
2492
|
-
ids = sendResult.sentIds;
|
|
2493
|
-
}
|
|
1860
|
+
// Pre-send activity bump keeps idle gating consistent during the await.
|
|
1861
|
+
scheduler.noteActivity();
|
|
1862
|
+
const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
|
|
1863
|
+
scheduler.noteActivity();
|
|
1864
|
+
ids = sendResult.sentIds;
|
|
2494
1865
|
const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
|
|
2495
1866
|
return { content: [{ type: "text", text }] };
|
|
2496
1867
|
}
|
|
@@ -2498,17 +1869,8 @@ async function dispatchFetch(args) {
|
|
|
2498
1869
|
const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
|
|
2499
1870
|
const limit = args.limit ?? 20;
|
|
2500
1871
|
let msgs;
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
if (!proxyResult.ok) {
|
|
2504
|
-
return { content: [{ type: "text", text: `proxy fetch failed: ${proxyResult.error}` }], isError: true };
|
|
2505
|
-
}
|
|
2506
|
-
msgs = proxyResult.data?.messages ?? [];
|
|
2507
|
-
// recordFetchedMessages already ran on the owner side (/fetch route).
|
|
2508
|
-
} else {
|
|
2509
|
-
msgs = await backend.fetchMessages(channelId, limit);
|
|
2510
|
-
recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
|
|
2511
|
-
}
|
|
1872
|
+
msgs = await backend.fetchMessages(channelId, limit);
|
|
1873
|
+
recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
|
|
2512
1874
|
const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
|
|
2513
1875
|
const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
|
|
2514
1876
|
return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
|
|
@@ -2516,53 +1878,18 @@ async function dispatchFetch(args) {
|
|
|
2516
1878
|
return { content: [{ type: "text", text }] };
|
|
2517
1879
|
}
|
|
2518
1880
|
async function dispatchReact(args) {
|
|
2519
|
-
|
|
2520
|
-
const proxyResult = await proxyRequest("/react", "POST", {
|
|
2521
|
-
chatId: args.chat_id,
|
|
2522
|
-
messageId: args.message_id,
|
|
2523
|
-
emoji: args.emoji
|
|
2524
|
-
});
|
|
2525
|
-
if (!proxyResult.ok) {
|
|
2526
|
-
return { content: [{ type: "text", text: `proxy react failed: ${proxyResult.error}` }], isError: true };
|
|
2527
|
-
}
|
|
2528
|
-
} else {
|
|
2529
|
-
await backend.react(args.chat_id, args.message_id, args.emoji);
|
|
2530
|
-
}
|
|
1881
|
+
await backend.react(args.chat_id, args.message_id, args.emoji);
|
|
2531
1882
|
return { content: [{ type: "text", text: "reacted" }] };
|
|
2532
1883
|
}
|
|
2533
1884
|
async function dispatchEditMessage(args) {
|
|
2534
1885
|
const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
|
|
2535
1886
|
let id;
|
|
2536
|
-
|
|
2537
|
-
const proxyResult = await proxyRequest("/edit", "POST", {
|
|
2538
|
-
chatId: args.chat_id,
|
|
2539
|
-
messageId: args.message_id,
|
|
2540
|
-
text: args.text,
|
|
2541
|
-
opts
|
|
2542
|
-
});
|
|
2543
|
-
if (!proxyResult.ok) {
|
|
2544
|
-
return { content: [{ type: "text", text: `proxy edit failed: ${proxyResult.error}` }], isError: true };
|
|
2545
|
-
}
|
|
2546
|
-
id = proxyResult.data?.id;
|
|
2547
|
-
} else {
|
|
2548
|
-
id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
|
|
2549
|
-
}
|
|
1887
|
+
id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
|
|
2550
1888
|
return { content: [{ type: "text", text: `edited (id: ${id})` }] };
|
|
2551
1889
|
}
|
|
2552
1890
|
async function dispatchDownloadAttachment(args) {
|
|
2553
1891
|
let files;
|
|
2554
|
-
|
|
2555
|
-
const proxyResult = await proxyRequest("/download", "POST", {
|
|
2556
|
-
chatId: args.chat_id,
|
|
2557
|
-
messageId: args.message_id
|
|
2558
|
-
});
|
|
2559
|
-
if (!proxyResult.ok) {
|
|
2560
|
-
return { content: [{ type: "text", text: `proxy download failed: ${proxyResult.error}` }], isError: true };
|
|
2561
|
-
}
|
|
2562
|
-
files = proxyResult.data?.files ?? [];
|
|
2563
|
-
} else {
|
|
2564
|
-
files = await backend.downloadAttachment(args.chat_id, args.message_id);
|
|
2565
|
-
}
|
|
1892
|
+
files = await backend.downloadAttachment(args.chat_id, args.message_id);
|
|
2566
1893
|
if (files.length === 0) {
|
|
2567
1894
|
return { content: [{ type: "text", text: "message has no attachments" }] };
|
|
2568
1895
|
}
|
|
@@ -2603,97 +1930,35 @@ async function handleToolCall(name, args, _signal) {
|
|
|
2603
1930
|
result = await dispatchDownloadAttachment(args);
|
|
2604
1931
|
break;
|
|
2605
1932
|
case "schedule_status": {
|
|
2606
|
-
|
|
2607
|
-
const proxyResult = await proxyRequest("/schedule-status", "GET");
|
|
2608
|
-
if (!proxyResult.ok) {
|
|
2609
|
-
result = { content: [{ type: "text", text: `proxy schedule_status failed: ${proxyResult.error}` }], isError: true };
|
|
2610
|
-
break;
|
|
2611
|
-
}
|
|
2612
|
-
result = proxyResult.data?.result ?? { content: [{ type: "text", text: "no schedules configured" }] };
|
|
2613
|
-
} else {
|
|
2614
|
-
result = scheduleStatusResult();
|
|
2615
|
-
}
|
|
1933
|
+
result = scheduleStatusResult();
|
|
2616
1934
|
break;
|
|
2617
1935
|
}
|
|
2618
1936
|
case "trigger_schedule": {
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
if (!proxyResult.ok) {
|
|
2622
|
-
result = { content: [{ type: "text", text: `proxy trigger_schedule failed: ${proxyResult.error}` }], isError: true };
|
|
2623
|
-
break;
|
|
2624
|
-
}
|
|
2625
|
-
const triggerResult = proxyResult.data?.result;
|
|
2626
|
-
result = { content: [{ type: "text", text: triggerResult == null ? "" : String(triggerResult) }] };
|
|
2627
|
-
} else {
|
|
2628
|
-
const triggerResult = await scheduler.triggerManual(args.name);
|
|
2629
|
-
result = { content: [{ type: "text", text: triggerResult }] };
|
|
2630
|
-
}
|
|
1937
|
+
const triggerResult = await scheduler.triggerManual(args.name);
|
|
1938
|
+
result = { content: [{ type: "text", text: triggerResult }] };
|
|
2631
1939
|
break;
|
|
2632
1940
|
}
|
|
2633
1941
|
case "schedule_control": {
|
|
2634
|
-
|
|
2635
|
-
const proxyResult = await proxyRequest("/schedule-control", "POST", {
|
|
2636
|
-
name: args.name,
|
|
2637
|
-
action: args.action,
|
|
2638
|
-
minutes: args.minutes
|
|
2639
|
-
});
|
|
2640
|
-
if (!proxyResult.ok) {
|
|
2641
|
-
result = { content: [{ type: "text", text: `proxy schedule_control failed: ${proxyResult.error}` }], isError: true };
|
|
2642
|
-
break;
|
|
2643
|
-
}
|
|
2644
|
-
result = proxyResult.data?.result ?? { content: [{ type: "text", text: `unknown action: ${args.action}` }], isError: true };
|
|
2645
|
-
} else {
|
|
2646
|
-
result = scheduleControlResult(args);
|
|
2647
|
-
}
|
|
1942
|
+
result = scheduleControlResult(args);
|
|
2648
1943
|
break;
|
|
2649
1944
|
}
|
|
2650
1945
|
case "activate_channel_bridge": {
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
ownerHttpPort = 0;
|
|
2665
|
-
}
|
|
2666
|
-
result = { content: [{ type: "text", text: `channel bridge ${args.active ? "activated" : "deactivated"}` }] };
|
|
2667
|
-
}
|
|
2668
|
-
} else {
|
|
2669
|
-
const active = args.active === true;
|
|
2670
|
-
const wasActive = channelBridgeActive;
|
|
2671
|
-
channelBridgeActive = active;
|
|
2672
|
-
writeBridgeState(active);
|
|
2673
|
-
if (active && !wasActive) {
|
|
2674
|
-
refreshBridgeOwnershipSafe({ restoreBinding: true });
|
|
2675
|
-
}
|
|
2676
|
-
if (!active && wasActive) {
|
|
2677
|
-
stopServerTyping();
|
|
2678
|
-
// Tear down the owner-side runtime so Discord/scheduler/webhook/
|
|
2679
|
-
// event-pipeline/owner-HTTP/heartbeat don't keep running on a
|
|
2680
|
-
// deactivated bridge (and to prevent this owner from later
|
|
2681
|
-
// entering proxyMode against its own port).
|
|
2682
|
-
try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
|
|
2683
|
-
process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
|
|
2684
|
-
}
|
|
2685
|
-
// Also clear proxyMode/ownerHttpPort. Without this, a session
|
|
2686
|
-
// that was acting as proxy when deactivate landed keeps the
|
|
2687
|
-
// stale flag + port set; later direct tool calls then route
|
|
2688
|
-
// through proxyRequest() to a port whose owner has just been
|
|
2689
|
-
// stopped or stripped of auth, returning ECONNREFUSED/401.
|
|
2690
|
-
if (proxyMode) {
|
|
2691
|
-
proxyMode = false;
|
|
2692
|
-
ownerHttpPort = 0;
|
|
2693
|
-
}
|
|
1946
|
+
const active = args.active === true;
|
|
1947
|
+
const wasActive = channelBridgeActive;
|
|
1948
|
+
channelBridgeActive = active;
|
|
1949
|
+
writeBridgeState(active);
|
|
1950
|
+
if (active && !wasActive) {
|
|
1951
|
+
refreshBridgeOwnershipSafe({ restoreBinding: true });
|
|
1952
|
+
}
|
|
1953
|
+
if (!active && wasActive) {
|
|
1954
|
+
stopServerTyping();
|
|
1955
|
+
// Tear down the owner-side runtime so Discord/scheduler/webhook/
|
|
1956
|
+
// event-pipeline don't keep running on a deactivated bridge.
|
|
1957
|
+
try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
|
|
1958
|
+
process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
|
|
2694
1959
|
}
|
|
2695
|
-
result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
|
|
2696
1960
|
}
|
|
1961
|
+
result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
|
|
2697
1962
|
break;
|
|
2698
1963
|
}
|
|
2699
1964
|
case "reload_config": {
|
|
@@ -2716,7 +1981,7 @@ async function handleToolCall(name, args, _signal) {
|
|
|
2716
1981
|
}
|
|
2717
1982
|
case "inject_command": {
|
|
2718
1983
|
const cmd = String(args?.command || "").trim();
|
|
2719
|
-
const ALLOW = new Set(["
|
|
1984
|
+
const ALLOW = new Set(["clear"]);
|
|
2720
1985
|
if (!ALLOW.has(cmd)) {
|
|
2721
1986
|
result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
|
|
2722
1987
|
break;
|
|
@@ -2772,36 +2037,21 @@ async function handleToolCallWithBridgeRetry(toolName, args, signal) {
|
|
|
2772
2037
|
_lastForwardMs = now;
|
|
2773
2038
|
await forwarder.forwardNewText();
|
|
2774
2039
|
}
|
|
2775
|
-
if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
if (!bridgeRuntimeConnected) {
|
|
2779
|
-
return {
|
|
2780
|
-
content: [{ type: "text", text: `Channel runtime is not connected. Check token and network.` }],
|
|
2781
|
-
isError: true
|
|
2782
|
-
};
|
|
2783
|
-
}
|
|
2784
|
-
} else {
|
|
2785
|
-
// Do NOT pre-claim ownership here. claimBridgeOwnership() overwrites the
|
|
2786
|
-
// active-instance advert immediately, which kicks a live owner offline if
|
|
2787
|
-
// refreshBridgeOwnership() would have otherwise discovered them via
|
|
2788
|
-
// pingOwner() and entered proxyMode. Let refreshBridgeOwnership() below
|
|
2789
|
-
// ping/proxy the existing owner first and only fall through to a takeover
|
|
2790
|
-
// when the live owner is unreachable.
|
|
2791
|
-
for (let i = 0; i < 2 && !bridgeRuntimeConnected && !proxyMode; i++) {
|
|
2040
|
+
if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected) {
|
|
2041
|
+
// Remote-owner startup: ensure this owner's backend is connected.
|
|
2042
|
+
for (let i = 0; i < 2 && !bridgeRuntimeConnected; i++) {
|
|
2792
2043
|
try {
|
|
2793
2044
|
await refreshBridgeOwnership();
|
|
2794
2045
|
} catch {
|
|
2795
2046
|
}
|
|
2796
|
-
if (!bridgeRuntimeConnected
|
|
2047
|
+
if (!bridgeRuntimeConnected) await new Promise((r) => setTimeout(r, 300));
|
|
2797
2048
|
}
|
|
2798
|
-
if (!bridgeRuntimeConnected
|
|
2049
|
+
if (!bridgeRuntimeConnected) {
|
|
2799
2050
|
return {
|
|
2800
2051
|
content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
|
|
2801
2052
|
isError: true
|
|
2802
2053
|
};
|
|
2803
2054
|
}
|
|
2804
|
-
}
|
|
2805
2055
|
}
|
|
2806
2056
|
const result = await handleToolCall(toolName, args, signal);
|
|
2807
2057
|
const toolLine = OutputForwarder.buildToolLine(toolName, args);
|
|
@@ -2937,14 +2187,42 @@ backend.onMessage = (msg) => {
|
|
|
2937
2187
|
}).finally(() => forwarder.reset());
|
|
2938
2188
|
const previousPath = getPersistedTranscriptPath();
|
|
2939
2189
|
let boundTranscript = null;
|
|
2190
|
+
let stoleSelfTranscript = false;
|
|
2940
2191
|
let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
|
|
2192
|
+
// Reuse the current binding only while it still points at THIS owner's own
|
|
2193
|
+
// session. discoverSessionBoundTranscript() now ranks the live parent-chain
|
|
2194
|
+
// session (the one that forked this worker and receives injected input)
|
|
2195
|
+
// above a more-recently-touched neighbour, so when a co-located session
|
|
2196
|
+
// owns the stale binding we steal it back here instead of tailing the wrong
|
|
2197
|
+
// transcript for the rest of the process lifetime. Steal whenever the live
|
|
2198
|
+
// parent-chain (self) candidate resolves to a different path — even when its
|
|
2199
|
+
// transcript is not on disk yet: we keep selfBound.exists=false so the
|
|
2200
|
+
// downstream `!boundTranscript?.exists` branch routes through
|
|
2201
|
+
// rebindTranscriptContext()'s pending-bind + re-arm poll, which forwards the
|
|
2202
|
+
// first assistant reply once the self transcript is created. Marking the
|
|
2203
|
+
// stale neighbour path as exists=true here would suppress that rearm and
|
|
2204
|
+
// keep tailing the wrong session for the whole turn.
|
|
2941
2205
|
if (transcriptPath) {
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
transcriptPath,
|
|
2946
|
-
|
|
2947
|
-
|
|
2206
|
+
const selfBound = discoverSessionBoundTranscript();
|
|
2207
|
+
const shouldStealBoundTranscript = Boolean(
|
|
2208
|
+
selfBound?.transcriptPath &&
|
|
2209
|
+
!sameResolvedPath(selfBound.transcriptPath, transcriptPath) &&
|
|
2210
|
+
selfBound.active === true &&
|
|
2211
|
+
(selfBound.parentChain === true || selfBound.cwdMatches === true)
|
|
2212
|
+
);
|
|
2213
|
+
if (shouldStealBoundTranscript) {
|
|
2214
|
+
process.stderr.write(`mixdog: inbound rebind: stealing transcript ${transcriptPath} -> ${selfBound.transcriptPath} (source=${selfBound.source || "unknown"}, exists=${selfBound.exists})\n`);
|
|
2215
|
+
transcriptPath = selfBound.transcriptPath;
|
|
2216
|
+
boundTranscript = selfBound;
|
|
2217
|
+
stoleSelfTranscript = true;
|
|
2218
|
+
} else {
|
|
2219
|
+
boundTranscript = {
|
|
2220
|
+
sessionId: sessionIdFromTranscriptPath(transcriptPath),
|
|
2221
|
+
sessionCwd: statusState.read().sessionCwd ?? null,
|
|
2222
|
+
transcriptPath,
|
|
2223
|
+
exists: true
|
|
2224
|
+
};
|
|
2225
|
+
}
|
|
2948
2226
|
} else {
|
|
2949
2227
|
boundTranscript = discoverSessionBoundTranscript();
|
|
2950
2228
|
transcriptPath = pickUsableTranscriptPath(boundTranscript, previousPath);
|
|
@@ -2983,8 +2261,17 @@ backend.onMessage = (msg) => {
|
|
|
2983
2261
|
});
|
|
2984
2262
|
if (!boundTranscript?.exists) {
|
|
2985
2263
|
await rebindTranscriptContext(route.targetChatId, {
|
|
2264
|
+
// For a stolen self transcript (not yet on disk) the sync bind above
|
|
2265
|
+
// persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
|
|
2266
|
+
// setContext resume from offset 0 once the file appears — forwarding
|
|
2267
|
+
// the first assistant reply. Relying on replayFromStart instead would
|
|
2268
|
+
// race: the discovery loop only sets replayFromStart when it first saw
|
|
2269
|
+
// the transcript as PENDING, so a file that already exists on the first
|
|
2270
|
+
// loop iteration would bind at EOF and skip the reply. Non-steal keeps
|
|
2271
|
+
// the original catch-up-from-cursor behaviour.
|
|
2986
2272
|
previousPath: transcriptPath,
|
|
2987
2273
|
catchUp: true,
|
|
2274
|
+
catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
|
|
2988
2275
|
persistStatus: true
|
|
2989
2276
|
});
|
|
2990
2277
|
}
|
|
@@ -3011,24 +2298,29 @@ async function handleInbound(msg, route, options = {}) {
|
|
|
3011
2298
|
let text = msg.text;
|
|
3012
2299
|
const voiceAtts = msg.attachments.filter((a) => isVoiceAttachment(a.contentType));
|
|
3013
2300
|
if (voiceAtts.length > 0) {
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
const
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
2301
|
+
if (config.voice?.enabled === false) {
|
|
2302
|
+
process.stderr.write(`mixdog: voice.transcription skipped — voice.enabled=false\n`);
|
|
2303
|
+
text = text || "[voice message]";
|
|
2304
|
+
} else {
|
|
2305
|
+
try {
|
|
2306
|
+
const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
|
|
2307
|
+
// concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
|
|
2308
|
+
for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
|
|
2309
|
+
const _t0 = Date.now();
|
|
2310
|
+
const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
|
|
2311
|
+
const _elapsed = Date.now() - _t0;
|
|
2312
|
+
if (transcript) {
|
|
2313
|
+
text = transcript;
|
|
2314
|
+
process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
|
|
2315
|
+
} else {
|
|
2316
|
+
process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
|
|
2317
|
+
text = text || "[voice message \u2014 transcription failed]";
|
|
2318
|
+
}
|
|
3027
2319
|
}
|
|
2320
|
+
} catch (err) {
|
|
2321
|
+
process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
|
|
2322
|
+
text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
|
|
3028
2323
|
}
|
|
3029
|
-
} catch (err) {
|
|
3030
|
-
process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
|
|
3031
|
-
text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
|
|
3032
2324
|
}
|
|
3033
2325
|
}
|
|
3034
2326
|
const hasVoiceAtt = voiceAtts.length > 0;
|
|
@@ -3037,7 +2329,6 @@ async function handleInbound(msg, route, options = {}) {
|
|
|
3037
2329
|
attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
|
|
3038
2330
|
} : {};
|
|
3039
2331
|
const messageBody = route.sourceMode === "monitor" && route.sourceLabel ? `[monitor:${route.sourceLabel}] ${text}` : text;
|
|
3040
|
-
const now = (/* @__PURE__ */ new Date()).toLocaleString();
|
|
3041
2332
|
const notificationMeta = {
|
|
3042
2333
|
chat_id: route.targetChatId,
|
|
3043
2334
|
message_id: msg.messageId,
|
|
@@ -3052,8 +2343,7 @@ async function handleInbound(msg, route, options = {}) {
|
|
|
3052
2343
|
...attMeta,
|
|
3053
2344
|
...msg.imagePath ? { image_path: msg.imagePath } : {}
|
|
3054
2345
|
};
|
|
3055
|
-
const notificationContent =
|
|
3056
|
-
${messageBody}`;
|
|
2346
|
+
const notificationContent = messageBody;
|
|
3057
2347
|
sendNotifyToParent("notifications/claude/channel", {
|
|
3058
2348
|
content: notificationContent,
|
|
3059
2349
|
meta: notificationMeta
|
|
@@ -3085,19 +2375,47 @@ async function init(_sharedMcp) {
|
|
|
3085
2375
|
});
|
|
3086
2376
|
}
|
|
3087
2377
|
async function start() {
|
|
3088
|
-
startChannelDaemonIdleMonitor();
|
|
3089
2378
|
channelBridgeActive = true;
|
|
3090
2379
|
writeBridgeState(true);
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
2380
|
+
// Opt-in remote, single-owner, last-wins. Claim the seat immediately so a
|
|
2381
|
+
// later `mixdog --remote` session overwrites us and we drop on our next
|
|
2382
|
+
// refresh tick. Then connect the owned runtime and arm the ownership timer
|
|
2383
|
+
// that keeps checking whether a newer session has taken over.
|
|
2384
|
+
claimBridgeOwnership("remote start");
|
|
2385
|
+
const _bindingReadyStart = Date.now();
|
|
2386
|
+
try {
|
|
2387
|
+
await refreshBridgeOwnership({ restoreBinding: true });
|
|
2388
|
+
bindingReadyStatus = "resolved";
|
|
2389
|
+
dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
|
|
2390
|
+
_bindingReadyResolve(true);
|
|
2391
|
+
} catch (e) {
|
|
2392
|
+
bindingReadyStatus = "rejected";
|
|
2393
|
+
dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
|
|
2394
|
+
_bindingReadyResolve(e);
|
|
2395
|
+
}
|
|
2396
|
+
// Ownership timer: keep checking whether a newer remote session has taken
|
|
2397
|
+
// over (last-wins) so a superseded owner disconnects promptly.
|
|
2398
|
+
if (!bridgeOwnershipTimer) {
|
|
2399
|
+
bridgeOwnershipTimer = setInterval(() => {
|
|
2400
|
+
refreshBridgeOwnershipSafe();
|
|
2401
|
+
}, 3e3);
|
|
2402
|
+
bridgeOwnershipTimer.unref?.();
|
|
2403
|
+
}
|
|
2404
|
+
// Hot-reload config on file change (schedules/webhooks/events).
|
|
2405
|
+
if (!_configWatcher) {
|
|
2406
|
+
try {
|
|
2407
|
+
_configWatcher = fs.watch(path.join(DATA_DIR, "mixdog-config.json"), () => {
|
|
2408
|
+
if (_reloadDebounce) clearTimeout(_reloadDebounce);
|
|
2409
|
+
_reloadDebounce = setTimeout(() => { reloadRuntimeConfig().catch(() => {}); }, 500);
|
|
2410
|
+
});
|
|
2411
|
+
} catch {}
|
|
3095
2412
|
}
|
|
3096
2413
|
// Pre-warm the whisper-server manager once at owner startup so the first
|
|
3097
2414
|
// voice transcription does not pay cold-start cost. Non-blocking: failures
|
|
3098
2415
|
// (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
|
|
3099
2416
|
void (async () => {
|
|
3100
2417
|
try {
|
|
2418
|
+
if (config.voice?.enabled === false) return;
|
|
3101
2419
|
const runtime = resolveVoiceRuntime(DATA_DIR);
|
|
3102
2420
|
if (!runtime?.installed) return;
|
|
3103
2421
|
const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
|
|
@@ -3109,7 +2427,6 @@ async function start() {
|
|
|
3109
2427
|
})();
|
|
3110
2428
|
}
|
|
3111
2429
|
async function stop() {
|
|
3112
|
-
stopChannelDaemonIdleMonitor();
|
|
3113
2430
|
try { await stopVoiceWhisperServer(); } catch {}
|
|
3114
2431
|
await stopOwnedRuntime("unified server stop");
|
|
3115
2432
|
cleanupInstanceRuntimeFiles(INSTANCE_ID);
|
|
@@ -3117,113 +2434,13 @@ async function stop() {
|
|
|
3117
2434
|
clearInterval(bridgeOwnershipTimer);
|
|
3118
2435
|
bridgeOwnershipTimer = null;
|
|
3119
2436
|
}
|
|
2437
|
+
if (_reloadDebounce) { clearTimeout(_reloadDebounce); _reloadDebounce = null; }
|
|
2438
|
+
if (_configWatcher) { try { _configWatcher.close(); } catch {} _configWatcher = null; }
|
|
3120
2439
|
if (turnEndWatcher) {
|
|
3121
2440
|
try { turnEndWatcher.close(); } catch {}
|
|
3122
2441
|
turnEndWatcher = null;
|
|
3123
2442
|
}
|
|
3124
2443
|
}
|
|
3125
|
-
if (process.env.MIXDOG_CHANNELS_AUTO_BOOT !== '0') {
|
|
3126
|
-
let detectChannelFlag = function() {
|
|
3127
|
-
const isWin = process.platform === "win32";
|
|
3128
|
-
const flagRe = /--channels\b|--dangerously-load-development-channels\b/;
|
|
3129
|
-
if (process.env.MIXDOG_CHANNEL_FLAG === "1") return true;
|
|
3130
|
-
if (process.env.MIXDOG_CHANNEL_FLAG === "0") return false;
|
|
3131
|
-
if (isWin) {
|
|
3132
|
-
// Single CIM snapshot + in-process chain walk: one powershell.exe spawn
|
|
3133
|
-
// instead of up to 12 synchronous wmic/powershell spawns. Snapshots all
|
|
3134
|
-
// processes into a map, walks from process.ppid up to 6 ancestors
|
|
3135
|
-
// (closest first), and emits each ancestor CommandLine on its own line
|
|
3136
|
-
// for the same flagRe test below. Any failure returns false.
|
|
3137
|
-
try {
|
|
3138
|
-
const ps = [
|
|
3139
|
-
'$procs = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine;',
|
|
3140
|
-
'$map = @{};',
|
|
3141
|
-
'foreach ($p in $procs) { $map[[int]$p.ProcessId] = $p }',
|
|
3142
|
-
`$cur = ${Number(process.ppid)};`,
|
|
3143
|
-
'for ($i = 0; $i -lt 6; $i++) {',
|
|
3144
|
-
' if (-not $cur -or $cur -le 1) { break }',
|
|
3145
|
-
' $p = $map[[int]$cur]; if ($null -eq $p) { break }',
|
|
3146
|
-
' [Console]::WriteLine($p.CommandLine);',
|
|
3147
|
-
' $next = [int]$p.ParentProcessId;',
|
|
3148
|
-
' if ($next -eq [int]$cur -or $next -le 1) { break }',
|
|
3149
|
-
' $cur = $next',
|
|
3150
|
-
'}',
|
|
3151
|
-
].join(" ");
|
|
3152
|
-
const r = spawnSync("powershell.exe", ["-NoProfile", "-Command", ps], {
|
|
3153
|
-
encoding: "utf8",
|
|
3154
|
-
timeout: 5e3,
|
|
3155
|
-
windowsHide: true,
|
|
3156
|
-
});
|
|
3157
|
-
const out = String(r.stdout || "");
|
|
3158
|
-
for (const line of out.split(/\r?\n/)) {
|
|
3159
|
-
if (flagRe.test(line)) return true;
|
|
3160
|
-
}
|
|
3161
|
-
} catch {}
|
|
3162
|
-
return false;
|
|
3163
|
-
}
|
|
3164
|
-
let pid = process.ppid;
|
|
3165
|
-
for (let depth = 0; pid && pid > 1 && depth < 6; depth++) {
|
|
3166
|
-
try {
|
|
3167
|
-
const cmdLine = execSync(`ps -p ${pid} -o args=`, { encoding: "utf8", timeout: 3e3, windowsHide: true });
|
|
3168
|
-
if (flagRe.test(cmdLine)) return true;
|
|
3169
|
-
pid = parseInt(execSync(`ps -p ${pid} -o ppid=`, { encoding: "utf8", timeout: 3e3, windowsHide: true }).trim(), 10);
|
|
3170
|
-
} catch {
|
|
3171
|
-
break;
|
|
3172
|
-
}
|
|
3173
|
-
}
|
|
3174
|
-
return false;
|
|
3175
|
-
};
|
|
3176
|
-
_channelFlagDetected = detectChannelFlag();
|
|
3177
|
-
if (isMixdogDebug()) {
|
|
3178
|
-
fs.appendFileSync(_bootLog, `[${localTimestamp()}] channelFlag: ${_channelFlagDetected}\n`);
|
|
3179
|
-
if (_channelFlagDetected) {
|
|
3180
|
-
fs.appendFileSync(_bootLog, `[${localTimestamp()}] channel mode detected — bridge auto-activated\n`);
|
|
3181
|
-
}
|
|
3182
|
-
}
|
|
3183
|
-
if (_channelFlagDetected) {
|
|
3184
|
-
channelBridgeActive = true;
|
|
3185
|
-
}
|
|
3186
|
-
writeBridgeState(channelBridgeActive);
|
|
3187
|
-
const previousOwner = readActiveInstance();
|
|
3188
|
-
noteStartupHandoff(previousOwner);
|
|
3189
|
-
// Do not claim ownership just because this terminal is channel-capable.
|
|
3190
|
-
// refreshBridgeOwnership() below pings/proxies a live owner first and only
|
|
3191
|
-
// claims when there is no reachable active owner or the record is stale.
|
|
3192
|
-
const _bindingReadyStart = Date.now();
|
|
3193
|
-
void refreshBridgeOwnership({ restoreBinding: true }).then(
|
|
3194
|
-
(v) => {
|
|
3195
|
-
bindingReadyStatus = "resolved";
|
|
3196
|
-
dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
|
|
3197
|
-
_bindingReadyResolve(v);
|
|
3198
|
-
},
|
|
3199
|
-
(e) => {
|
|
3200
|
-
bindingReadyStatus = "rejected";
|
|
3201
|
-
dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
|
|
3202
|
-
_bindingReadyResolve(e);
|
|
3203
|
-
}
|
|
3204
|
-
);
|
|
3205
|
-
bridgeOwnershipTimer = setInterval(() => {
|
|
3206
|
-
refreshBridgeOwnershipSafe();
|
|
3207
|
-
}, 3e3);
|
|
3208
|
-
// Hook/statusline IPC is owned by the MCP parent process so it is available
|
|
3209
|
-
// before channels finishes bridge ownership and backend startup.
|
|
3210
|
-
const configPath = path.join(DATA_DIR, "mixdog-config.json");
|
|
3211
|
-
let reloadDebounce = null;
|
|
3212
|
-
let configWatcher = null;
|
|
3213
|
-
try {
|
|
3214
|
-
configWatcher = fs.watch(configPath, () => {
|
|
3215
|
-
if (reloadDebounce) clearTimeout(reloadDebounce);
|
|
3216
|
-
reloadDebounce = setTimeout(() => {
|
|
3217
|
-
reloadRuntimeConfig().catch(() => {});
|
|
3218
|
-
}, 500);
|
|
3219
|
-
});
|
|
3220
|
-
} catch {
|
|
3221
|
-
}
|
|
3222
|
-
process.on("exit", () => {
|
|
3223
|
-
if (configWatcher) { try { configWatcher.close(); } catch {} }
|
|
3224
|
-
if (bridgeOwnershipTimer) { clearInterval(bridgeOwnershipTimer); }
|
|
3225
|
-
});
|
|
3226
|
-
}
|
|
3227
2444
|
// ── IPC worker mode ──────────────────────────────────────────────
|
|
3228
2445
|
if (_isWorkerMode && process.send) {
|
|
3229
2446
|
// SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
|
|
@@ -3385,11 +2602,11 @@ if (_isWorkerMode && process.send) {
|
|
|
3385
2602
|
try {
|
|
3386
2603
|
await start()
|
|
3387
2604
|
bootProfile("worker:ready", { ms: (performance.now() - startedAt).toFixed(1) })
|
|
3388
|
-
process.send({ type: 'ready'
|
|
2605
|
+
process.send({ type: 'ready' })
|
|
3389
2606
|
} catch (e) {
|
|
3390
2607
|
bootProfile("worker:failed", { ms: (performance.now() - startedAt).toFixed(1), error: e?.message || String(e) })
|
|
3391
2608
|
process.stderr.write(`[channels-worker] start() failed: ${e && (e.message || e)}\n`)
|
|
3392
|
-
process.send({ type: 'ready',
|
|
2609
|
+
process.send({ type: 'ready', degraded: true, error: e?.message || String(e) })
|
|
3393
2610
|
}
|
|
3394
2611
|
})()
|
|
3395
2612
|
}
|