mixdog 0.9.3 → 0.9.4
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 +7 -3
- package/scripts/bench/lead-review-tasks-r3.json +20 -0
- package/scripts/bench/lead-review-tasks.json +20 -0
- package/scripts/bench/r4-mixed-tasks.json +20 -0
- package/scripts/bench/review-tasks.json +20 -0
- package/scripts/bench/round-codex.json +114 -0
- package/scripts/bench/round-mixdog-lead-r3.json +269 -0
- package/scripts/bench/round-mixdog-lead.json +269 -0
- package/scripts/bench/round-mixdog.json +126 -0
- package/scripts/bench/round-r10-bigsample.json +679 -0
- package/scripts/bench/round-r11-codexalign.json +257 -0
- package/scripts/bench/round-r4-codex.json +114 -0
- package/scripts/bench/round-r4-mixed.json +225 -0
- package/scripts/bench/round-r5-gpt-lead.json +259 -0
- package/scripts/bench/round-r6-codex.json +114 -0
- package/scripts/bench/round-r6-solo.json +257 -0
- package/scripts/bench/round-r7-full.json +254 -0
- package/scripts/bench/round-r8-fulldefault.json +255 -0
- package/scripts/bench-run.mjs +215 -29
- package/scripts/freevar-smoke.mjs +95 -0
- package/scripts/internal-comms-bench.mjs +1 -0
- package/scripts/internal-comms-smoke.mjs +10 -9
- package/scripts/mouse-probe.mjs +45 -0
- package/scripts/output-style-bench.mjs +13 -6
- package/scripts/output-style-smoke.mjs +4 -4
- package/scripts/provider-toolcall-test.mjs +7 -3
- package/scripts/recall-usecase-cases.json +18 -0
- package/scripts/recall-usecase-probe.json +6 -0
- package/scripts/session-bench.mjs +152 -6
- package/scripts/tool-smoke.mjs +23 -63
- package/scripts/tui-render-smoke.mjs +90 -0
- package/scripts/webhook-smoke.mjs +208 -0
- package/src/agents/debugger/AGENT.md +4 -1
- package/src/agents/heavy-worker/AGENT.md +6 -5
- package/src/agents/maintainer/AGENT.md +4 -0
- package/src/agents/reviewer/AGENT.md +2 -1
- package/src/agents/worker/AGENT.md +8 -4
- package/src/lib/rules-builder.cjs +4 -0
- package/src/mixdog-session-runtime.mjs +632 -2042
- package/src/output-styles/default.md +34 -9
- package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
- package/src/output-styles/minimal.md +4 -1
- package/src/output-styles/simple.md +22 -7
- package/src/rules/agent/00-common.md +2 -0
- package/src/rules/lead/lead-brief.md +12 -0
- package/src/rules/lead/lead-tool.md +0 -11
- package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
- package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
- package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
- package/src/runtime/agent/orchestrator/config.mjs +69 -2
- package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
- package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +297 -2123
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +130 -1002
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
- package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
- package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
- package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
- package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
- package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
- package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
- package/src/runtime/agent/orchestrator/session/loop.mjs +251 -397
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
- package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
- package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
- package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +32 -41
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
- package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
- package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
- package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
- package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
- package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
- package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
- package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
- package/src/runtime/channels/backends/discord-access.mjs +32 -0
- package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
- package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
- package/src/runtime/channels/backends/discord.mjs +12 -292
- package/src/runtime/channels/index.mjs +229 -663
- package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
- package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
- package/src/runtime/channels/lib/event-queue.mjs +63 -4
- package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
- package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
- package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
- package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
- package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
- package/src/runtime/channels/lib/session-discovery.mjs +56 -4
- package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
- package/src/runtime/channels/lib/tool-format.mjs +1 -1
- package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
- package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
- package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
- package/src/runtime/channels/lib/webhook/log.mjs +42 -0
- package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
- package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
- package/src/runtime/channels/lib/webhook.mjs +36 -570
- package/src/runtime/channels/tool-defs.mjs +11 -130
- package/src/runtime/memory/index.mjs +201 -1948
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
- package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
- package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
- package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
- package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
- package/src/runtime/memory/lib/memory-embed.mjs +149 -0
- package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
- package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
- package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
- package/src/runtime/memory/lib/query-handlers.mjs +780 -0
- package/src/runtime/memory/lib/recall-format.mjs +55 -0
- package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
- package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
- package/src/runtime/memory/tool-defs.mjs +5 -13
- package/src/runtime/search/lib/http-fetch.mjs +274 -0
- package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
- package/src/runtime/search/lib/web-tools.mjs +24 -602
- package/src/runtime/shared/atomic-file.mjs +26 -1
- package/src/runtime/shared/launcher-control.mjs +2 -2
- package/src/runtime/shared/tool-primitives.mjs +308 -0
- package/src/runtime/shared/tool-result-summary.mjs +515 -0
- package/src/runtime/shared/tool-surface.mjs +80 -898
- package/src/runtime/shared/transcript-writer.mjs +23 -0
- package/src/runtime/shared/update-checker.mjs +7 -4
- package/src/session-runtime/config-helpers.mjs +84 -2
- package/src/session-runtime/config-lifecycle.mjs +232 -0
- package/src/session-runtime/cwd-plugins.mjs +226 -0
- package/src/session-runtime/mcp-glue.mjs +177 -0
- package/src/session-runtime/model-recency.mjs +111 -0
- package/src/session-runtime/native-search.mjs +247 -0
- package/src/session-runtime/output-styles.mjs +11 -9
- package/src/session-runtime/prewarm.mjs +142 -0
- package/src/session-runtime/provider-models.mjs +278 -0
- package/src/session-runtime/provider-usage.mjs +120 -0
- package/src/session-runtime/quick-model-rows.mjs +170 -0
- package/src/session-runtime/quick-search-models.mjs +46 -0
- package/src/session-runtime/session-hooks.mjs +93 -0
- package/src/session-runtime/settings-api.mjs +319 -0
- package/src/session-runtime/tool-catalog.mjs +29 -29
- package/src/session-runtime/tool-defs.mjs +84 -0
- package/src/session-runtime/warmup-schedulers.mjs +201 -0
- package/src/standalone/agent-tool/helpers.mjs +237 -0
- package/src/standalone/agent-tool/notify.mjs +107 -0
- package/src/standalone/agent-tool/provider-init.mjs +143 -0
- package/src/standalone/agent-tool/render.mjs +152 -0
- package/src/standalone/agent-tool/tool-def.mjs +55 -0
- package/src/standalone/agent-tool.mjs +110 -671
- package/src/standalone/channel-worker.mjs +4 -7
- package/src/standalone/explore-tool.mjs +30 -9
- package/src/standalone/hook-bus/config.mjs +207 -0
- package/src/standalone/hook-bus/constants.mjs +90 -0
- package/src/standalone/hook-bus/handlers.mjs +481 -0
- package/src/standalone/hook-bus/payload.mjs +31 -0
- package/src/standalone/hook-bus/rules.mjs +77 -0
- package/src/standalone/hook-bus.mjs +77 -870
- package/src/standalone/memory-runtime-proxy.mjs +7 -0
- package/src/standalone/opencode-go-login.mjs +5 -1
- package/src/standalone/provider-admin.mjs +1 -16
- package/src/standalone/usage-dashboard.mjs +3 -1
- package/src/tui/App.jsx +945 -8094
- package/src/tui/app/app-format.mjs +206 -0
- package/src/tui/app/channel-pickers.mjs +510 -0
- package/src/tui/app/clipboard.mjs +67 -0
- package/src/tui/app/core-memory-picker.mjs +210 -0
- package/src/tui/app/extension-pickers.mjs +506 -0
- package/src/tui/app/input-parsers.mjs +193 -0
- package/src/tui/app/maintenance-pickers.mjs +324 -0
- package/src/tui/app/model-options.mjs +330 -0
- package/src/tui/app/model-picker.mjs +365 -0
- package/src/tui/app/onboarding-steps.mjs +400 -0
- package/src/tui/app/project-picker.mjs +247 -0
- package/src/tui/app/provider-setup-picker.mjs +580 -0
- package/src/tui/app/resume-picker.mjs +55 -0
- package/src/tui/app/route-pickers.mjs +419 -0
- package/src/tui/app/settings-picker.mjs +490 -0
- package/src/tui/app/slash-commands.mjs +101 -0
- package/src/tui/app/slash-dispatch.mjs +427 -0
- package/src/tui/app/text-layout.mjs +46 -0
- package/src/tui/app/theme-effort-pickers.mjs +154 -0
- package/src/tui/app/transcript-window.mjs +671 -0
- package/src/tui/app/use-mouse-input.mjs +460 -0
- package/src/tui/app/use-prompt-handlers.mjs +310 -0
- package/src/tui/app/use-transcript-scroll.mjs +510 -0
- package/src/tui/app/use-transcript-window.mjs +589 -0
- package/src/tui/components/ConfirmBar.jsx +1 -1
- package/src/tui/components/Picker.jsx +32 -4
- package/src/tui/components/PromptInput.jsx +23 -101
- package/src/tui/components/SlashCommandPalette.jsx +8 -1
- package/src/tui/components/StatusLine.jsx +63 -12
- package/src/tui/components/TextEntryPanel.jsx +11 -0
- package/src/tui/components/ToolExecution.jsx +52 -594
- package/src/tui/components/TranscriptItem.jsx +105 -0
- package/src/tui/components/UsagePanel.jsx +18 -4
- package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
- package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
- package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
- package/src/tui/components/tool-execution/text-format.mjs +161 -0
- package/src/tui/display-width.mjs +20 -3
- package/src/tui/dist/index.mjs +19652 -18630
- package/src/tui/engine/agent-job-feed.mjs +133 -0
- package/src/tui/engine/notification-plan.mjs +76 -0
- package/src/tui/engine/render-timing.mjs +17 -0
- package/src/tui/engine/tool-approval.mjs +94 -0
- package/src/tui/engine/tool-card-results.mjs +234 -0
- package/src/tui/engine/tool-result-status.mjs +135 -0
- package/src/tui/engine.mjs +122 -562
- package/src/tui/figures.mjs +5 -0
- package/src/tui/index.jsx +105 -0
- package/src/tui/input-editing.mjs +2 -2
- package/src/tui/markdown/format-token.mjs +4 -1
- package/src/tui/statusline-ansi-bridge.mjs +11 -3
- package/src/tui/theme.mjs +6 -0
- package/src/ui/statusline-agents.mjs +213 -0
- package/src/ui/statusline-format.mjs +146 -0
- package/src/ui/statusline-segments.mjs +148 -0
- package/src/ui/statusline.mjs +67 -501
- package/src/ui/tool-card.mjs +0 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
- package/src/workflows/default/WORKFLOW.md +1 -1
- package/src/workflows/sequential/WORKFLOW.md +1 -1
- package/vendor/ink/build/display-width.js +19 -3
- package/vendor/ink/build/ink.js +103 -6
- package/vendor/ink/build/log-update.js +17 -3
- package/vendor/ink/build/wrap-text.js +125 -0
- package/scripts/_test-folder-dialog.mjs +0 -30
- package/scripts/fix-brief-fn.mjs +0 -35
- package/scripts/fix-format-tool-surface.mjs +0 -24
- package/scripts/fix-tool-exec-visible.mjs +0 -42
- package/scripts/patch-agent-brief.mjs +0 -48
- package/scripts/patch-app.mjs +0 -21
- package/scripts/patch-app2.mjs +0 -18
- package/scripts/patch-dist-brief.mjs +0 -96
- package/scripts/patch-tool-exec.mjs +0 -70
- package/src/examples/schedules/SCHEDULE.example.md +0 -32
- package/src/examples/webhooks/WEBHOOK.example.md +0 -40
- package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
- package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
- package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
- package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
- package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
- package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
- package/src/standalone/agent-task-status.test.mjs +0 -76
- package/src/tui/components/tool-output-format.test.mjs +0 -399
- package/src/tui/display-width.test.mjs +0 -35
- package/src/tui/engine-runtime-notification.test.mjs +0 -115
- package/src/tui/engine-tool-result-text.test.mjs +0 -75
- package/src/tui/input-editing.selection.test.mjs +0 -75
- package/src/tui/markdown/format-token.test.mjs +0 -354
- package/src/tui/markdown/render-ansi.test.mjs +0 -108
- package/src/tui/markdown/stream-fence.test.mjs +0 -26
- package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
- package/src/tui/paste-fix.test.mjs +0 -119
- package/src/tui/prompt-history-store.test.mjs +0 -52
- package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
- package/src/tui/transcript-tool-failures.test.mjs +0 -111
- package/src/ui/markdown.test.mjs +0 -70
- package/src/ui/statusline-context-label.test.mjs +0 -15
- package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
- package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
1
|
import * as fs from "fs";
|
|
3
2
|
import * as os from "os";
|
|
4
3
|
import * as path from "path";
|
|
@@ -7,17 +6,15 @@ import { createRequire } from "module";
|
|
|
7
6
|
const _require = createRequire(import.meta.url);
|
|
8
7
|
import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
|
|
9
8
|
import { resolveVoiceRuntime } from "./lib/voice-runtime-fetcher.mjs";
|
|
10
|
-
import { ensureReady,
|
|
9
|
+
import { ensureReady, stopVoiceWhisperServer } from "./lib/whisper-server.mjs";
|
|
11
10
|
import { loadConfig as loadAgentConfig } from "../agent/orchestrator/config.mjs";
|
|
12
11
|
import { captureOriginalUserCwd, readLastSessionCwd } from "../shared/user-cwd.mjs";
|
|
13
|
-
import { managedLaunchId, enqueueLauncherCommand } from "../shared/launcher-control.mjs";
|
|
14
12
|
import { initProviders } from "../agent/orchestrator/providers/registry.mjs";
|
|
15
13
|
import { Scheduler } from "./lib/scheduler.mjs";
|
|
16
14
|
import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./lib/status-snapshot.mjs";
|
|
17
15
|
import { hasPending as dispatchHasPending } from "../agent/orchestrator/dispatch-persist.mjs";
|
|
18
16
|
import { setListener as setActivityBusListener } from "../agent/orchestrator/activity-bus.mjs";
|
|
19
17
|
import { stripSoftWarns } from "../agent/orchestrator/tool-loop-guard.mjs";
|
|
20
|
-
import { invalidatePrefetchCache } from "../agent/orchestrator/session/cache/prefetch-cache.mjs";
|
|
21
18
|
import { WebhookServer } from "./lib/webhook.mjs";
|
|
22
19
|
import { EventPipeline } from "./lib/event-pipeline.mjs";
|
|
23
20
|
import { startCliWorker } from "./lib/cli-worker-host.mjs";
|
|
@@ -63,7 +60,12 @@ import {
|
|
|
63
60
|
BENIGN_CRASH_STREAK_WINDOW_MS,
|
|
64
61
|
} from "./lib/crash-log.mjs";
|
|
65
62
|
import { dropTrace, preview, _dtIdxFlush } from "./lib/index-drop-trace.mjs";
|
|
66
|
-
import {
|
|
63
|
+
import { createVoiceTranscription } from "./lib/voice-transcription.mjs";
|
|
64
|
+
import { createBackendDispatch } from "./lib/backend-dispatch.mjs";
|
|
65
|
+
import { createParentBridge } from "./lib/parent-bridge.mjs";
|
|
66
|
+
import { createInboundRouting } from "./lib/inbound-routing.mjs";
|
|
67
|
+
import { createToolDispatch } from "./lib/tool-dispatch.mjs";
|
|
68
|
+
import { createOwnerHeartbeat } from "./lib/owner-heartbeat.mjs";
|
|
67
69
|
const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.url).href;
|
|
68
70
|
const {
|
|
69
71
|
appendEntry: memoryAppendEntry,
|
|
@@ -201,66 +203,11 @@ const INSTRUCTIONS = "";
|
|
|
201
203
|
// never `connect()`ed to any transport, so `.notification()` silently
|
|
202
204
|
// threw 'Not connected' inside the SDK and every call was dropped by an
|
|
203
205
|
// outer `.catch(() => {})`. That regression is what this path replaces.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
m[k] = k === 'silent_to_agent' ? (v === true || v === 'true') : String(v);
|
|
210
|
-
}
|
|
211
|
-
return { ...params, meta: m };
|
|
212
|
-
}
|
|
213
|
-
return params;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function sendNotifyToParent(method, params) {
|
|
217
|
-
// CC channel schema requires meta: Record<string,string> (channelNotification.ts).
|
|
218
|
-
// Coerce every meta value to string so a non-string (e.g. a Discord
|
|
219
|
-
// interaction.type number) can't fail zod and silently drop the notify.
|
|
220
|
-
// silent_to_agent stays boolean — an internal routing flag the daemon
|
|
221
|
-
// router / agentNotify consume (=== true) before the CC zod boundary.
|
|
222
|
-
const outParams = normalizeChannelNotifyParams(method, params);
|
|
223
|
-
if (!process.send) {
|
|
224
|
-
try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
try {
|
|
228
|
-
process.send({ type: 'notify', method, params: outParams });
|
|
229
|
-
} catch (err) {
|
|
230
|
-
try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ── Memory worker bridge (worker → parent → memory) ─────────────────
|
|
235
|
-
// The channels worker does not own the memory worker handle. To trigger
|
|
236
|
-
// memory tool actions (e.g. cycle1) we send `memory_call_request` to the
|
|
237
|
-
// parent, which routes through callWorker('memory', ...) and ships the
|
|
238
|
-
// result back as `memory_call_response`. The response listener is
|
|
239
|
-
// integrated into the main IPC handler below (not a second listener).
|
|
240
|
-
const _memoryCallPending = new Map();
|
|
241
|
-
let _memoryCallSeq = 0;
|
|
242
|
-
|
|
243
|
-
function callMemoryAction(action, args, timeoutMs) {
|
|
244
|
-
return new Promise((resolve, reject) => {
|
|
245
|
-
if (!process.send) return reject(new Error('not a worker process'));
|
|
246
|
-
const callId = `mc_${INSTANCE_ID}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
247
|
-
const timer = setTimeout(() => {
|
|
248
|
-
_memoryCallPending.delete(callId);
|
|
249
|
-
reject(new Error(`memory_call ${action} timed out after ${timeoutMs}ms`));
|
|
250
|
-
}, timeoutMs);
|
|
251
|
-
_memoryCallPending.set(callId, {
|
|
252
|
-
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
253
|
-
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
254
|
-
});
|
|
255
|
-
try {
|
|
256
|
-
process.send({ type: 'memory_call_request', callId, action, args: args || {} });
|
|
257
|
-
} catch (e) {
|
|
258
|
-
_memoryCallPending.delete(callId);
|
|
259
|
-
clearTimeout(timer);
|
|
260
|
-
reject(e);
|
|
261
|
-
}
|
|
262
|
-
});
|
|
263
|
-
}
|
|
206
|
+
const {
|
|
207
|
+
sendNotifyToParent,
|
|
208
|
+
callMemoryAction,
|
|
209
|
+
handleMemoryCallResponse,
|
|
210
|
+
} = createParentBridge({ getInstanceId: () => INSTANCE_ID });
|
|
264
211
|
function resolveChannelLabel(channelsConfig, label) {
|
|
265
212
|
if (!label || !channelsConfig) return label;
|
|
266
213
|
const entry = channelsConfig[label];
|
|
@@ -362,15 +309,20 @@ forwarder.setOnIdle(() => {
|
|
|
362
309
|
// (webhook enabled or event rules present). Without an event pipeline the
|
|
363
310
|
// forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
|
|
364
311
|
// non-owner process forward transcript output (duplicate Discord sends).
|
|
365
|
-
// The closure reads bridgeRuntimeConnected at call time
|
|
366
|
-
|
|
312
|
+
// The closure reads bridgeRuntimeConnected at call time as a fast-path AND;
|
|
313
|
+
// bridgeRuntimeConnected alone can go stale (e.g. this process lost the seat
|
|
314
|
+
// but has not yet observed it), so currentOwnerState().owned is re-read at
|
|
315
|
+
// probe time as the source of truth for ownership.
|
|
316
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
367
317
|
function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
368
318
|
if (!transcriptPath) return;
|
|
369
319
|
forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
|
|
370
320
|
const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
|
|
371
321
|
forwarder.startWatch();
|
|
372
322
|
void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
|
|
373
|
-
|
|
323
|
+
// onlyIfOwned: binds happen on the owned path, but discovery/poll loops
|
|
324
|
+
// above can outlast an ownership handoff — never overwrite a newer owner.
|
|
325
|
+
refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
|
|
374
326
|
if (options.persistStatus !== false) {
|
|
375
327
|
statusState.update((state) => {
|
|
376
328
|
state.channelId = channelId;
|
|
@@ -597,13 +549,7 @@ let bridgeRuntimeStarting = false;
|
|
|
597
549
|
let _ownedRuntimeStopRequested = false;
|
|
598
550
|
let bridgeOwnershipRefreshInFlight = null;
|
|
599
551
|
let bridgeOwnershipTimer = null;
|
|
600
|
-
let lastOwnershipNote = "";
|
|
601
552
|
const ACTIVE_OWNER_STALE_MS = 1e4;
|
|
602
|
-
// Owner heartbeat: keep active-instance.json fresh so other sessions cannot
|
|
603
|
-
// steal the seat after 10 s of channel-action silence. unref'd interval —
|
|
604
|
-
// never blocks process exit. Single JSON atomic write, no measurable load.
|
|
605
|
-
const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
606
|
-
let ownerHeartbeatTimer = null;
|
|
607
553
|
// Owner gating here is multi-process runtime coordination: only the active
|
|
608
554
|
// bindingReady gates all send paths until the boot-time refreshBridgeOwnership
|
|
609
555
|
// ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
|
|
@@ -613,31 +559,21 @@ let bindingReadyStatus = "pending";
|
|
|
613
559
|
let _bindingReadyResolve;
|
|
614
560
|
const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
|
|
615
561
|
dropTrace("bindingReady.create", { status: bindingReadyStatus });
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
owned: active?.instanceId === INSTANCE_ID
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
function getBridgeOwnershipSnapshot() {
|
|
635
|
-
return currentOwnerState();
|
|
636
|
-
}
|
|
637
|
-
function claimBridgeOwnership(reason) {
|
|
638
|
-
refreshActiveInstance(INSTANCE_ID);
|
|
639
|
-
logOwnership(`claimed owner (${reason})`);
|
|
640
|
-
}
|
|
562
|
+
// ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
|
|
563
|
+
// Extracted → lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
|
|
564
|
+
// dedup; bound to live identity + active-instance primitives.
|
|
565
|
+
const {
|
|
566
|
+
logOwnership,
|
|
567
|
+
currentOwnerState,
|
|
568
|
+
getBridgeOwnershipSnapshot,
|
|
569
|
+
claimBridgeOwnership,
|
|
570
|
+
startOwnerHeartbeat,
|
|
571
|
+
stopOwnerHeartbeat,
|
|
572
|
+
} = createOwnerHeartbeat({
|
|
573
|
+
getInstanceId: () => INSTANCE_ID,
|
|
574
|
+
readActiveInstance,
|
|
575
|
+
refreshActiveInstance,
|
|
576
|
+
});
|
|
641
577
|
async function bindPersistedTranscriptIfAny() {
|
|
642
578
|
// Main-channel fallback requires channelBridgeActive (set in start() before
|
|
643
579
|
// refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
|
|
@@ -787,7 +723,20 @@ async function startOwnedRuntime(options = {}) {
|
|
|
787
723
|
// Advertise active-instance.json BEFORE backend connect so a newer remote
|
|
788
724
|
// session's last-wins claim is visible immediately. backendReady=false
|
|
789
725
|
// marks the partial state until backend.connect() succeeds.
|
|
790
|
-
|
|
726
|
+
// onlyIfOwned: startOwnedRuntime is only entered on the owned path, but a
|
|
727
|
+
// newer session can claim the seat between that check and this write; CAS
|
|
728
|
+
// aborts instead of re-stealing. The write result is checked below so a
|
|
729
|
+
// CAS abort stops the start path immediately (heartbeat/backend/scheduler
|
|
730
|
+
// never start) instead of relying on the 3s ownership timer to notice.
|
|
731
|
+
const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true });
|
|
732
|
+
// A successful CAS write always sets instanceId to ours; any other result
|
|
733
|
+
// (aborted write returning the stale/foreign/missing prior state) means
|
|
734
|
+
// the seat was not ours to claim — abort the start path here rather than
|
|
735
|
+
// relying on the 3s ownership timer to notice later.
|
|
736
|
+
if (casResult?.instanceId !== INSTANCE_ID) {
|
|
737
|
+
bridgeRuntimeStarting = false;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
791
740
|
startOwnerHeartbeat();
|
|
792
741
|
// Re-check after each post-connect await so a stopOwnedRuntime() landing
|
|
793
742
|
// mid-start cannot be overridden by the resuming start (scheduler/snapshot/
|
|
@@ -825,7 +774,11 @@ async function startOwnedRuntime(options = {}) {
|
|
|
825
774
|
return;
|
|
826
775
|
}
|
|
827
776
|
bridgeRuntimeConnected = true;
|
|
828
|
-
|
|
777
|
+
// onlyIfOwned: backend.connect() above can take seconds — a newer owner
|
|
778
|
+
// claiming the seat during that await must not be overwritten here. On
|
|
779
|
+
// CAS abort the next refreshBridgeOwnership tick observes owned=false
|
|
780
|
+
// and stops this runtime; backendReady stays unset for the lost seat.
|
|
781
|
+
refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
|
|
829
782
|
// initProviders must complete before scheduler.start() — otherwise the
|
|
830
783
|
// scheduler's first fire can land before the registry is populated and
|
|
831
784
|
// return `Provider "<name>" not found or not enabled`. The previous
|
|
@@ -916,26 +869,14 @@ async function stopOwnedRuntime(reason) {
|
|
|
916
869
|
function refreshBridgeOwnershipSafe(options = {}) {
|
|
917
870
|
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
918
871
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
// refreshBridgeOwnership() will observe owned=false and disconnect us.
|
|
928
|
-
if (currentOwnerState().owned) refreshActiveInstance(INSTANCE_ID);
|
|
929
|
-
} catch (e) {
|
|
930
|
-
process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
931
|
-
}
|
|
932
|
-
}, OWNER_HEARTBEAT_INTERVAL_MS);
|
|
933
|
-
ownerHeartbeatTimer.unref?.();
|
|
934
|
-
}
|
|
935
|
-
function stopOwnerHeartbeat() {
|
|
936
|
-
if (!ownerHeartbeatTimer) return;
|
|
937
|
-
clearInterval(ownerHeartbeatTimer);
|
|
938
|
-
ownerHeartbeatTimer = null;
|
|
872
|
+
// Tell the parent session that this worker LOST the bridge seat to a newer
|
|
873
|
+
// remote session (last-wins). The parent flips its remote mode OFF entirely —
|
|
874
|
+
// exactly one session holds remote; losers fully release, no handover.
|
|
875
|
+
function notifyRemoteSuperseded() {
|
|
876
|
+
if (!process.send) return;
|
|
877
|
+
try {
|
|
878
|
+
process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
|
|
879
|
+
} catch {}
|
|
939
880
|
}
|
|
940
881
|
async function refreshBridgeOwnership(options = {}) {
|
|
941
882
|
// Coalesce concurrent callers onto the in-flight refresh so backend tool
|
|
@@ -955,14 +896,18 @@ async function refreshBridgeOwnership(options = {}) {
|
|
|
955
896
|
}
|
|
956
897
|
const { active, owned } = currentOwnerState();
|
|
957
898
|
if (owned) {
|
|
958
|
-
|
|
899
|
+
// onlyIfOwned: ownership was checked outside the file lock; CAS mode
|
|
900
|
+
// prevents this periodic tick from overwriting a newer owner that
|
|
901
|
+
// claimed the seat in between (re-steal / ping-pong guard).
|
|
902
|
+
refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true });
|
|
959
903
|
await startOwnedRuntime(options);
|
|
960
904
|
return;
|
|
961
905
|
}
|
|
962
906
|
// Not the owner. Two sub-cases:
|
|
963
907
|
// (a) A live remote session holds the seat (active-instance names a
|
|
964
908
|
// different, non-stale instance) → last-wins: we lost, go quiet
|
|
965
|
-
// (disconnect if we were connected)
|
|
909
|
+
// (disconnect if we were connected) and tell the parent session to
|
|
910
|
+
// drop remote mode entirely (single-holder, no handover).
|
|
966
911
|
// (b) There is NO live owner (active is null/stale — e.g. our own entry
|
|
967
912
|
// was cleared after a backend-connect failure or a bridge
|
|
968
913
|
// deactivate/reactivate) → this remote session claims the empty seat
|
|
@@ -971,6 +916,7 @@ async function refreshBridgeOwnership(options = {}) {
|
|
|
971
916
|
if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
|
|
972
917
|
if (bridgeRuntimeConnected) {
|
|
973
918
|
await stopOwnedRuntime("ownership lost (newer remote session)");
|
|
919
|
+
notifyRemoteSuperseded();
|
|
974
920
|
}
|
|
975
921
|
return;
|
|
976
922
|
}
|
|
@@ -1200,8 +1146,10 @@ function wireEventQueueHandlers(eventQueue) {
|
|
|
1200
1146
|
// Defensive ownership probe: the queue tick should only run in the active
|
|
1201
1147
|
// owner process. Non-owner instances see bridgeRuntimeConnected=false and
|
|
1202
1148
|
// will skip the tick even if an errant start() slipped through.
|
|
1203
|
-
|
|
1204
|
-
|
|
1149
|
+
// bridgeRuntimeConnected is a fast-path AND; currentOwnerState().owned is
|
|
1150
|
+
// re-read at probe time so a stale-connected flag cannot mask a lost seat.
|
|
1151
|
+
eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
1152
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
|
|
1205
1153
|
}
|
|
1206
1154
|
function editDiscordMessage(channelId, messageId, label) {
|
|
1207
1155
|
// Behavior-preserving: route through the backend abstraction (which uses
|
|
@@ -1430,496 +1378,75 @@ backend.onInteraction = (interaction) => {
|
|
|
1430
1378
|
}
|
|
1431
1379
|
});
|
|
1432
1380
|
};
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
}
|
|
1438
|
-
function runCmd(cmd, args, capture = false) {
|
|
1439
|
-
return new Promise((resolve, reject) => {
|
|
1440
|
-
const proc = spawn(cmd, args, {
|
|
1441
|
-
stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore",
|
|
1442
|
-
windowsHide: true
|
|
1443
|
-
});
|
|
1444
|
-
let out = "";
|
|
1445
|
-
if (capture && proc.stdout) proc.stdout.on("data", (d) => {
|
|
1446
|
-
out += d;
|
|
1447
|
-
});
|
|
1448
|
-
proc.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} exit ${code}`)));
|
|
1449
|
-
proc.on("error", reject);
|
|
1450
|
-
});
|
|
1451
|
-
}
|
|
1452
|
-
// ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
|
|
1453
|
-
const _voiceTranscriptionQueue = (() => {
|
|
1454
|
-
let running = 0;
|
|
1455
|
-
const pending = [];
|
|
1456
|
-
function drain() {
|
|
1457
|
-
const limit = config.voice?.transcription?.maxConcurrency ?? 1;
|
|
1458
|
-
while (running < limit && pending.length > 0) {
|
|
1459
|
-
const { fn, resolve, reject } = pending.shift();
|
|
1460
|
-
running++;
|
|
1461
|
-
fn().then(resolve, reject).finally(() => { running--; drain(); });
|
|
1462
|
-
}
|
|
1463
|
-
}
|
|
1464
|
-
return function enqueue(fn) {
|
|
1465
|
-
return new Promise((resolve, reject) => { pending.push({ fn, resolve, reject }); drain(); });
|
|
1466
|
-
};
|
|
1467
|
-
})();
|
|
1468
|
-
|
|
1469
|
-
// ── wav + transcript cache keyed by attachment id ──
|
|
1470
|
-
const _voiceWavCache = new Map(); // attachmentId → wavPath
|
|
1471
|
-
const _voiceTranscriptCache = new Map(); // attachmentId → transcript string
|
|
1472
|
-
const _voiceInflight = new Map(); // attachmentId → Promise<string|null>
|
|
1473
|
-
const _voiceFfmpegInflight = new Map(); // attachmentId|wavPath → Promise<void> single-flight ffmpeg
|
|
1474
|
-
|
|
1475
|
-
async function _probeAudioDurationSec(filePath) {
|
|
1476
|
-
try {
|
|
1477
|
-
const ffprobePath = (() => { try { return _require('ffprobe-static').path; } catch { return 'ffprobe'; } })();
|
|
1478
|
-
return await new Promise((resolve, reject) => {
|
|
1479
|
-
const args = ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath];
|
|
1480
|
-
let out = '';
|
|
1481
|
-
const proc = spawn(ffprobePath, args, { windowsHide: true });
|
|
1482
|
-
proc.stdout.on('data', (d) => { out += d; });
|
|
1483
|
-
proc.on('close', (code) => { code === 0 ? resolve(parseFloat(out.trim()) || null) : reject(new Error(`ffprobe exit ${code}`)); });
|
|
1484
|
-
proc.on('error', reject);
|
|
1485
|
-
});
|
|
1486
|
-
} catch {
|
|
1487
|
-
return null;
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
async function transcribeVoice(audioPath, { attachmentId } = {}) {
|
|
1492
|
-
// ── size gate (config: voice.transcription.maxFileSizeMB) ──
|
|
1493
|
-
const maxSizeBytes = (config.voice?.transcription?.maxFileSizeMB ?? 0) * 1024 * 1024;
|
|
1494
|
-
if (maxSizeBytes > 0) {
|
|
1495
|
-
try {
|
|
1496
|
-
const stat = await fs.promises.stat(audioPath);
|
|
1497
|
-
if (stat.size > maxSizeBytes) {
|
|
1498
|
-
process.stderr.write(`mixdog: voice.transcription skipped — file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB > ${config.voice.transcription.maxFileSizeMB} MB): ${audioPath}\n`);
|
|
1499
|
-
return null;
|
|
1500
|
-
}
|
|
1501
|
-
} catch { /* stat failure: proceed */ }
|
|
1502
|
-
}
|
|
1503
|
-
// ── duration gate (config: voice.transcription.maxDurationSec) ──
|
|
1504
|
-
const maxDurationSec = config.voice?.transcription?.maxDurationSec ?? 0;
|
|
1505
|
-
if (maxDurationSec > 0) {
|
|
1506
|
-
const dur = await _probeAudioDurationSec(audioPath);
|
|
1507
|
-
if (dur !== null && dur > maxDurationSec) {
|
|
1508
|
-
process.stderr.write(`mixdog: voice.transcription skipped — audio too long (${Math.floor(dur)}s > ${maxDurationSec}s): ${audioPath}\n`);
|
|
1509
|
-
return null;
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
// ── transcript cache hit ──
|
|
1513
|
-
if (attachmentId && _voiceTranscriptCache.has(attachmentId)) {
|
|
1514
|
-
process.stderr.write(`mixdog: voice.transcription cache hit (${attachmentId})\n`);
|
|
1515
|
-
return _voiceTranscriptCache.get(attachmentId);
|
|
1516
|
-
}
|
|
1517
|
-
if (attachmentId && _voiceInflight.has(attachmentId)) {
|
|
1518
|
-
return _voiceInflight.get(attachmentId);
|
|
1519
|
-
}
|
|
1520
|
-
const p = _voiceTranscriptionQueue(() => _doTranscribeVoice(audioPath, attachmentId));
|
|
1521
|
-
if (attachmentId) {
|
|
1522
|
-
_voiceInflight.set(attachmentId, p);
|
|
1523
|
-
p.catch((err) => {
|
|
1524
|
-
try { process.stderr.write(`mixdog: voice.transcription inflight rejection: ${err?.stack || err}\n`); } catch {}
|
|
1525
|
-
}).finally(() => _voiceInflight.delete(attachmentId));
|
|
1526
|
-
}
|
|
1527
|
-
return p;
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
async function _doTranscribeVoice(audioPath, attachmentId) {
|
|
1531
|
-
try {
|
|
1532
|
-
const runtime = resolveVoiceRuntime(DATA_DIR);
|
|
1533
|
-
if (!runtime?.installed) {
|
|
1534
|
-
const missing = [runtime?.binary ? null : 'binary', runtime?.model ? null : 'model', runtime?.ffmpeg ? null : 'ffmpeg'].filter(Boolean).join(' + ');
|
|
1535
|
-
throw new Error(`voice runtime not installed (missing: ${missing}) — open the setup wizard and click "Install voice"`);
|
|
1536
|
-
}
|
|
1537
|
-
const whisperCmd = runtime.whisperCmd;
|
|
1538
|
-
const modelPath = runtime.modelPath;
|
|
1539
|
-
const ffmpegPath = runtime.ffmpegPath;
|
|
1540
|
-
const lang = normalizeWhisperLanguage(config.voice?.language) ?? detectDeviceLanguage();
|
|
1541
|
-
const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
|
|
1542
|
-
const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
|
|
1543
|
-
// ── wav cache keyed by attachment id ──
|
|
1544
|
-
let wavPath;
|
|
1545
|
-
if (attachmentId && _voiceWavCache.has(attachmentId)) {
|
|
1546
|
-
wavPath = _voiceWavCache.get(attachmentId);
|
|
1547
|
-
if (!fs.existsSync(wavPath)) {
|
|
1548
|
-
_voiceWavCache.delete(attachmentId);
|
|
1549
|
-
wavPath = undefined;
|
|
1550
|
-
} else {
|
|
1551
|
-
process.stderr.write(`mixdog: voice.transcription wav cache hit (${attachmentId})\n`);
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
if (!wavPath) {
|
|
1555
|
-
wavPath = audioPath.replace(/\.[^.]+$/, ".wav");
|
|
1556
|
-
const sampleRate = config.voice?.transcription?.sampleRate ?? 16000;
|
|
1557
|
-
const channels = config.voice?.transcription?.channels ?? 1;
|
|
1558
|
-
// Single-flight: parallel callers for the same key share one ffmpeg spawn.
|
|
1559
|
-
const _ffmpegKey = attachmentId || wavPath;
|
|
1560
|
-
if (_voiceFfmpegInflight.has(_ffmpegKey)) {
|
|
1561
|
-
await _voiceFfmpegInflight.get(_ffmpegKey);
|
|
1562
|
-
} else {
|
|
1563
|
-
const _ffmpegPromise = runCmd(ffmpegPath, ["-i", audioPath, "-ar", String(sampleRate), "-ac", String(channels), "-threads", String(threadCount), "-y", wavPath]);
|
|
1564
|
-
_voiceFfmpegInflight.set(_ffmpegKey, _ffmpegPromise);
|
|
1565
|
-
try {
|
|
1566
|
-
await _ffmpegPromise;
|
|
1567
|
-
if (attachmentId) _voiceWavCache.set(attachmentId, wavPath);
|
|
1568
|
-
} finally {
|
|
1569
|
-
_voiceFfmpegInflight.delete(_ffmpegKey);
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
}
|
|
1573
|
-
process.stderr.write(`mixdog: voice.transcription start runtime=${runtime.kind} cmd=${path.basename(whisperCmd)}\n`);
|
|
1574
|
-
await ensureReady({ serverCmd: runtime.serverCmd, modelPath, threadCount, host: '127.0.0.1' });
|
|
1575
|
-
const text = await transcribe(wavPath, { language: lang });
|
|
1576
|
-
const result = text.trim() || null;
|
|
1577
|
-
if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
|
|
1578
|
-
return result;
|
|
1579
|
-
} catch (err) {
|
|
1580
|
-
if (err?.message?.startsWith('voice runtime not installed')) throw err; // propagate setup errors; caller posts user-visible failure
|
|
1581
|
-
process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
|
|
1582
|
-
return null;
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1381
|
+
const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
|
|
1382
|
+
getConfig: () => config,
|
|
1383
|
+
dataDir: DATA_DIR,
|
|
1384
|
+
});
|
|
1585
1385
|
import { TOOL_DEFS } from './tool-defs.mjs';
|
|
1586
1386
|
// Tool dispatch in worker mode goes through the IPC `call` handler at the
|
|
1587
1387
|
// bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
|
|
1588
1388
|
// orphan worker-level MCP Server: the parent (server.mjs) owns the single
|
|
1589
1389
|
// connected transport and routes CallTool through the IPC `call` path.
|
|
1590
|
-
const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"
|
|
1390
|
+
const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
|
|
1391
|
+
// ── Inbound routing / dedup / ownership helpers ─────────────────────────────
|
|
1392
|
+
// Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
|
|
1393
|
+
// Created here (ahead of backend-dispatch) so labelForChannelId is initialised
|
|
1394
|
+
// before it is passed into createBackendDispatch below.
|
|
1395
|
+
const {
|
|
1396
|
+
writeChannelOwner,
|
|
1397
|
+
shouldDropDuplicateInbound,
|
|
1398
|
+
resolveInboundRoute,
|
|
1399
|
+
labelForChannelId,
|
|
1400
|
+
} = createInboundRouting({
|
|
1401
|
+
getConfig: () => config,
|
|
1402
|
+
getInstanceId: () => INSTANCE_ID,
|
|
1403
|
+
getChannelOwnerPath,
|
|
1404
|
+
});
|
|
1591
1405
|
// ── Backend-tool dispatch helpers ───────────────────────────────────────────
|
|
1592
1406
|
// Each helper dispatches through the local backend (this process is always the
|
|
1593
|
-
// owner in opt-in remote mode).
|
|
1594
|
-
//
|
|
1595
|
-
//
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
files: args.files ?? [],
|
|
1637
|
-
embeds: args.embeds ?? [],
|
|
1638
|
-
components: args.components ?? []
|
|
1639
|
-
};
|
|
1640
|
-
let ids;
|
|
1641
|
-
// Pre-send activity bump keeps idle gating consistent during the await.
|
|
1642
|
-
scheduler.noteActivity();
|
|
1643
|
-
const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
|
|
1644
|
-
scheduler.noteActivity();
|
|
1645
|
-
ids = sendResult.sentIds;
|
|
1646
|
-
const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
|
|
1647
|
-
return { content: [{ type: "text", text }] };
|
|
1648
|
-
}
|
|
1649
|
-
async function dispatchFetch(args) {
|
|
1650
|
-
const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
|
|
1651
|
-
const limit = args.limit ?? 20;
|
|
1652
|
-
let msgs;
|
|
1653
|
-
msgs = await backend.fetchMessages(channelId, limit);
|
|
1654
|
-
recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
|
|
1655
|
-
const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
|
|
1656
|
-
const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
|
|
1657
|
-
return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
|
|
1658
|
-
}).join("\n");
|
|
1659
|
-
return { content: [{ type: "text", text }] };
|
|
1660
|
-
}
|
|
1661
|
-
async function dispatchReact(args) {
|
|
1662
|
-
await backend.react(args.chat_id, args.message_id, args.emoji);
|
|
1663
|
-
return { content: [{ type: "text", text: "reacted" }] };
|
|
1664
|
-
}
|
|
1665
|
-
async function dispatchEditMessage(args) {
|
|
1666
|
-
const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
|
|
1667
|
-
let id;
|
|
1668
|
-
id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
|
|
1669
|
-
return { content: [{ type: "text", text: `edited (id: ${id})` }] };
|
|
1670
|
-
}
|
|
1671
|
-
async function dispatchDownloadAttachment(args) {
|
|
1672
|
-
let files;
|
|
1673
|
-
files = await backend.downloadAttachment(args.chat_id, args.message_id);
|
|
1674
|
-
if (files.length === 0) {
|
|
1675
|
-
return { content: [{ type: "text", text: "message has no attachments" }] };
|
|
1676
|
-
}
|
|
1677
|
-
const lines = files.map(
|
|
1678
|
-
(f) => ` ${f.path} (${f.name}, ${f.contentType}, ${(f.size / 1024).toFixed(0)}KB)`
|
|
1679
|
-
);
|
|
1680
|
-
// Each downloaded file lands on the local FS; if any of them
|
|
1681
|
-
// had a stale prefetch entry from a prior session, drop it so
|
|
1682
|
-
// the next prefetch sees the fresh contents.
|
|
1683
|
-
for (const f of files) {
|
|
1684
|
-
if (f && typeof f.path === "string" && f.path) {
|
|
1685
|
-
invalidatePrefetchCache(f.path);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
|
-
return { content: [{ type: "text", text: `downloaded ${files.length} attachment(s):
|
|
1689
|
-
${lines.join("\n")}` }] };
|
|
1690
|
-
}
|
|
1691
|
-
async function handleToolCall(name, args, _signal) {
|
|
1692
|
-
if (isChannelsDegraded()) {
|
|
1693
|
-
return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
|
|
1694
|
-
}
|
|
1695
|
-
let result;
|
|
1696
|
-
try {
|
|
1697
|
-
switch (name) {
|
|
1698
|
-
case "reply":
|
|
1699
|
-
result = await dispatchReply(args);
|
|
1700
|
-
break;
|
|
1701
|
-
case "fetch":
|
|
1702
|
-
result = await dispatchFetch(args);
|
|
1703
|
-
break;
|
|
1704
|
-
case "react":
|
|
1705
|
-
result = await dispatchReact(args);
|
|
1706
|
-
break;
|
|
1707
|
-
case "edit_message":
|
|
1708
|
-
result = await dispatchEditMessage(args);
|
|
1709
|
-
break;
|
|
1710
|
-
case "download_attachment":
|
|
1711
|
-
result = await dispatchDownloadAttachment(args);
|
|
1712
|
-
break;
|
|
1713
|
-
case "schedule_status": {
|
|
1714
|
-
result = scheduleStatusResult();
|
|
1715
|
-
break;
|
|
1716
|
-
}
|
|
1717
|
-
case "trigger_schedule": {
|
|
1718
|
-
const triggerResult = await scheduler.triggerManual(args.name);
|
|
1719
|
-
result = { content: [{ type: "text", text: triggerResult }] };
|
|
1720
|
-
break;
|
|
1721
|
-
}
|
|
1722
|
-
case "schedule_control": {
|
|
1723
|
-
result = scheduleControlResult(args);
|
|
1724
|
-
break;
|
|
1725
|
-
}
|
|
1726
|
-
case "activate_channel_bridge": {
|
|
1727
|
-
const active = args.active === true;
|
|
1728
|
-
const wasActive = channelBridgeActive;
|
|
1729
|
-
channelBridgeActive = active;
|
|
1730
|
-
writeBridgeState(active);
|
|
1731
|
-
if (active && !wasActive) {
|
|
1732
|
-
refreshBridgeOwnershipSafe({ restoreBinding: true });
|
|
1733
|
-
}
|
|
1734
|
-
if (!active && wasActive) {
|
|
1735
|
-
stopServerTyping();
|
|
1736
|
-
// Tear down the owner-side runtime so Discord/scheduler/webhook/
|
|
1737
|
-
// event-pipeline don't keep running on a deactivated bridge.
|
|
1738
|
-
try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
|
|
1739
|
-
process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
|
|
1743
|
-
break;
|
|
1744
|
-
}
|
|
1745
|
-
case "reload_config": {
|
|
1746
|
-
await reloadRuntimeConfig();
|
|
1747
|
-
// Extend reload to the agent module so providers/presets/maintenance
|
|
1748
|
-
// hot-reload on the same call (dynamic import: agent/index.mjs does not
|
|
1749
|
-
// import channels, so this stays acyclic and tolerant of load order).
|
|
1750
|
-
let agentReloadMsg = "";
|
|
1751
|
-
if (process.env.MIXDOG_STANDALONE !== '1') {
|
|
1752
|
-
try {
|
|
1753
|
-
const { reloadAgentConfig } = await import("../agent/index.mjs");
|
|
1754
|
-
await reloadAgentConfig("reload_config tool");
|
|
1755
|
-
agentReloadMsg = ", agent providers/presets/maintenance";
|
|
1756
|
-
} catch (err) {
|
|
1757
|
-
process.stderr.write(`[reload_config] agent reload failed: ${err?.message || String(err)}\n`);
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
|
|
1761
|
-
break;
|
|
1762
|
-
}
|
|
1763
|
-
case "inject_command": {
|
|
1764
|
-
const cmd = String(args?.command || "").trim();
|
|
1765
|
-
const ALLOW = new Set(["clear"]);
|
|
1766
|
-
if (!ALLOW.has(cmd)) {
|
|
1767
|
-
result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
|
|
1768
|
-
break;
|
|
1769
|
-
}
|
|
1770
|
-
// Unified managed-launcher control path (cross-platform). The
|
|
1771
|
-
// command is delivered to the `mixdog`-launched child's stdin by the
|
|
1772
|
-
// launcher that owns it — no OS/terminal keystroke injection, no new
|
|
1773
|
-
// window. Only sessions with an engaged native managed-launch bridge
|
|
1774
|
-
// are addressable; anything else gets a clear not-managed error
|
|
1775
|
-
// rather than a silent no-op.
|
|
1776
|
-
try {
|
|
1777
|
-
const launchId = managedLaunchId();
|
|
1778
|
-
if (!launchId) {
|
|
1779
|
-
result = { content: [{ type: "text", text: "inject_command: this session is not a managed `mixdog` launch (MIXDOG_LAUNCH_ID unset). Managed input delivery requires the native mixdog-launch PTY/ConPTY bridge." }], isError: true };
|
|
1780
|
-
break;
|
|
1781
|
-
}
|
|
1782
|
-
enqueueLauncherCommand(launchId, `/${cmd}`);
|
|
1783
|
-
result = { content: [{ type: "text", text: `queued /${cmd} for managed launcher (launchId=${launchId})` }] };
|
|
1784
|
-
} catch (err) {
|
|
1785
|
-
result = { content: [{ type: "text", text: `inject_command error: ${err?.message || err}` }], isError: true };
|
|
1786
|
-
}
|
|
1787
|
-
break;
|
|
1788
|
-
}
|
|
1789
|
-
// memory — handled by memory-service.mjs MCP
|
|
1790
|
-
default:
|
|
1791
|
-
result = {
|
|
1792
|
-
content: [{ type: "text", text: `unknown tool: ${name}` }],
|
|
1793
|
-
isError: true
|
|
1794
|
-
};
|
|
1795
|
-
}
|
|
1796
|
-
} catch (err) {
|
|
1797
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1798
|
-
result = {
|
|
1799
|
-
content: [{ type: "text", text: `${name} failed: ${msg}` }],
|
|
1800
|
-
isError: true
|
|
1801
|
-
};
|
|
1802
|
-
}
|
|
1803
|
-
return result;
|
|
1804
|
-
}
|
|
1805
|
-
// Bridge auto-connect retry + forwarder-aware tool dispatch wrapper. Used by
|
|
1806
|
-
// both the HTTP MCP path (createHttpMcpServer's CallTool handler can call this)
|
|
1807
|
-
// and the worker IPC handler at the bottom of this file. The pre-v0.6.7 code
|
|
1808
|
-
// registered this on the orphan worker-level `Server`, which never had a
|
|
1809
|
-
// transport, so the wrapper never actually fired. Centralised here for reuse.
|
|
1810
|
-
// Last timestamp a forwardNewText() call was dispatched (debounce for item 4).
|
|
1811
|
-
let _lastForwardMs = 0;
|
|
1812
|
-
|
|
1813
|
-
async function handleToolCallWithBridgeRetry(toolName, args, signal) {
|
|
1814
|
-
// Debounce: only forward when ≥250 ms have elapsed since the last forward,
|
|
1815
|
-
// to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
|
|
1816
|
-
const now = Date.now();
|
|
1817
|
-
if (now - _lastForwardMs >= 250) {
|
|
1818
|
-
_lastForwardMs = now;
|
|
1819
|
-
await forwarder.forwardNewText();
|
|
1820
|
-
}
|
|
1821
|
-
if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected) {
|
|
1822
|
-
// Remote-owner startup: ensure this owner's backend is connected.
|
|
1823
|
-
for (let i = 0; i < 2 && !bridgeRuntimeConnected; i++) {
|
|
1824
|
-
try {
|
|
1825
|
-
await refreshBridgeOwnership();
|
|
1826
|
-
} catch {
|
|
1827
|
-
}
|
|
1828
|
-
if (!bridgeRuntimeConnected) await new Promise((r) => setTimeout(r, 300));
|
|
1829
|
-
}
|
|
1830
|
-
if (!bridgeRuntimeConnected) {
|
|
1831
|
-
return {
|
|
1832
|
-
content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
|
|
1833
|
-
isError: true
|
|
1834
|
-
};
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
const result = await handleToolCall(toolName, args, signal);
|
|
1838
|
-
const toolLine = OutputForwarder.buildToolLine(toolName, args);
|
|
1839
|
-
if (toolLine) {
|
|
1840
|
-
// Distinct from the dispatch-log ok line (server-main.mjs): this forwards
|
|
1841
|
-
// a human-readable tool summary to Discord for the user, not operator stdout.
|
|
1842
|
-
void forwarder.forwardToolLog(toolLine, toolName, args);
|
|
1843
|
-
}
|
|
1844
|
-
return result;
|
|
1845
|
-
}
|
|
1846
|
-
const INBOUND_DEDUP_TTL = 5 * 6e4;
|
|
1847
|
-
const inboundSeen = /* @__PURE__ */ new Map();
|
|
1848
|
-
const INBOUND_DEDUP_DIR = path.join(os.tmpdir(), "mixdog-inbound");
|
|
1849
|
-
ensureDir(INBOUND_DEDUP_DIR);
|
|
1850
|
-
function writeChannelOwner(channelId) {
|
|
1851
|
-
const ownerPath = getChannelOwnerPath(channelId);
|
|
1852
|
-
try {
|
|
1853
|
-
fs.writeFileSync(ownerPath, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, updatedAt: Date.now() }));
|
|
1854
|
-
return true;
|
|
1855
|
-
} catch {
|
|
1856
|
-
return false;
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
function shouldDropDuplicateInbound(msg) {
|
|
1860
|
-
const key = `${msg.chatId}:${msg.messageId}`;
|
|
1861
|
-
const now = Date.now();
|
|
1862
|
-
if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
|
|
1863
|
-
inboundSeen.set(key, now);
|
|
1864
|
-
const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
|
|
1865
|
-
try {
|
|
1866
|
-
fs.writeFileSync(marker, String(now), { flag: "wx" });
|
|
1867
|
-
} catch (e) {
|
|
1868
|
-
if (e.code === "EEXIST") {
|
|
1869
|
-
try {
|
|
1870
|
-
const stat = fs.statSync(marker);
|
|
1871
|
-
if (now - stat.mtimeMs < INBOUND_DEDUP_TTL) return true;
|
|
1872
|
-
} catch {}
|
|
1873
|
-
}
|
|
1874
|
-
}
|
|
1875
|
-
if (Math.random() < 0.1) {
|
|
1876
|
-
try {
|
|
1877
|
-
for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
|
|
1878
|
-
const fp = path.join(INBOUND_DEDUP_DIR, f);
|
|
1879
|
-
try {
|
|
1880
|
-
if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
|
|
1881
|
-
} catch {
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
} catch {
|
|
1885
|
-
}
|
|
1886
|
-
}
|
|
1887
|
-
for (const [k, t] of inboundSeen) {
|
|
1888
|
-
if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
|
|
1889
|
-
}
|
|
1890
|
-
return false;
|
|
1891
|
-
}
|
|
1892
|
-
function resolveInboundRoute(chatId, parentChatId) {
|
|
1893
|
-
const main = config.channelsConfig?.main;
|
|
1894
|
-
const findEntry = (id) => {
|
|
1895
|
-
if (!id || !config.channelsConfig) return null;
|
|
1896
|
-
if (typeof main === "object" && main !== null && main.channelId === id) {
|
|
1897
|
-
return { label: "main", entry: main };
|
|
1898
|
-
}
|
|
1899
|
-
for (const [label, entry] of Object.entries(config.channelsConfig)) {
|
|
1900
|
-
if (typeof entry === "object" && entry !== null && entry.channelId === id) {
|
|
1901
|
-
return { label, entry };
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
return null;
|
|
1905
|
-
};
|
|
1906
|
-
// Prefer a direct channelsConfig match on the thread/channel id; fall back
|
|
1907
|
-
// to the parent channel id so thread messages inherit the parent's label
|
|
1908
|
-
// and mode (e.g. monitor) instead of being routed as untagged interactive.
|
|
1909
|
-
const direct = findEntry(chatId);
|
|
1910
|
-
if (direct) {
|
|
1911
|
-
const mode = direct.entry.mode === "monitor" ? "monitor" : (direct.entry.mode || "interactive");
|
|
1912
|
-
return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: direct.label, sourceMode: mode };
|
|
1913
|
-
}
|
|
1914
|
-
if (parentChatId) {
|
|
1915
|
-
const viaParent = findEntry(parentChatId);
|
|
1916
|
-
if (viaParent) {
|
|
1917
|
-
const mode = viaParent.entry.mode === "monitor" ? "monitor" : (viaParent.entry.mode || "interactive");
|
|
1918
|
-
return { targetChatId: chatId, sourceChatId: parentChatId, sourceLabel: viaParent.label, sourceMode: mode };
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
|
-
return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: undefined, sourceMode: "interactive" };
|
|
1922
|
-
}
|
|
1407
|
+
// owner in opt-in remote mode). Extracted → lib/backend-dispatch.mjs. Bound to
|
|
1408
|
+
// live config/backend getters so runtime reloads keep the original file-level
|
|
1409
|
+
// reference semantics.
|
|
1410
|
+
const {
|
|
1411
|
+
dispatchReply,
|
|
1412
|
+
dispatchFetch,
|
|
1413
|
+
} = createBackendDispatch({
|
|
1414
|
+
getConfig: () => config,
|
|
1415
|
+
getBackend: () => backend,
|
|
1416
|
+
scheduler,
|
|
1417
|
+
resolveChannelLabel,
|
|
1418
|
+
labelForChannelId,
|
|
1419
|
+
});
|
|
1420
|
+
// ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
|
|
1421
|
+
// handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
|
|
1422
|
+
// lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
|
|
1423
|
+
// functions plus mutable owner state (channelBridgeActive/bridgeRuntimeConnected)
|
|
1424
|
+
// and the forwarder; those are threaded as a lifecycle bag of lazy getters so
|
|
1425
|
+
// the module reads live file-level references at call time (original closure
|
|
1426
|
+
// semantics preserved). Used by the HTTP MCP CallTool path and the worker IPC
|
|
1427
|
+
// `call` handler at the bottom of this file.
|
|
1428
|
+
const {
|
|
1429
|
+
handleToolCall,
|
|
1430
|
+
handleToolCallWithBridgeRetry,
|
|
1431
|
+
} = createToolDispatch({
|
|
1432
|
+
getForwarder: () => forwarder,
|
|
1433
|
+
BACKEND_TOOLS,
|
|
1434
|
+
isChannelsDegraded,
|
|
1435
|
+
dispatchReply,
|
|
1436
|
+
dispatchFetch,
|
|
1437
|
+
lifecycle: {
|
|
1438
|
+
getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
|
|
1439
|
+
getChannelBridgeActive: () => channelBridgeActive,
|
|
1440
|
+
setChannelBridgeActive: (v) => { channelBridgeActive = v; },
|
|
1441
|
+
writeBridgeState,
|
|
1442
|
+
stopServerTyping,
|
|
1443
|
+
claimBridgeOwnership,
|
|
1444
|
+
refreshBridgeOwnership,
|
|
1445
|
+
bindPersistedTranscriptIfAny,
|
|
1446
|
+
stopOwnedRuntime,
|
|
1447
|
+
reloadRuntimeConfig,
|
|
1448
|
+
},
|
|
1449
|
+
});
|
|
1923
1450
|
const inboundQueue = (() => {
|
|
1924
1451
|
let tail = Promise.resolve();
|
|
1925
1452
|
let _iqDepth = 0;
|
|
@@ -1935,14 +1462,6 @@ const inboundQueue = (() => {
|
|
|
1935
1462
|
}).finally(() => { _iqDepth--; });
|
|
1936
1463
|
};
|
|
1937
1464
|
})();
|
|
1938
|
-
// ── Reverse-lookup channelId → human label from channelsConfig ──────────────
|
|
1939
|
-
function labelForChannelId(channelId) {
|
|
1940
|
-
if (!channelId || !config.channelsConfig) return channelId;
|
|
1941
|
-
for (const [label, entry] of Object.entries(config.channelsConfig)) {
|
|
1942
|
-
if (entry?.channelId === channelId) return label;
|
|
1943
|
-
}
|
|
1944
|
-
return channelId;
|
|
1945
|
-
}
|
|
1946
1465
|
|
|
1947
1466
|
backend.onMessage = (msg) => {
|
|
1948
1467
|
const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
|
|
@@ -1970,6 +1489,7 @@ backend.onMessage = (msg) => {
|
|
|
1970
1489
|
let boundTranscript = null;
|
|
1971
1490
|
let stoleSelfTranscript = false;
|
|
1972
1491
|
let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
|
|
1492
|
+
let needsStealPoll = false;
|
|
1973
1493
|
// Reuse the current binding only while it still points at THIS owner's own
|
|
1974
1494
|
// session. discoverSessionBoundTranscript() now ranks the live parent-chain
|
|
1975
1495
|
// session (the one that forked this worker and receives injected input)
|
|
@@ -2003,6 +1523,12 @@ backend.onMessage = (msg) => {
|
|
|
2003
1523
|
transcriptPath,
|
|
2004
1524
|
exists: true
|
|
2005
1525
|
};
|
|
1526
|
+
// Fast path skips the poll below (zero added latency) unless we lack a
|
|
1527
|
+
// confident, currently-active self-bound candidate — that's the
|
|
1528
|
+
// ~ms race window right after activate, before the parent-chain
|
|
1529
|
+
// session record is published, where the steal gate above fails on
|
|
1530
|
+
// the very first inbound even though a real self session exists.
|
|
1531
|
+
if (!selfBound || selfBound.active !== true) needsStealPoll = true;
|
|
2006
1532
|
}
|
|
2007
1533
|
} else {
|
|
2008
1534
|
boundTranscript = discoverSessionBoundTranscript();
|
|
@@ -2020,16 +1546,98 @@ backend.onMessage = (msg) => {
|
|
|
2020
1546
|
}
|
|
2021
1547
|
}
|
|
2022
1548
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
1549
|
+
// Binding-settled signal: resolves once the queued binding task below
|
|
1550
|
+
// (poll-if-needed + apply-bind + rebind) has run, so the react/status
|
|
1551
|
+
// IIFE can read the FINAL transcriptPath/boundTranscript instead of the
|
|
1552
|
+
// pre-poll snapshot. onMessage itself stays synchronous — nothing here
|
|
1553
|
+
// blocks message ordering or delays the enqueue calls.
|
|
1554
|
+
let bindingDoneResolve;
|
|
1555
|
+
const bindingDone = new Promise((resolve) => { bindingDoneResolve = resolve; });
|
|
1556
|
+
const queuedAtMs = Date.now();
|
|
1557
|
+
const preQueueMs = queuedAtMs - onMessageAtMs;
|
|
1558
|
+
const gatewayToQueueMs = queuedAtMs - receivedAtMs;
|
|
1559
|
+
if (preQueueMs > 250 || gatewayToQueueMs > 500) {
|
|
1560
|
+
process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
|
|
2027
1561
|
}
|
|
1562
|
+
// ONE queued task per message: binding (poll-if-needed + bind + rebind)
|
|
1563
|
+
// first, then handleInbound. Keeping both phases in a single task preserves
|
|
1564
|
+
// the queue's per-message depth accounting — the overflow guard drops a
|
|
1565
|
+
// whole message, never just its handleInbound half — and guarantees
|
|
1566
|
+
// bindingDone/stopServerTyping always settle even when the binding phase
|
|
1567
|
+
// throws. FIFO is preserved: inboundQueue chains tasks in call order, so
|
|
1568
|
+
// this message's poll delay (if any) defers only its own delivery.
|
|
1569
|
+
inboundQueue(async () => {
|
|
1570
|
+
try {
|
|
1571
|
+
if (needsStealPoll) {
|
|
1572
|
+
const POLL_INTERVAL_MS = 50;
|
|
1573
|
+
const POLL_TIMEOUT_MS = 500;
|
|
1574
|
+
const pollStart = Date.now();
|
|
1575
|
+
while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
|
|
1576
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
1577
|
+
// fresh: bypass the negative parent-pid cache inside the walk —
|
|
1578
|
+
// a transient parent-lookup miss cached just before this poll
|
|
1579
|
+
// would otherwise pin every retry to null until its TTL expires,
|
|
1580
|
+
// defeating the whole first-inbound recovery window.
|
|
1581
|
+
const retryBound = discoverSessionBoundTranscript({ fresh: true });
|
|
1582
|
+
const retryShouldSteal = Boolean(
|
|
1583
|
+
retryBound?.transcriptPath &&
|
|
1584
|
+
!sameResolvedPath(retryBound.transcriptPath, transcriptPath) &&
|
|
1585
|
+
retryBound.active === true &&
|
|
1586
|
+
(retryBound.parentChain === true || retryBound.cwdMatches === true)
|
|
1587
|
+
);
|
|
1588
|
+
if (retryShouldSteal) {
|
|
1589
|
+
process.stderr.write(`mixdog: inbound rebind (poll +${Date.now() - pollStart}ms): stealing transcript ${transcriptPath} -> ${retryBound.transcriptPath} (source=${retryBound.source || "unknown"}, exists=${retryBound.exists})\n`);
|
|
1590
|
+
transcriptPath = retryBound.transcriptPath;
|
|
1591
|
+
boundTranscript = retryBound;
|
|
1592
|
+
stoleSelfTranscript = true;
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
if (transcriptPath) {
|
|
1598
|
+
applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
|
|
1599
|
+
} else {
|
|
1600
|
+
refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId }, { onlyIfOwned: true });
|
|
1601
|
+
}
|
|
1602
|
+
if (!boundTranscript?.exists) {
|
|
1603
|
+
await rebindTranscriptContext(route.targetChatId, {
|
|
1604
|
+
// For a stolen self transcript (not yet on disk) the sync bind above
|
|
1605
|
+
// persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
|
|
1606
|
+
// setContext resume from offset 0 once the file appears — forwarding
|
|
1607
|
+
// the first assistant reply. Relying on replayFromStart instead would
|
|
1608
|
+
// race: the discovery loop only sets replayFromStart when it first saw
|
|
1609
|
+
// the transcript as PENDING, so a file that already exists on the first
|
|
1610
|
+
// loop iteration would bind at EOF and skip the reply. Non-steal keeps
|
|
1611
|
+
// the original catch-up-from-cursor behaviour.
|
|
1612
|
+
previousPath: transcriptPath,
|
|
1613
|
+
catchUp: true,
|
|
1614
|
+
catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
|
|
1615
|
+
persistStatus: true
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
try { process.stderr.write(`mixdog: inbound binding error: ${err}\n`); } catch {}
|
|
1620
|
+
} finally {
|
|
1621
|
+
bindingDoneResolve();
|
|
1622
|
+
}
|
|
1623
|
+
try {
|
|
1624
|
+
await handleInbound(msg, route, {
|
|
1625
|
+
sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
|
|
1626
|
+
receivedAtMs,
|
|
1627
|
+
queuedAtMs
|
|
1628
|
+
});
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
process.stderr.write(`mixdog: handleInbound error: ${err}\n`);
|
|
1631
|
+
} finally {
|
|
1632
|
+
stopServerTyping();
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
2028
1635
|
void (async () => {
|
|
2029
1636
|
try {
|
|
2030
1637
|
await backend.react(msg.chatId, msg.messageId, "\u{1F914}");
|
|
2031
1638
|
} catch {
|
|
2032
1639
|
}
|
|
1640
|
+
await bindingDone;
|
|
2033
1641
|
statusState.update((state) => {
|
|
2034
1642
|
state.channelId = route.targetChatId;
|
|
2035
1643
|
state.userMessageId = msg.messageId;
|
|
@@ -2040,39 +1648,7 @@ backend.onMessage = (msg) => {
|
|
|
2040
1648
|
else delete state.transcriptPath;
|
|
2041
1649
|
state.sessionCwd = boundTranscript?.sessionCwd ?? null;
|
|
2042
1650
|
});
|
|
2043
|
-
if (!boundTranscript?.exists) {
|
|
2044
|
-
await rebindTranscriptContext(route.targetChatId, {
|
|
2045
|
-
// For a stolen self transcript (not yet on disk) the sync bind above
|
|
2046
|
-
// persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
|
|
2047
|
-
// setContext resume from offset 0 once the file appears — forwarding
|
|
2048
|
-
// the first assistant reply. Relying on replayFromStart instead would
|
|
2049
|
-
// race: the discovery loop only sets replayFromStart when it first saw
|
|
2050
|
-
// the transcript as PENDING, so a file that already exists on the first
|
|
2051
|
-
// loop iteration would bind at EOF and skip the reply. Non-steal keeps
|
|
2052
|
-
// the original catch-up-from-cursor behaviour.
|
|
2053
|
-
previousPath: transcriptPath,
|
|
2054
|
-
catchUp: true,
|
|
2055
|
-
catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
|
|
2056
|
-
persistStatus: true
|
|
2057
|
-
});
|
|
2058
|
-
}
|
|
2059
1651
|
})();
|
|
2060
|
-
const queuedAtMs = Date.now();
|
|
2061
|
-
const preQueueMs = queuedAtMs - onMessageAtMs;
|
|
2062
|
-
const gatewayToQueueMs = queuedAtMs - receivedAtMs;
|
|
2063
|
-
if (preQueueMs > 250 || gatewayToQueueMs > 500) {
|
|
2064
|
-
process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
|
|
2065
|
-
}
|
|
2066
|
-
inboundQueue(() => handleInbound(msg, route, {
|
|
2067
|
-
sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
|
|
2068
|
-
receivedAtMs,
|
|
2069
|
-
queuedAtMs
|
|
2070
|
-
}).catch((err) => {
|
|
2071
|
-
process.stderr.write(`mixdog: handleInbound error: ${err}
|
|
2072
|
-
`);
|
|
2073
|
-
}).finally(() => {
|
|
2074
|
-
stopServerTyping();
|
|
2075
|
-
}));
|
|
2076
1652
|
};
|
|
2077
1653
|
async function handleInbound(msg, route, options = {}) {
|
|
2078
1654
|
const handleStartMs = Date.now();
|
|
@@ -2343,17 +1919,7 @@ if (_isWorkerMode && process.send) {
|
|
|
2343
1919
|
}
|
|
2344
1920
|
return;
|
|
2345
1921
|
}
|
|
2346
|
-
if (msg
|
|
2347
|
-
// Response side of the worker → parent → memory bridge. Routed into
|
|
2348
|
-
// this existing listener (instead of a second process.on('message'))
|
|
2349
|
-
// to keep IPC dispatch in one place.
|
|
2350
|
-
const pending = _memoryCallPending.get(msg.callId);
|
|
2351
|
-
if (!pending) return;
|
|
2352
|
-
_memoryCallPending.delete(msg.callId);
|
|
2353
|
-
if (msg.ok) pending.resolve(msg.result);
|
|
2354
|
-
else pending.reject(new Error(msg.error || 'memory_call failed'));
|
|
2355
|
-
return;
|
|
2356
|
-
}
|
|
1922
|
+
if (handleMemoryCallResponse(msg)) return;
|
|
2357
1923
|
if (msg.type === 'cancel' && msg.callId) {
|
|
2358
1924
|
const entry = _inFlightChannelCalls.get(msg.callId)
|
|
2359
1925
|
if (entry) {
|