mixdog 0.7.1
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/.claude-plugin/marketplace.json +31 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.gitattributes +34 -0
- package/.mcp.json +14 -0
- package/ARCHITECTURE.md +77 -0
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +45 -0
- package/DATA-FLOW.md +79 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/SECURITY.md +138 -0
- package/UNINSTALL.md +112 -0
- package/agents/maintenance.md +5 -0
- package/agents/memory-classification.md +30 -0
- package/agents/scheduler-task.md +18 -0
- package/agents/webhook-handler.md +27 -0
- package/agents/worker.md +24 -0
- package/bin/bridge +133 -0
- package/bin/statusline-launcher.mjs +78 -0
- package/bin/statusline-lib.mjs +550 -0
- package/bin/statusline.mjs +607 -0
- package/bun.lock +802 -0
- package/commands/config.md +16 -0
- package/commands/doctor.md +13 -0
- package/commands/setup.md +17 -0
- package/defaults/cycle3-review-prompt.md +90 -0
- package/defaults/hidden-roles.json +65 -0
- package/defaults/memory-chunk-prompt.md +63 -0
- package/defaults/memory-promote-prompt.md +135 -0
- package/defaults/mixdog-config.template.json +27 -0
- package/defaults/user-workflow.json +8 -0
- package/defaults/user-workflow.md +12 -0
- package/hooks/hooks.json +73 -0
- package/hooks/lib/active-instance.cjs +77 -0
- package/hooks/lib/permission-evaluator.cjs +411 -0
- package/hooks/lib/permission-route.cjs +63 -0
- package/hooks/lib/permission-rules.cjs +170 -0
- package/hooks/lib/settings-loader.cjs +116 -0
- package/hooks/post-tool-use.cjs +84 -0
- package/hooks/pre-mcp-sandbox.cjs +158 -0
- package/hooks/pre-tool-subagent.cjs +253 -0
- package/hooks/session-start.cjs +1372 -0
- package/hooks/turn-timer.cjs +82 -0
- package/lib/claude-md-writer.cjs +386 -0
- package/lib/config-cjs.cjs +61 -0
- package/lib/hook-pipe-path.cjs +10 -0
- package/lib/keychain-cjs.cjs +263 -0
- package/lib/plugin-paths.cjs +61 -0
- package/lib/rules-builder.cjs +241 -0
- package/lib/text-utils.cjs +61 -0
- package/native/README.md +117 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +107 -0
- package/prompts/code-review.txt +16 -0
- package/prompts/security-audit.txt +17 -0
- package/rules/bridge/00-common.md +39 -0
- package/rules/bridge/20-skip-protocol.md +18 -0
- package/rules/bridge/30-explorer.md +33 -0
- package/rules/bridge/40-cycle1-agent.md +52 -0
- package/rules/bridge/41-cycle2-agent.md +62 -0
- package/rules/bridge/42-cycle3-agent.md +44 -0
- package/rules/lead/00-tool-lead.md +61 -0
- package/rules/lead/01-general.md +23 -0
- package/rules/lead/02-channels.md +49 -0
- package/rules/lead/03-team.md +27 -0
- package/rules/lead/04-workflow.md +20 -0
- package/rules/shared/00-language.md +14 -0
- package/rules/shared/01-tool.md +138 -0
- package/scripts/bootstrap.mjs +184 -0
- package/scripts/bridge-unify-smoke.mjs +308 -0
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/builtin-utils-smoke.mjs +392 -0
- package/scripts/check-json.mjs +45 -0
- package/scripts/check-syntax-changed.mjs +102 -0
- package/scripts/check-syntax.mjs +58 -0
- package/scripts/code-graph-batch.test.mjs +33 -0
- package/scripts/config-preserve-smoke.mjs +180 -0
- package/scripts/doctor.mjs +484 -0
- package/scripts/edit-normalize-fuzz.mjs +130 -0
- package/scripts/edit-normalize-smoke.mjs +401 -0
- package/scripts/edit-operation-smoke.mjs +369 -0
- package/scripts/edit2-smoke.mjs +63 -0
- package/scripts/fuzzy-e2e.mjs +28 -0
- package/scripts/fuzzy-smoke.mjs +26 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/guard-smoke.mjs +66 -0
- package/scripts/hidden-role-schema-smoke.mjs +162 -0
- package/scripts/hook-routing-smoke.mjs +29 -0
- package/scripts/inject-input.ps1 +204 -0
- package/scripts/io-complex-smoke.mjs +667 -0
- package/scripts/io-explore-bench.mjs +424 -0
- package/scripts/io-guardrails-smoke.mjs +205 -0
- package/scripts/io-mini-bench-baseline.json +11 -0
- package/scripts/io-mini-bench.mjs +216 -0
- package/scripts/io-route-harness.mjs +933 -0
- package/scripts/io-telemetry-report.mjs +691 -0
- package/scripts/mutation-bench.mjs +564 -0
- package/scripts/mutation-io-smoke.mjs +1081 -0
- package/scripts/native-patch-bridge-smoke.mjs +288 -0
- package/scripts/native-patch-smoke.mjs +304 -0
- package/scripts/patch-interior-context-smoke.mjs +49 -0
- package/scripts/patch-newline-utf8-smoke.mjs +157 -0
- package/scripts/perf-hook-smoke.mjs +71 -0
- package/scripts/permission-eval-smoke.mjs +426 -0
- package/scripts/prep-patch.mjs +53 -0
- package/scripts/prep-shim.mjs +96 -0
- package/scripts/provider-cache-smoke.mjs +687 -0
- package/scripts/report-runtime-health.mjs +132 -0
- package/scripts/run-mcp.mjs +1547 -0
- package/scripts/salvage-v4a-shatter.test.mjs +58 -0
- package/scripts/scoped-cache-io-smoke.mjs +103 -0
- package/scripts/shell-policy-round3-smoke.mjs +46 -0
- package/scripts/smoke-runtime-negative.ps1 +100 -0
- package/scripts/smoke-runtime-negative.sh +95 -0
- package/scripts/stall-policy-smoke.mjs +50 -0
- package/scripts/start-memory-worker.mjs +23 -0
- package/scripts/statusline-launcher-smoke.mjs +82 -0
- package/scripts/stress-atomic-write.mjs +1028 -0
- package/scripts/test-config-rmw-restore.mjs +122 -0
- package/scripts/test-fault-inject.mjs +164 -0
- package/scripts/test-large-file.mjs +174 -0
- package/scripts/tool-edge-smoke.mjs +209 -0
- package/scripts/uninstall.mjs +201 -0
- package/scripts/webhook-selfheal-smoke.mjs +29 -0
- package/scripts/write-overwrite-guard-smoke.mjs +56 -0
- package/server-main.mjs +3055 -0
- package/server.mjs +468 -0
- package/setup/config-merge.mjs +254 -0
- package/setup/install.mjs +120 -0
- package/setup/launch-core.mjs +507 -0
- package/setup/launch.mjs +101 -0
- package/setup/setup-server.mjs +3206 -0
- package/setup/setup.html +3693 -0
- package/skills/retro-skill-proposer/SKILL.md +92 -0
- package/skills/schedule-add/SKILL.md +77 -0
- package/skills/setup/SKILL.md +346 -0
- package/skills/webhook-add/SKILL.md +81 -0
- package/src/agent/bridge-stall-watchdog.mjs +337 -0
- package/src/agent/index.mjs +2138 -0
- package/src/agent/orchestrator/activity-bus.mjs +38 -0
- package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
- package/src/agent/orchestrator/bridge-retry.mjs +220 -0
- package/src/agent/orchestrator/bridge-trace.mjs +583 -0
- package/src/agent/orchestrator/cache-mtime.mjs +58 -0
- package/src/agent/orchestrator/config.mjs +358 -0
- package/src/agent/orchestrator/context/collect.mjs +651 -0
- package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
- package/src/agent/orchestrator/drain-registry.mjs +50 -0
- package/src/agent/orchestrator/explore-validator.mjs +8 -0
- package/src/agent/orchestrator/internal-roles.mjs +118 -0
- package/src/agent/orchestrator/internal-tools.mjs +88 -0
- package/src/agent/orchestrator/jobs.mjs +116 -0
- package/src/agent/orchestrator/mcp/client.mjs +364 -0
- package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
- package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
- package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
- package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
- package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
- package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
- package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
- package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
- package/src/agent/orchestrator/providers/registry.mjs +192 -0
- package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
- package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
- package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
- package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
- package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
- package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
- package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
- package/src/agent/orchestrator/session/cache/util.mjs +49 -0
- package/src/agent/orchestrator/session/loop.mjs +1478 -0
- package/src/agent/orchestrator/session/manager.mjs +1975 -0
- package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
- package/src/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/agent/orchestrator/session/store.mjs +624 -0
- package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
- package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
- package/src/agent/orchestrator/session/trim.mjs +491 -0
- package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
- package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
- package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
- package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
- package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
- package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
- package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
- package/src/agent/orchestrator/stall-policy.mjs +195 -0
- package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
- package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
- package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
- package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
- package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
- package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
- package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
- package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
- package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
- package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
- package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
- package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
- package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
- package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
- package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
- package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
- package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
- package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
- package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
- package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
- package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
- package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
- package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
- package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
- package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
- package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
- package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
- package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
- package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
- package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
- package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
- package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
- package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
- package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
- package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
- package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
- package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
- package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
- package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
- package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
- package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
- package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
- package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
- package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
- package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
- package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
- package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
- package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
- package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
- package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
- package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
- package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
- package/src/agent/orchestrator/tools/builtin.mjs +500 -0
- package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
- package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
- package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
- package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
- package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
- package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
- package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
- package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
- package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
- package/src/agent/orchestrator/tools/host-input.mjs +204 -0
- package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
- package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
- package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
- package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
- package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
- package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
- package/src/agent/orchestrator/tools/patch.mjs +2754 -0
- package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
- package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
- package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
- package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
- package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
- package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
- package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
- package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
- package/src/agent/orchestrator/workflow-store.mjs +93 -0
- package/src/agent/tool-defs.mjs +103 -0
- package/src/channels/backends/discord.mjs +784 -0
- package/src/channels/data/voice-runtime-manifest.json +138 -0
- package/src/channels/index.mjs +3229 -0
- package/src/channels/lib/cli-worker-host.mjs +12 -0
- package/src/channels/lib/config-lock.mjs +13 -0
- package/src/channels/lib/config.mjs +292 -0
- package/src/channels/lib/drop-trace.mjs +71 -0
- package/src/channels/lib/event-pipeline.mjs +81 -0
- package/src/channels/lib/event-queue.mjs +345 -0
- package/src/channels/lib/executor.mjs +168 -0
- package/src/channels/lib/format.mjs +188 -0
- package/src/channels/lib/holidays.mjs +138 -0
- package/src/channels/lib/hook-pipe-server.mjs +802 -0
- package/src/channels/lib/interaction-workflows.mjs +184 -0
- package/src/channels/lib/memory-client.mjs +149 -0
- package/src/channels/lib/output-forwarder.mjs +765 -0
- package/src/channels/lib/runtime-paths.mjs +479 -0
- package/src/channels/lib/scheduler.mjs +723 -0
- package/src/channels/lib/session-control.mjs +36 -0
- package/src/channels/lib/session-discovery.mjs +103 -0
- package/src/channels/lib/settings.mjs +11 -0
- package/src/channels/lib/state-file.mjs +68 -0
- package/src/channels/lib/status-snapshot.mjs +219 -0
- package/src/channels/lib/tool-format.mjs +140 -0
- package/src/channels/lib/transcript-discovery.mjs +195 -0
- package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
- package/src/channels/lib/webhook.mjs +1179 -0
- package/src/channels/lib/whisper-server.mjs +477 -0
- package/src/channels/tool-defs.mjs +170 -0
- package/src/daemon/host.mjs +118 -0
- package/src/daemon/mcp-transport.mjs +47 -0
- package/src/daemon/session.mjs +100 -0
- package/src/daemon/thin-client.mjs +71 -0
- package/src/daemon/transport.mjs +163 -0
- package/src/memory/data/runtime-manifest.json +40 -0
- package/src/memory/index.mjs +3305 -0
- package/src/memory/lib/agent-ipc.mjs +93 -0
- package/src/memory/lib/bridge-trace-queries.mjs +120 -0
- package/src/memory/lib/core-memory-store.mjs +330 -0
- package/src/memory/lib/embedding-provider.mjs +269 -0
- package/src/memory/lib/embedding-worker.mjs +323 -0
- package/src/memory/lib/llm-worker-host.mjs +17 -0
- package/src/memory/lib/memory-cycle.mjs +11 -0
- package/src/memory/lib/memory-cycle1.mjs +641 -0
- package/src/memory/lib/memory-cycle2.mjs +1284 -0
- package/src/memory/lib/memory-cycle3.mjs +540 -0
- package/src/memory/lib/memory-embed.mjs +299 -0
- package/src/memory/lib/memory-extraction.mjs +5 -0
- package/src/memory/lib/memory-maintenance-store.mjs +32 -0
- package/src/memory/lib/memory-ops-policy.mjs +190 -0
- package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
- package/src/memory/lib/memory-recall-read-query.mjs +7 -0
- package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
- package/src/memory/lib/memory-recall-store.mjs +621 -0
- package/src/memory/lib/memory-retrievers.mjs +112 -0
- package/src/memory/lib/memory-score.mjs +71 -0
- package/src/memory/lib/memory-text-utils.mjs +58 -0
- package/src/memory/lib/memory.mjs +412 -0
- package/src/memory/lib/model-profile.mjs +85 -0
- package/src/memory/lib/pg/adapter.mjs +308 -0
- package/src/memory/lib/pg/process.mjs +360 -0
- package/src/memory/lib/pg/supervisor.mjs +396 -0
- package/src/memory/lib/project-id-resolver.mjs +86 -0
- package/src/memory/lib/runtime-fetcher.mjs +442 -0
- package/src/memory/lib/trace-store.mjs +728 -0
- package/src/memory/tool-defs.mjs +79 -0
- package/src/search/index.mjs +1173 -0
- package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
- package/src/search/lib/backends/exa.mjs +50 -0
- package/src/search/lib/backends/firecrawl.mjs +61 -0
- package/src/search/lib/backends/gemini-api.mjs +83 -0
- package/src/search/lib/backends/grok-oauth.mjs +86 -0
- package/src/search/lib/backends/index.mjs +150 -0
- package/src/search/lib/backends/openai-api.mjs +144 -0
- package/src/search/lib/backends/openai-oauth.mjs +98 -0
- package/src/search/lib/backends/openai-web-search.mjs +76 -0
- package/src/search/lib/backends/tavily.mjs +55 -0
- package/src/search/lib/backends/xai-api.mjs +113 -0
- package/src/search/lib/cache.mjs +131 -0
- package/src/search/lib/config.mjs +192 -0
- package/src/search/lib/formatter.mjs +115 -0
- package/src/search/lib/provider-usage.mjs +67 -0
- package/src/search/lib/providers.mjs +47 -0
- package/src/search/lib/search-intent.mjs +109 -0
- package/src/search/lib/setup-handler.mjs +261 -0
- package/src/search/lib/state.mjs +201 -0
- package/src/search/lib/web-tools.mjs +1207 -0
- package/src/search/tool-defs.mjs +83 -0
- package/src/setup/defender-exclusion.mjs +183 -0
- package/src/shared/abort-controller.mjs +15 -0
- package/src/shared/atomic-file.mjs +420 -0
- package/src/shared/config.mjs +350 -0
- package/src/shared/daemon-recycle.mjs +108 -0
- package/src/shared/disable-claude-builtins.mjs +88 -0
- package/src/shared/err-text.mjs +12 -0
- package/src/shared/llm/cost.mjs +66 -0
- package/src/shared/llm/http-agent.mjs +123 -0
- package/src/shared/llm/index.mjs +41 -0
- package/src/shared/llm/pid-cleanup.mjs +27 -0
- package/src/shared/llm/usage-log.mjs +47 -0
- package/src/shared/plugin-paths.mjs +58 -0
- package/src/shared/schedules-store.mjs +70 -0
- package/src/shared/seed.mjs +119 -0
- package/src/shared/user-cwd.mjs +213 -0
- package/src/shared/user-data-guard.mjs +238 -0
- package/src/status/aggregator.mjs +584 -0
- package/src/status/server.mjs +413 -0
- package/tools.json +1653 -0
|
@@ -0,0 +1,3229 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
import {
|
|
4
|
+
ListToolsRequestSchema,
|
|
5
|
+
CallToolRequestSchema
|
|
6
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { spawn, execSync, spawnSync } from "child_process";
|
|
8
|
+
import * as crypto from "crypto";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as http from "http";
|
|
11
|
+
import * as os from "os";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import { pathToFileURL } from "url";
|
|
14
|
+
import { createRequire } from "module";
|
|
15
|
+
const _require = createRequire(import.meta.url);
|
|
16
|
+
import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
|
|
17
|
+
import { resolveVoiceRuntime } from "./lib/voice-runtime-fetcher.mjs";
|
|
18
|
+
import { ensureReady, transcribe, stopVoiceWhisperServer } from "./lib/whisper-server.mjs";
|
|
19
|
+
import { loadConfig as loadAgentConfig } from "../agent/orchestrator/config.mjs";
|
|
20
|
+
import { captureOriginalUserCwd, readLastSessionCwd } from "../shared/user-cwd.mjs";
|
|
21
|
+
import { initProviders } from "../agent/orchestrator/providers/registry.mjs";
|
|
22
|
+
import { Scheduler } from "./lib/scheduler.mjs";
|
|
23
|
+
import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./lib/status-snapshot.mjs";
|
|
24
|
+
import { hasPending as dispatchHasPending } from "../agent/orchestrator/dispatch-persist.mjs";
|
|
25
|
+
import { setListener as setActivityBusListener } from "../agent/orchestrator/activity-bus.mjs";
|
|
26
|
+
import { stripSoftWarns } from "../agent/orchestrator/tool-loop-guard.mjs";
|
|
27
|
+
import { invalidatePrefetchCache } from "../agent/orchestrator/session/cache/prefetch-cache.mjs";
|
|
28
|
+
import { WebhookServer } from "./lib/webhook.mjs";
|
|
29
|
+
import { EventPipeline } from "./lib/event-pipeline.mjs";
|
|
30
|
+
import { startCliWorker } from "./lib/cli-worker-host.mjs";
|
|
31
|
+
import {
|
|
32
|
+
OutputForwarder,
|
|
33
|
+
discoverSessionBoundTranscript,
|
|
34
|
+
findLatestTranscriptByMtime
|
|
35
|
+
} from "./lib/output-forwarder.mjs";
|
|
36
|
+
import { controlClaudeSession } from "./lib/session-control.mjs";
|
|
37
|
+
import { JsonStateFile, ensureDir, removeFileIfExists, writeTextFile } from "./lib/state-file.mjs";
|
|
38
|
+
import {
|
|
39
|
+
buildModalRequestSpec,
|
|
40
|
+
PendingInteractionStore
|
|
41
|
+
} from "./lib/interaction-workflows.mjs";
|
|
42
|
+
import {
|
|
43
|
+
ensureRuntimeDirs,
|
|
44
|
+
makeInstanceId,
|
|
45
|
+
getTurnEndPath,
|
|
46
|
+
getStatusPath,
|
|
47
|
+
getPermissionResultPath,
|
|
48
|
+
getChannelOwnerPath,
|
|
49
|
+
getActiveOwnerPid,
|
|
50
|
+
getTerminalLeadPid,
|
|
51
|
+
readActiveInstance,
|
|
52
|
+
refreshActiveInstance,
|
|
53
|
+
cleanupStaleRuntimeFiles,
|
|
54
|
+
cleanupInstanceRuntimeFiles,
|
|
55
|
+
releaseOwnedChannelLocks,
|
|
56
|
+
clearActiveInstance,
|
|
57
|
+
killAllPreviousServers,
|
|
58
|
+
writeServerPid,
|
|
59
|
+
clearServerPid,
|
|
60
|
+
RUNTIME_ROOT
|
|
61
|
+
} from "./lib/runtime-paths.mjs";
|
|
62
|
+
import { PLUGIN_ROOT, getDiscordToken } from "./lib/config.mjs";
|
|
63
|
+
const memoryClientModulePath = pathToFileURL(path.join(PLUGIN_ROOT, "src/channels/lib/memory-client.mjs")).href;
|
|
64
|
+
const {
|
|
65
|
+
appendEntry: memoryAppendEntry,
|
|
66
|
+
ingestTranscript: memoryIngestTranscript,
|
|
67
|
+
} = await import(memoryClientModulePath);
|
|
68
|
+
const searchModulePath = pathToFileURL(path.join(PLUGIN_ROOT, "src/search/index.mjs")).href;
|
|
69
|
+
const DEFAULT_PLUGIN_VERSION = "0.0.1";
|
|
70
|
+
function localTimestamp() {
|
|
71
|
+
return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
|
|
72
|
+
}
|
|
73
|
+
function readPluginVersion() {
|
|
74
|
+
try {
|
|
75
|
+
const manifestPath = path.join(PLUGIN_ROOT, ".claude-plugin", "plugin.json");
|
|
76
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
77
|
+
return manifest.version || DEFAULT_PLUGIN_VERSION;
|
|
78
|
+
} catch {
|
|
79
|
+
return DEFAULT_PLUGIN_VERSION;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const PLUGIN_VERSION = readPluginVersion();
|
|
83
|
+
let crashLogging = false;
|
|
84
|
+
let _channelsDegraded = false;
|
|
85
|
+
let _stderrBroken = false;
|
|
86
|
+
function isChannelsDegraded() { return _channelsDegraded; }
|
|
87
|
+
|
|
88
|
+
function moduleEnabled(name) {
|
|
89
|
+
try {
|
|
90
|
+
const raw = JSON.parse(fs.readFileSync(path.join(DATA_DIR, "mixdog-config.json"), "utf8"));
|
|
91
|
+
const entry = raw?.modules?.[name];
|
|
92
|
+
return !(entry && typeof entry === "object" && entry.enabled === false);
|
|
93
|
+
} catch {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeInternalToolResult(result) {
|
|
99
|
+
if (!result || result.isError !== true) return result;
|
|
100
|
+
const text = Array.isArray(result.content)
|
|
101
|
+
? result.content.map((part) => part?.type === "text" ? part.text || "" : JSON.stringify(part)).join("\n").trim()
|
|
102
|
+
: String(result.raw || result.error || "").trim();
|
|
103
|
+
return {
|
|
104
|
+
...result,
|
|
105
|
+
content: [{ type: "text", text: `Error: ${text || "tool failed"}` }],
|
|
106
|
+
isError: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// stderr can break when the parent stdio pipe closes. Node then emits an
|
|
111
|
+
// async 'error' on process.stderr, which sync try/catch around write() does
|
|
112
|
+
// not catch — without a listener, that error becomes uncaughtException and
|
|
113
|
+
// re-enters logCrash, looping until the disk fills. Register a suppressor
|
|
114
|
+
// once at load time and stop writing to stderr after the first EPIPE so the
|
|
115
|
+
// loop cannot start.
|
|
116
|
+
try {
|
|
117
|
+
process.stderr.on('error', (e) => {
|
|
118
|
+
if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
|
|
119
|
+
_stderrBroken = true;
|
|
120
|
+
_channelsDegraded = true;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
} catch {}
|
|
124
|
+
|
|
125
|
+
// Crash log guards: dedup repeated identical errors (a single broken handler
|
|
126
|
+
// can fire thousands of times per minute) and rotate at a 10 MB cap so the
|
|
127
|
+
// file cannot grow unbounded. One .old generation is kept; older rolls drop.
|
|
128
|
+
const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
129
|
+
let _lastCrashSig = "";
|
|
130
|
+
let _crashRepeatCount = 0;
|
|
131
|
+
|
|
132
|
+
function _writeCrashLine(crashLog, line) {
|
|
133
|
+
try {
|
|
134
|
+
let size = 0;
|
|
135
|
+
try { size = fs.statSync(crashLog).size; } catch {}
|
|
136
|
+
if (size + line.length > CRASH_LOG_MAX_BYTES) {
|
|
137
|
+
try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
|
|
138
|
+
}
|
|
139
|
+
fs.appendFileSync(crashLog, line);
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function logCrash(label, err) {
|
|
144
|
+
if (crashLogging) return;
|
|
145
|
+
crashLogging = true;
|
|
146
|
+
const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
|
|
147
|
+
${err instanceof Error ? err.stack : ""}
|
|
148
|
+
`;
|
|
149
|
+
if (!_stderrBroken) {
|
|
150
|
+
try { process.stderr.write(msg); } catch (e) {
|
|
151
|
+
if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
|
|
152
|
+
_stderrBroken = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const sig = `${label}|${err && err.message ? err.message : String(err)}`;
|
|
157
|
+
const crashLog = path.join(DATA_DIR, "crash.log");
|
|
158
|
+
if (sig === _lastCrashSig) {
|
|
159
|
+
// Same error repeating — count it but skip the disk write. The next
|
|
160
|
+
// distinct error (or EPIPE branch below) flushes the suppressed total.
|
|
161
|
+
_crashRepeatCount += 1;
|
|
162
|
+
} else {
|
|
163
|
+
if (_crashRepeatCount > 0) {
|
|
164
|
+
_writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
|
|
165
|
+
_crashRepeatCount = 0;
|
|
166
|
+
}
|
|
167
|
+
_lastCrashSig = sig;
|
|
168
|
+
_writeCrashLine(crashLog, msg);
|
|
169
|
+
}
|
|
170
|
+
if (err instanceof Error && err.message.includes("EPIPE")) {
|
|
171
|
+
_channelsDegraded = true;
|
|
172
|
+
_stderrBroken = true;
|
|
173
|
+
}
|
|
174
|
+
crashLogging = false;
|
|
175
|
+
}
|
|
176
|
+
process.on("unhandledRejection", (err) => logCrash("unhandled rejection", err));
|
|
177
|
+
process.on("uncaughtException", (err) => logCrash("uncaught exception", err));
|
|
178
|
+
if (process.env.MIXDOG_CHANNELS_NO_CONNECT) {
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
const _isWorkerMode = process.env.MIXDOG_WORKER_MODE === '1'
|
|
182
|
+
const _bootLogEarly = path.join(
|
|
183
|
+
process.env.CLAUDE_PLUGIN_DATA || path.join(os.tmpdir(), "mixdog"),
|
|
184
|
+
"boot.log"
|
|
185
|
+
);
|
|
186
|
+
// One-shot log rotation at worker boot (10 MB threshold, .1 suffix overwrite).
|
|
187
|
+
try { if (fs.statSync(_bootLogEarly).size > 10 * 1024 * 1024) fs.renameSync(_bootLogEarly, _bootLogEarly + '.1') } catch {}
|
|
188
|
+
fs.appendFileSync(_bootLogEarly, `[${localTimestamp()}] bootstrap start pid=${process.pid}
|
|
189
|
+
`);
|
|
190
|
+
const _bootLog = path.join(DATA_DIR, "boot.log");
|
|
191
|
+
let config = loadConfig();
|
|
192
|
+
let backend = createBackend(config);
|
|
193
|
+
const INSTANCE_ID = makeInstanceId();
|
|
194
|
+
const TERMINAL_LEAD_PID = getTerminalLeadPid();
|
|
195
|
+
// ── drop-trace instrumentation ──────────────────────────────────────────────
|
|
196
|
+
const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
|
|
197
|
+
const DROP_TRACE_ENABLED =
|
|
198
|
+
process.env.MIXDOG_DROP_TRACE === "1" ||
|
|
199
|
+
process.env.MIXDOG_DROP_TRACE === "true" ||
|
|
200
|
+
process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
|
|
201
|
+
process.env.MIXDOG_DEBUG_CHANNELS === "true";
|
|
202
|
+
// One-shot rotation for drop-trace.log at worker boot.
|
|
203
|
+
if (DROP_TRACE_ENABLED) {
|
|
204
|
+
try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
|
|
205
|
+
}
|
|
206
|
+
// Rotate additional worker logs (10 MB threshold).
|
|
207
|
+
for (const _rotLog of ["channels-worker.log", "schedule.log", "event.log", "memory-worker.log", "mcp-debug.log", "webhook.log", "pg.log", "session-start.log"]) {
|
|
208
|
+
const _rotPath = path.join(DATA_DIR, _rotLog);
|
|
209
|
+
try { if (fs.statSync(_rotPath).size > 10 * 1024 * 1024) fs.renameSync(_rotPath, _rotPath + ".1") } catch {}
|
|
210
|
+
}
|
|
211
|
+
// GC per-worker scoped sibling logs (`<name>-worker.<leadPid>.<workerPid>.log`).
|
|
212
|
+
// Master logs above rotate live; scoped siblings are opened once per worker
|
|
213
|
+
// process and never reopened, so age-based removal is the only reliable
|
|
214
|
+
// cleanup signal. 7-day TTL keeps recent crash forensics while bounding leak.
|
|
215
|
+
const _STALE_WORKER_LOG_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
216
|
+
try {
|
|
217
|
+
const _now = Date.now();
|
|
218
|
+
for (const _f of fs.readdirSync(DATA_DIR)) {
|
|
219
|
+
if (!/^(channels|memory)-worker\.\d+\.\d+\.log$/.test(_f)
|
|
220
|
+
&& !/^mcp-debug\.\d+\.\d+\.log$/.test(_f)
|
|
221
|
+
&& !/^supervisor\.\d+\.log$/.test(_f)) continue;
|
|
222
|
+
const _p = path.join(DATA_DIR, _f);
|
|
223
|
+
try { if (_now - fs.statSync(_p).mtimeMs > _STALE_WORKER_LOG_TTL_MS) fs.unlinkSync(_p); } catch {}
|
|
224
|
+
}
|
|
225
|
+
} catch {}
|
|
226
|
+
// GC stale ephemeral session files. closeSession plants a closed=true
|
|
227
|
+
// tombstone, but bench / smoke / probe drivers historically created sessions
|
|
228
|
+
// without ever calling closeSession, leaving 175-byte placeholders behind.
|
|
229
|
+
// 7-day TTL is safe because live bridge sessions touch their JSON file on
|
|
230
|
+
// every ask iteration, so any file older than 7 days is provably abandoned.
|
|
231
|
+
const _SESSIONS_DIR = path.join(DATA_DIR, 'sessions');
|
|
232
|
+
const _STALE_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
233
|
+
try {
|
|
234
|
+
const _now = Date.now();
|
|
235
|
+
for (const _f of fs.readdirSync(_SESSIONS_DIR)) {
|
|
236
|
+
if (!_f.endsWith('.json')) continue;
|
|
237
|
+
const _p = path.join(_SESSIONS_DIR, _f);
|
|
238
|
+
try { if (_now - fs.statSync(_p).mtimeMs > _STALE_SESSION_TTL_MS) fs.unlinkSync(_p); } catch {}
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
|
|
242
|
+
// ── Buffered drop-trace writer (channels/index) ──────────────────────────────
|
|
243
|
+
// Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
|
|
244
|
+
// Drains on process exit so no log lines are lost.
|
|
245
|
+
let _dtIdxBuf = "";
|
|
246
|
+
let _dtIdxBytes = 0;
|
|
247
|
+
let _dtIdxFlushTimer = null;
|
|
248
|
+
let _dtIdxStream = null;
|
|
249
|
+
function _dtIdxGetStream() {
|
|
250
|
+
if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
|
|
251
|
+
return _dtIdxStream;
|
|
252
|
+
}
|
|
253
|
+
async function _dtIdxFlush() {
|
|
254
|
+
if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
|
|
255
|
+
if (!_dtIdxBuf) return;
|
|
256
|
+
const stream = _dtIdxGetStream();
|
|
257
|
+
const buf = _dtIdxBuf;
|
|
258
|
+
_dtIdxBuf = "";
|
|
259
|
+
_dtIdxBytes = 0;
|
|
260
|
+
try {
|
|
261
|
+
const ok = stream.write(buf);
|
|
262
|
+
if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
|
|
263
|
+
} catch {}
|
|
264
|
+
}
|
|
265
|
+
function _dtIdxScheduleFlush() {
|
|
266
|
+
if (_dtIdxFlushTimer) return;
|
|
267
|
+
_dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
|
|
268
|
+
if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
|
|
269
|
+
}
|
|
270
|
+
function _dtIdxAppend(line) {
|
|
271
|
+
_dtIdxBuf += line;
|
|
272
|
+
_dtIdxBytes += Buffer.byteLength(line);
|
|
273
|
+
if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
|
|
274
|
+
_dtIdxScheduleFlush();
|
|
275
|
+
}
|
|
276
|
+
process.on("exit", () => { void _dtIdxFlush(); });
|
|
277
|
+
// SIGTERM: flush the drop-trace buffer, but do NOT exit here. In worker
|
|
278
|
+
// mode the graceful `_channelsShutdownHandler` below owns shutdown
|
|
279
|
+
// (stop() → cleanup → process.exit). In non-worker mode no SIGTERM
|
|
280
|
+
// handler was previously installed beyond this one; defer to default
|
|
281
|
+
// termination so process.on('exit') hooks still run.
|
|
282
|
+
process.on("SIGTERM", () => {
|
|
283
|
+
void _dtIdxFlush();
|
|
284
|
+
if (!_isWorkerMode) process.exit(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
function preview(text) {
|
|
288
|
+
if (!text) return "";
|
|
289
|
+
const s = String(text).replace(/\n/g, "\\n");
|
|
290
|
+
return s.length > 120 ? s.slice(0, 120) + "…" : s;
|
|
291
|
+
}
|
|
292
|
+
function dropTrace(event, fields) {
|
|
293
|
+
if (!DROP_TRACE_ENABLED) return;
|
|
294
|
+
try {
|
|
295
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
296
|
+
const loc = `[${ts}][pid=${process.pid}] ${event}`;
|
|
297
|
+
const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
|
|
298
|
+
_dtIdxAppend(loc + kv + "\n");
|
|
299
|
+
} catch {}
|
|
300
|
+
}
|
|
301
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
302
|
+
ensureRuntimeDirs();
|
|
303
|
+
cleanupStaleRuntimeFiles();
|
|
304
|
+
if (!_isWorkerMode) {
|
|
305
|
+
killAllPreviousServers();
|
|
306
|
+
writeServerPid();
|
|
307
|
+
// Publish owner identity immediately so the SessionStart shim's
|
|
308
|
+
// owner_lead_alive() sees a live owner and uses the full connect budget
|
|
309
|
+
// instead of the 5s no-owner grace (fixes missing recap/core on restart).
|
|
310
|
+
// backendReady intentionally omitted — readiness stays gated until connect.
|
|
311
|
+
refreshActiveInstance(INSTANCE_ID);
|
|
312
|
+
startCliWorker();
|
|
313
|
+
}
|
|
314
|
+
const INSTRUCTIONS = "";
|
|
315
|
+
|
|
316
|
+
// ── Parent notification helper ───────────────────────────────────────
|
|
317
|
+
// This worker has no MCP transport of its own. All notifications flow
|
|
318
|
+
// through IPC to the parent (server.mjs), which owns the single connected
|
|
319
|
+
// MCP `Server` instance. The parent's IPC message handler translates
|
|
320
|
+
// `{type:'notify', method, params}` into `server.notification({method, params})`.
|
|
321
|
+
//
|
|
322
|
+
// Before v0.6.7 the worker had its own orphan `Server` instance that was
|
|
323
|
+
// never `connect()`ed to any transport, so `.notification()` silently
|
|
324
|
+
// threw 'Not connected' inside the SDK and every call was dropped by an
|
|
325
|
+
// outer `.catch(() => {})`. That regression is what this path replaces.
|
|
326
|
+
function sendNotifyToParent(method, params) {
|
|
327
|
+
if (!process.send) {
|
|
328
|
+
try { process.stderr.write(`mixdog channels: notify dropped (no IPC): ${method}\n`); } catch {}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
// CC channel schema requires meta: Record<string,string> (channelNotification.ts).
|
|
332
|
+
// Coerce every meta value to string so a non-string (e.g. a Discord
|
|
333
|
+
// interaction.type number) can't fail zod and silently drop the notify.
|
|
334
|
+
// silent_to_agent stays boolean — an internal routing flag the daemon
|
|
335
|
+
// router / agentNotify consume (=== true) before the CC zod boundary.
|
|
336
|
+
let outParams = params;
|
|
337
|
+
if (method === 'notifications/claude/channel' && params && params.meta) {
|
|
338
|
+
const m = {};
|
|
339
|
+
for (const [k, v] of Object.entries(params.meta)) {
|
|
340
|
+
if (v === undefined || v === null) continue;
|
|
341
|
+
m[k] = k === 'silent_to_agent' ? (v === true || v === 'true') : String(v);
|
|
342
|
+
}
|
|
343
|
+
outParams = { ...params, meta: m };
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
process.send({ type: 'notify', method, params: outParams });
|
|
347
|
+
} catch (err) {
|
|
348
|
+
try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const recapState = { state: 'idle', running: false, startedAt: null, lastCompletedAt: null, updatedAt: null, errorMessage: null };
|
|
353
|
+
function sendRecapStateToParent() {
|
|
354
|
+
if (!process.send) return;
|
|
355
|
+
try {
|
|
356
|
+
process.send({ type: 'recap_status', recap: { ...recapState } });
|
|
357
|
+
} catch (err) {
|
|
358
|
+
try { process.stderr.write(`mixdog channels: recap status IPC send failed: ${err && err.message || err}\n`); } catch {}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Memory worker bridge (worker → parent → memory) ─────────────────
|
|
363
|
+
// The channels worker does not own the memory worker handle. To trigger
|
|
364
|
+
// memory tool actions (e.g. cycle1) we send `memory_call_request` to the
|
|
365
|
+
// parent, which routes through callWorker('memory', ...) and ships the
|
|
366
|
+
// result back as `memory_call_response`. The response listener is
|
|
367
|
+
// integrated into the main IPC handler below (not a second listener).
|
|
368
|
+
const _memoryCallPending = new Map();
|
|
369
|
+
let _memoryCallSeq = 0;
|
|
370
|
+
|
|
371
|
+
function callMemoryAction(action, args, timeoutMs) {
|
|
372
|
+
return new Promise((resolve, reject) => {
|
|
373
|
+
if (!process.send) return reject(new Error('not a worker process'));
|
|
374
|
+
const callId = `mc_${INSTANCE_ID}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
|
|
375
|
+
const timer = setTimeout(() => {
|
|
376
|
+
_memoryCallPending.delete(callId);
|
|
377
|
+
reject(new Error(`memory_call ${action} timed out after ${timeoutMs}ms`));
|
|
378
|
+
}, timeoutMs);
|
|
379
|
+
_memoryCallPending.set(callId, {
|
|
380
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
381
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
382
|
+
});
|
|
383
|
+
try {
|
|
384
|
+
process.send({ type: 'memory_call_request', callId, action, args: args || {} });
|
|
385
|
+
} catch (e) {
|
|
386
|
+
_memoryCallPending.delete(callId);
|
|
387
|
+
clearTimeout(timer);
|
|
388
|
+
reject(e);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
function resolveChannelLabel(channelsConfig, label) {
|
|
393
|
+
if (!label || !channelsConfig) return label;
|
|
394
|
+
const entry = channelsConfig[label];
|
|
395
|
+
if (entry?.channelId) return entry.channelId;
|
|
396
|
+
return label;
|
|
397
|
+
}
|
|
398
|
+
let channelBridgeActive = false;
|
|
399
|
+
function writeBridgeState(active) {
|
|
400
|
+
try {
|
|
401
|
+
const stateFile = path.join(os.tmpdir(), "mixdog", "bridge-state.json");
|
|
402
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
403
|
+
fs.writeFileSync(stateFile, JSON.stringify({ active, ts: Date.now() }));
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function isChannelBridgeActive() {
|
|
408
|
+
return channelBridgeActive;
|
|
409
|
+
}
|
|
410
|
+
let typingChannelId = null;
|
|
411
|
+
const pendingSetup = new PendingInteractionStore();
|
|
412
|
+
function startServerTyping(channelId) {
|
|
413
|
+
if (typingChannelId && typingChannelId !== channelId) {
|
|
414
|
+
backend.stopTyping(typingChannelId);
|
|
415
|
+
}
|
|
416
|
+
typingChannelId = channelId;
|
|
417
|
+
backend.startTyping(channelId);
|
|
418
|
+
}
|
|
419
|
+
function stopServerTyping() {
|
|
420
|
+
if (typingChannelId) {
|
|
421
|
+
backend.stopTyping(typingChannelId);
|
|
422
|
+
typingChannelId = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const TURN_END_FILE = getTurnEndPath(INSTANCE_ID);
|
|
426
|
+
const TURN_END_BASENAME = path.basename(TURN_END_FILE);
|
|
427
|
+
const TURN_END_DIR = path.dirname(TURN_END_FILE);
|
|
428
|
+
let turnEndWatcher = null;
|
|
429
|
+
if (!_isWorkerMode) {
|
|
430
|
+
removeFileIfExists(TURN_END_FILE);
|
|
431
|
+
turnEndWatcher = fs.watch(TURN_END_DIR, async (_event, filename) => {
|
|
432
|
+
if (filename !== TURN_END_BASENAME) return;
|
|
433
|
+
try {
|
|
434
|
+
const stat = fs.statSync(TURN_END_FILE);
|
|
435
|
+
if (stat.size > 0) {
|
|
436
|
+
stopServerTyping();
|
|
437
|
+
await forwarder.forwardFinalText();
|
|
438
|
+
removeFileIfExists(TURN_END_FILE);
|
|
439
|
+
}
|
|
440
|
+
} catch {
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
const STATUS_FILE = getStatusPath(INSTANCE_ID);
|
|
445
|
+
const statusState = new JsonStateFile(STATUS_FILE, {});
|
|
446
|
+
statusState.ensure();
|
|
447
|
+
function sessionIdFromTranscriptPath(transcriptPath) {
|
|
448
|
+
const base = path.basename(transcriptPath);
|
|
449
|
+
return base.endsWith(".jsonl") ? base.slice(0, -6) : "";
|
|
450
|
+
}
|
|
451
|
+
function getPersistedTranscriptPath() {
|
|
452
|
+
const state = statusState.read();
|
|
453
|
+
if (typeof state.transcriptPath === "string" && state.transcriptPath) return state.transcriptPath;
|
|
454
|
+
return readActiveInstance()?.transcriptPath ?? "";
|
|
455
|
+
}
|
|
456
|
+
function pickUsableTranscriptPath(bound, previousPath) {
|
|
457
|
+
if (bound?.exists) return bound.transcriptPath;
|
|
458
|
+
if (!previousPath) return "";
|
|
459
|
+
if (!bound?.sessionId) return previousPath;
|
|
460
|
+
return sessionIdFromTranscriptPath(previousPath) === bound.sessionId ? previousPath : "";
|
|
461
|
+
}
|
|
462
|
+
const forwarder = new OutputForwarder({
|
|
463
|
+
send: async (ch, text) => {
|
|
464
|
+
if (!channelBridgeActive) {
|
|
465
|
+
throw new Error("send() called while channel bridge is inactive");
|
|
466
|
+
}
|
|
467
|
+
await backend.sendMessage(ch, text);
|
|
468
|
+
},
|
|
469
|
+
recordAssistantTurn: async () => {
|
|
470
|
+
},
|
|
471
|
+
react: (ch, mid, emoji) => {
|
|
472
|
+
if (!channelBridgeActive) return Promise.resolve();
|
|
473
|
+
return backend.react(ch, mid, emoji);
|
|
474
|
+
},
|
|
475
|
+
removeReaction: (ch, mid, emoji) => {
|
|
476
|
+
if (!channelBridgeActive) return Promise.resolve();
|
|
477
|
+
return backend.removeReaction(ch, mid, emoji);
|
|
478
|
+
}
|
|
479
|
+
}, statusState);
|
|
480
|
+
forwarder.setOnIdle(() => {
|
|
481
|
+
stopServerTyping();
|
|
482
|
+
void forwarder.forwardFinalText();
|
|
483
|
+
});
|
|
484
|
+
// Wire the forwarder ownership probe unconditionally. wireEventQueueHandlers()
|
|
485
|
+
// also sets this, but that path only runs when the event pipeline starts
|
|
486
|
+
// (webhook enabled or event rules present). Without an event pipeline the
|
|
487
|
+
// forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
|
|
488
|
+
// non-owner / proxy process forward transcript output (duplicate Discord
|
|
489
|
+
// sends). The closure reads bridgeRuntimeConnected/proxyMode at call time.
|
|
490
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
|
|
491
|
+
function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
|
|
492
|
+
if (!transcriptPath) return;
|
|
493
|
+
forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
|
|
494
|
+
const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
|
|
495
|
+
forwarder.startWatch();
|
|
496
|
+
void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
|
|
497
|
+
refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath });
|
|
498
|
+
if (options.persistStatus !== false) {
|
|
499
|
+
statusState.update((state) => {
|
|
500
|
+
state.channelId = channelId;
|
|
501
|
+
state.transcriptPath = boundTranscriptPath;
|
|
502
|
+
state.lastFileSize = forwarder.lastFileSize;
|
|
503
|
+
state.sentCount = forwarder.sentCount;
|
|
504
|
+
state.lastSentHash = forwarder.lastHash;
|
|
505
|
+
state.lastSentTime = 0;
|
|
506
|
+
state.sessionIdle = false;
|
|
507
|
+
state.sessionCwd = options.cwd ?? null;
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async function rebindTranscriptContext(channelId, options = {}) {
|
|
512
|
+
const previousPath = options.previousPath ?? "";
|
|
513
|
+
const mode = options.mode ?? "same";
|
|
514
|
+
const explicitTranscriptPath = typeof options.transcriptPath === "string" ? options.transcriptPath.trim() : "";
|
|
515
|
+
if (explicitTranscriptPath) {
|
|
516
|
+
let explicitExists = false;
|
|
517
|
+
try {
|
|
518
|
+
explicitExists = fs.statSync(explicitTranscriptPath).isFile();
|
|
519
|
+
} catch {
|
|
520
|
+
explicitExists = false;
|
|
521
|
+
}
|
|
522
|
+
if (explicitExists) {
|
|
523
|
+
applyTranscriptBinding(channelId, explicitTranscriptPath, {
|
|
524
|
+
replayFromStart: Boolean(options.catchUp),
|
|
525
|
+
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
526
|
+
persistStatus: options.persistStatus
|
|
527
|
+
});
|
|
528
|
+
if (options.catchUp || options.catchUpFromPersisted) {
|
|
529
|
+
await forwarder.forwardNewText();
|
|
530
|
+
}
|
|
531
|
+
return explicitTranscriptPath;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
let sawPendingTranscript = false;
|
|
535
|
+
let pendingSessionId = "";
|
|
536
|
+
for (let attempt = 0; attempt < 30; attempt += 1) {
|
|
537
|
+
const bound = discoverSessionBoundTranscript();
|
|
538
|
+
if (bound?.exists) {
|
|
539
|
+
const acceptable = mode === "same" || !previousPath || bound.transcriptPath !== previousPath;
|
|
540
|
+
if (acceptable) {
|
|
541
|
+
const replayFromStart = Boolean(
|
|
542
|
+
options.catchUp && !previousPath && sawPendingTranscript && pendingSessionId === bound.sessionId
|
|
543
|
+
);
|
|
544
|
+
applyTranscriptBinding(channelId, bound.transcriptPath, {
|
|
545
|
+
replayFromStart,
|
|
546
|
+
catchUpFromPersisted: options.catchUpFromPersisted,
|
|
547
|
+
persistStatus: options.persistStatus,
|
|
548
|
+
cwd: bound.sessionCwd,
|
|
549
|
+
});
|
|
550
|
+
if (replayFromStart || options.catchUpFromPersisted) {
|
|
551
|
+
await forwarder.forwardNewText();
|
|
552
|
+
}
|
|
553
|
+
return bound.transcriptPath;
|
|
554
|
+
}
|
|
555
|
+
} else if (bound?.sessionId) {
|
|
556
|
+
sawPendingTranscript = true;
|
|
557
|
+
pendingSessionId = bound.sessionId;
|
|
558
|
+
}
|
|
559
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
560
|
+
}
|
|
561
|
+
if (previousPath) {
|
|
562
|
+
applyTranscriptBinding(channelId, previousPath, { catchUpFromPersisted: true, cwd: statusState.read().sessionCwd });
|
|
563
|
+
await forwarder.forwardNewText();
|
|
564
|
+
process.stderr.write(`mixdog: rebind fallback: bound previous transcript ${previousPath}\n`);
|
|
565
|
+
return previousPath;
|
|
566
|
+
}
|
|
567
|
+
process.stderr.write(`mixdog: rebind failed: no transcript found and no previous path to fall back to\n`);
|
|
568
|
+
return "";
|
|
569
|
+
}
|
|
570
|
+
const scheduler = new Scheduler(
|
|
571
|
+
config.nonInteractive ?? [],
|
|
572
|
+
config.interactive ?? [],
|
|
573
|
+
// channelsConfig kept for channel-label resolution (resolveChannel)
|
|
574
|
+
// only — quiet/schedules now come from the top-level config.
|
|
575
|
+
config.channelsConfig,
|
|
576
|
+
// 0.1.62: top-level normalized config carries quiet/schedules.
|
|
577
|
+
config
|
|
578
|
+
);
|
|
579
|
+
// Register the pending-dispatch probe so the scheduler treats an in-flight
|
|
580
|
+
// bridge dispatch as "active" regardless of user-inbound silence.
|
|
581
|
+
scheduler.setPendingCheck(() => {
|
|
582
|
+
try {
|
|
583
|
+
return dispatchHasPending(process.env.CLAUDE_PLUGIN_DATA);
|
|
584
|
+
} catch {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
// Bridge the orchestrator-side activity notifier into the scheduler so
|
|
589
|
+
// events like `addPending` can bump lastActivity without importing the
|
|
590
|
+
// scheduler instance directly (avoids module cycles).
|
|
591
|
+
setActivityBusListener(() => scheduler.noteActivity());
|
|
592
|
+
let webhookServer = null;
|
|
593
|
+
let eventPipeline = null;
|
|
594
|
+
let bridgeRuntimeConnected = false;
|
|
595
|
+
let bridgeRuntimeStarting = false;
|
|
596
|
+
// Stop-requested signal: set by stopOwnedRuntime() when it runs during the
|
|
597
|
+
// startOwnedRuntime() in-flight window (bridgeRuntimeStarting=true). Checked
|
|
598
|
+
// by startOwnedRuntime() right after backend.connect() resolves so the
|
|
599
|
+
// in-flight start does not revive owner state after the stop already tore
|
|
600
|
+
// the partial-start state down.
|
|
601
|
+
let _ownedRuntimeStopRequested = false;
|
|
602
|
+
let bridgeOwnershipRefreshInFlight = null;
|
|
603
|
+
let bridgeOwnershipTimer = null;
|
|
604
|
+
let lastOwnershipNote = "";
|
|
605
|
+
const ACTIVE_OWNER_STALE_MS = 1e4;
|
|
606
|
+
// Owner heartbeat: keep active-instance.json fresh so other sessions cannot
|
|
607
|
+
// steal the seat after 10 s of channel-action silence. unref'd interval —
|
|
608
|
+
// never blocks process exit. Single JSON atomic write, no measurable load.
|
|
609
|
+
const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
|
|
610
|
+
let ownerHeartbeatTimer = null;
|
|
611
|
+
// Owner gating here is multi-process runtime coordination: only the active
|
|
612
|
+
// bindingReady gates all send paths until the boot-time refreshBridgeOwnership
|
|
613
|
+
// ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
|
|
614
|
+
// emissions fired within the first ~few hundred ms after restart drop because
|
|
615
|
+
// the Discord backend binding has not yet been established.
|
|
616
|
+
let bindingReadyStatus = "pending";
|
|
617
|
+
// Channel-flag detection result, stored at module scope so the worker-mode
|
|
618
|
+
// ready IPC can forward it to the daemon for caching across respawns.
|
|
619
|
+
let _channelFlagDetected = false;
|
|
620
|
+
let _bindingReadyResolve;
|
|
621
|
+
const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
|
|
622
|
+
dropTrace("bindingReady.create", { status: bindingReadyStatus });
|
|
623
|
+
// owner runs webhook/event ticks. It is not webhook HTTP authentication.
|
|
624
|
+
let proxyMode = false;
|
|
625
|
+
let ownerHttpPort = 0;
|
|
626
|
+
let ownerHttpServer = null;
|
|
627
|
+
const PROXY_PORT_MIN = 3460;
|
|
628
|
+
const PROXY_PORT_MAX = 3467;
|
|
629
|
+
// Per-owner-process auth secret. Generated once at HTTP server start and
|
|
630
|
+
// published into runtime/owner-secret-<instanceId>.json with 0o600 perms so
|
|
631
|
+
// only the owner UID can read it back. requireOwnerToken below checks THIS
|
|
632
|
+
// secret (not the public-by-/ping instanceId) so any local caller that
|
|
633
|
+
// scrapes /ping cannot forge owner-side calls. The file is keyed on the
|
|
634
|
+
// owner's INSTANCE_ID — the SAME identifier published into active-instance
|
|
635
|
+
// as `instanceId` and validated by requireOwnerToken's x-owner-instance
|
|
636
|
+
// header check — so proxy readers can resolve the path off readActiveInstance()
|
|
637
|
+
// without depending on getActiveOwnerPid(), which prefers ownerLeadPid/
|
|
638
|
+
// terminalLeadPid/supervisor_pid and would diverge from process.pid in
|
|
639
|
+
// supervisor-backed sessions.
|
|
640
|
+
let OWNER_SECRET = "";
|
|
641
|
+
function getOwnerSecretPath(instanceId) {
|
|
642
|
+
return path.join(RUNTIME_ROOT, `owner-secret-${String(instanceId)}.json`);
|
|
643
|
+
}
|
|
644
|
+
function publishOwnerSecret(secret) {
|
|
645
|
+
const file = getOwnerSecretPath(INSTANCE_ID);
|
|
646
|
+
try { ensureDir(RUNTIME_ROOT); } catch {}
|
|
647
|
+
// Best-effort restrictive write: O_CREAT|O_TRUNC|O_WRONLY with mode 0o600.
|
|
648
|
+
// On Windows mode bits are largely ignored, but the file still lives in
|
|
649
|
+
// the per-user tmp dir; an attacker without the same UID cannot read it.
|
|
650
|
+
try { fs.unlinkSync(file); } catch {}
|
|
651
|
+
const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY, 0o600);
|
|
652
|
+
try {
|
|
653
|
+
fs.writeSync(fd, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, secret, updatedAt: Date.now() }));
|
|
654
|
+
} finally {
|
|
655
|
+
try { fs.closeSync(fd); } catch {}
|
|
656
|
+
}
|
|
657
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
658
|
+
}
|
|
659
|
+
function clearOwnerSecret() {
|
|
660
|
+
try { fs.unlinkSync(getOwnerSecretPath(INSTANCE_ID)); } catch {}
|
|
661
|
+
}
|
|
662
|
+
function readOwnerSecretFor(ownerInstanceId) {
|
|
663
|
+
if (!ownerInstanceId) return "";
|
|
664
|
+
try {
|
|
665
|
+
const raw = fs.readFileSync(getOwnerSecretPath(ownerInstanceId), "utf8");
|
|
666
|
+
const parsed = JSON.parse(raw);
|
|
667
|
+
return typeof parsed?.secret === "string" ? parsed.secret : "";
|
|
668
|
+
} catch {
|
|
669
|
+
return "";
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async function proxyRequest(endpoint, method, body) {
|
|
673
|
+
return new Promise((resolve) => {
|
|
674
|
+
const url = new URL(`http://127.0.0.1:${ownerHttpPort}${endpoint}`);
|
|
675
|
+
// Auth: read the owner's per-process secret from the restricted
|
|
676
|
+
// owner-secret file (0o600). The instanceId header is kept only as a
|
|
677
|
+
// secondary diagnostic — requireOwnerToken on the owner side checks
|
|
678
|
+
// the secret, not the instanceId.
|
|
679
|
+
const active = readActiveInstance();
|
|
680
|
+
const ownerInstanceId = active?.instanceId || INSTANCE_ID;
|
|
681
|
+
// Key the secret-file lookup on the owner's published instanceId — the
|
|
682
|
+
// SAME identifier the owner used when writing owner-secret-<instanceId>.json
|
|
683
|
+
// (publishOwnerSecret above) and what requireOwnerToken's x-owner-instance
|
|
684
|
+
// header check compares against. Do NOT route this through
|
|
685
|
+
// getActiveOwnerPid(active): that helper prefers ownerLeadPid /
|
|
686
|
+
// terminalLeadPid / supervisor_pid, which in a supervisor-backed session
|
|
687
|
+
// diverge from the owner-HTTP process.pid (== owner's INSTANCE_ID),
|
|
688
|
+
// causing the proxy to read owner-secret-<supervisorPid>.json while the
|
|
689
|
+
// owner wrote owner-secret-<process.pid>.json → empty secret → 401.
|
|
690
|
+
const ownerSecret = readOwnerSecretFor(ownerInstanceId);
|
|
691
|
+
if (!ownerSecret) {
|
|
692
|
+
resolve({ ok: false, error: "owner secret unavailable (file missing or unreadable)" });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const reqOpts = {
|
|
696
|
+
hostname: "127.0.0.1",
|
|
697
|
+
port: ownerHttpPort,
|
|
698
|
+
path: url.pathname + url.search,
|
|
699
|
+
method,
|
|
700
|
+
headers: {
|
|
701
|
+
"Content-Type": "application/json",
|
|
702
|
+
"x-owner-token": ownerSecret,
|
|
703
|
+
"x-owner-instance": ownerInstanceId,
|
|
704
|
+
},
|
|
705
|
+
timeout: 3e4
|
|
706
|
+
};
|
|
707
|
+
const req = http.request(reqOpts, (res) => {
|
|
708
|
+
let data = "";
|
|
709
|
+
res.on("data", (chunk) => {
|
|
710
|
+
data += chunk;
|
|
711
|
+
});
|
|
712
|
+
res.on("end", () => {
|
|
713
|
+
try {
|
|
714
|
+
const parsed = JSON.parse(data);
|
|
715
|
+
resolve({ ok: res.statusCode === 200, data: parsed, error: parsed.error });
|
|
716
|
+
} catch {
|
|
717
|
+
resolve({ ok: false, error: `invalid response from owner: ${data.slice(0, 200)}` });
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
req.on("error", (err) => {
|
|
722
|
+
resolve({ ok: false, error: `proxy request failed: ${err.message}` });
|
|
723
|
+
});
|
|
724
|
+
req.on("timeout", () => {
|
|
725
|
+
req.destroy();
|
|
726
|
+
resolve({ ok: false, error: "proxy request timed out" });
|
|
727
|
+
});
|
|
728
|
+
if (body) req.write(JSON.stringify(body));
|
|
729
|
+
req.end();
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
async function pingOwner(port) {
|
|
733
|
+
return new Promise((resolve) => {
|
|
734
|
+
const req = http.request({
|
|
735
|
+
hostname: "127.0.0.1",
|
|
736
|
+
port,
|
|
737
|
+
path: "/ping",
|
|
738
|
+
method: "GET",
|
|
739
|
+
timeout: 3e3
|
|
740
|
+
}, (res) => {
|
|
741
|
+
res.resume();
|
|
742
|
+
resolve(res.statusCode === 200);
|
|
743
|
+
});
|
|
744
|
+
req.on("error", () => resolve(false));
|
|
745
|
+
req.on("timeout", () => {
|
|
746
|
+
req.destroy();
|
|
747
|
+
resolve(false);
|
|
748
|
+
});
|
|
749
|
+
req.end();
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
function tryListenPort(server, port) {
|
|
753
|
+
return new Promise((resolve) => {
|
|
754
|
+
server.once("error", () => resolve(false));
|
|
755
|
+
server.listen(port, "127.0.0.1", () => resolve(true));
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
// Owner-token auth gate. Compares x-owner-token against the per-process
|
|
759
|
+
// OWNER_SECRET generated at startOwnerHttpServer time. The secret is NOT
|
|
760
|
+
// returned by /ping (only the public instanceId is) so a local caller that
|
|
761
|
+
// scrapes /ping still cannot forge owner-side calls. Constant-time compare
|
|
762
|
+
// to avoid trivial timing leakage on the local socket. Optional secondary
|
|
763
|
+
// instanceId check via x-owner-instance: when present it must match this
|
|
764
|
+
// process's INSTANCE_ID, catching stale clients targeting an old owner.
|
|
765
|
+
function requireOwnerToken(req, res) {
|
|
766
|
+
const token = req.headers["x-owner-token"];
|
|
767
|
+
if (!OWNER_SECRET || typeof token !== "string" || token.length !== OWNER_SECRET.length) {
|
|
768
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
769
|
+
res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
let ok = false;
|
|
773
|
+
try {
|
|
774
|
+
ok = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(OWNER_SECRET));
|
|
775
|
+
} catch {
|
|
776
|
+
ok = false;
|
|
777
|
+
}
|
|
778
|
+
if (!ok) {
|
|
779
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
780
|
+
res.end(JSON.stringify({ error: "unauthorized: x-owner-token required" }));
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
const instanceHeader = req.headers["x-owner-instance"];
|
|
784
|
+
if (instanceHeader && instanceHeader !== INSTANCE_ID) {
|
|
785
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
786
|
+
res.end(JSON.stringify({ error: "unauthorized: instance mismatch" }));
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
return true;
|
|
790
|
+
}
|
|
791
|
+
// Per-route handler table. Each handler matches the original switch-case
|
|
792
|
+
// behavior byte-for-byte (auth checks, status codes, response shapes); the
|
|
793
|
+
// outer dispatch loop just looks up the entry instead of running a long
|
|
794
|
+
// switch. `methods` mirrors any pre-existing 405 guard.
|
|
795
|
+
const OWNER_ROUTES = {
|
|
796
|
+
"/ping": async (req, res /*, body, url*/) => {
|
|
797
|
+
res.writeHead(200);
|
|
798
|
+
res.end(JSON.stringify({ ok: true, instanceId: INSTANCE_ID, pid: process.pid }));
|
|
799
|
+
},
|
|
800
|
+
"/send": async (req, res, body) => {
|
|
801
|
+
if (!requireOwnerToken(req, res)) return;
|
|
802
|
+
// Pre/post-send activity bumps keep idle gating consistent across
|
|
803
|
+
// slow network / attachment / rate-limited sends; double bump is
|
|
804
|
+
// harmless.
|
|
805
|
+
scheduler.noteActivity();
|
|
806
|
+
const sendResult = await backend.sendMessage(body.chatId, body.text, body.opts);
|
|
807
|
+
scheduler.noteActivity();
|
|
808
|
+
res.writeHead(200);
|
|
809
|
+
res.end(JSON.stringify({ sentIds: sendResult.sentIds }));
|
|
810
|
+
},
|
|
811
|
+
"/react": async (req, res, body) => {
|
|
812
|
+
if (!requireOwnerToken(req, res)) return;
|
|
813
|
+
await backend.react(body.chatId, body.messageId, body.emoji);
|
|
814
|
+
res.writeHead(200);
|
|
815
|
+
res.end(JSON.stringify({ ok: true }));
|
|
816
|
+
},
|
|
817
|
+
"/edit": async (req, res, body) => {
|
|
818
|
+
if (!requireOwnerToken(req, res)) return;
|
|
819
|
+
const editId = await backend.editMessage(body.chatId, body.messageId, body.text, body.opts);
|
|
820
|
+
res.writeHead(200);
|
|
821
|
+
res.end(JSON.stringify({ id: editId }));
|
|
822
|
+
},
|
|
823
|
+
"/fetch": async (req, res, body, url) => {
|
|
824
|
+
if (!requireOwnerToken(req, res)) return;
|
|
825
|
+
const channelId = url.searchParams.get("channel") ?? "";
|
|
826
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "20", 10);
|
|
827
|
+
const msgs = await backend.fetchMessages(channelId, limit);
|
|
828
|
+
recordFetchedMessages(channelId, labelForChannelId(channelId), msgs);
|
|
829
|
+
res.writeHead(200);
|
|
830
|
+
res.end(JSON.stringify({ messages: msgs }));
|
|
831
|
+
},
|
|
832
|
+
"/download": async (req, res, body) => {
|
|
833
|
+
if (!requireOwnerToken(req, res)) return;
|
|
834
|
+
const files = await backend.downloadAttachment(body.chatId, body.messageId);
|
|
835
|
+
res.writeHead(200);
|
|
836
|
+
res.end(JSON.stringify({ files }));
|
|
837
|
+
},
|
|
838
|
+
"/typing/start": async (req, res, body) => {
|
|
839
|
+
if (!requireOwnerToken(req, res)) return;
|
|
840
|
+
backend.startTyping(body.channelId);
|
|
841
|
+
res.writeHead(200);
|
|
842
|
+
res.end(JSON.stringify({ ok: true }));
|
|
843
|
+
},
|
|
844
|
+
"/typing/stop": async (req, res, body) => {
|
|
845
|
+
if (!requireOwnerToken(req, res)) return;
|
|
846
|
+
backend.stopTyping(body.channelId);
|
|
847
|
+
res.writeHead(200);
|
|
848
|
+
res.end(JSON.stringify({ ok: true }));
|
|
849
|
+
},
|
|
850
|
+
"/inject": async (req, res, body) => {
|
|
851
|
+
// Require owner-token header to prevent unauthenticated local injection.
|
|
852
|
+
if (!requireOwnerToken(req, res)) return;
|
|
853
|
+
const content = body.content;
|
|
854
|
+
if (!content) {
|
|
855
|
+
res.writeHead(400);
|
|
856
|
+
res.end(JSON.stringify({ error: "content required" }));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const source = body.source || "mixdog-agent";
|
|
860
|
+
const injMeta = { user: source, user_id: "system", ts: (/* @__PURE__ */ new Date()).toISOString() };
|
|
861
|
+
if (body.instruction) injMeta.instruction = body.instruction;
|
|
862
|
+
if (body.type) injMeta.type = body.type;
|
|
863
|
+
sendNotifyToParent("notifications/claude/channel", { content, meta: injMeta });
|
|
864
|
+
res.writeHead(200);
|
|
865
|
+
res.end(JSON.stringify({ ok: true }));
|
|
866
|
+
},
|
|
867
|
+
"/trigger-schedule": async (req, res, body) => {
|
|
868
|
+
// Native fallback for `mcp__trigger_schedule` so out-of-band
|
|
869
|
+
// verification works when the MCP stdio bridge is down (Claude Code
|
|
870
|
+
// disconnected, supervisor restart pending, etc.). Same authz as
|
|
871
|
+
// /inject — x-owner-token must equal INSTANCE_ID.
|
|
872
|
+
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
|
|
873
|
+
if (!requireOwnerToken(req, res)) return;
|
|
874
|
+
const triggerName = body.name;
|
|
875
|
+
if (!triggerName) {
|
|
876
|
+
res.writeHead(400);
|
|
877
|
+
res.end(JSON.stringify({ error: "name required" }));
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
try {
|
|
881
|
+
const r = await scheduler.triggerManual(triggerName);
|
|
882
|
+
res.writeHead(200);
|
|
883
|
+
res.end(JSON.stringify({ ok: true, result: r ?? null }));
|
|
884
|
+
} catch (e) {
|
|
885
|
+
res.writeHead(500);
|
|
886
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }));
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
"/schedule-status": async (req, res) => {
|
|
890
|
+
// Owner-side schedule_status so standby/proxy sessions read the LIVE
|
|
891
|
+
// scheduler instead of their own stale local state. Mirrors the MCP
|
|
892
|
+
// schedule_status handler's formatting (kept byte-identical via the
|
|
893
|
+
// shared scheduleStatusResult() helper).
|
|
894
|
+
if (!requireOwnerToken(req, res)) return;
|
|
895
|
+
try {
|
|
896
|
+
const r = scheduleStatusResult();
|
|
897
|
+
res.writeHead(200);
|
|
898
|
+
res.end(JSON.stringify({ ok: true, result: r }));
|
|
899
|
+
} catch (e) {
|
|
900
|
+
res.writeHead(500);
|
|
901
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }));
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
"/schedule-control": async (req, res, body) => {
|
|
905
|
+
// Owner-side schedule_control so standby/proxy sessions mutate the LIVE
|
|
906
|
+
// scheduler (defer/skip_today) instead of their own stale local state.
|
|
907
|
+
// Validation lives here because the proxy side's scheduler.nonInteractive/
|
|
908
|
+
// interactive lists are not authoritative.
|
|
909
|
+
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
|
|
910
|
+
if (!requireOwnerToken(req, res)) return;
|
|
911
|
+
try {
|
|
912
|
+
const r = scheduleControlResult(body || {});
|
|
913
|
+
res.writeHead(200);
|
|
914
|
+
res.end(JSON.stringify({ ok: true, result: r }));
|
|
915
|
+
} catch (e) {
|
|
916
|
+
res.writeHead(500);
|
|
917
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }));
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
"/bridge": async (req, res, body) => {
|
|
921
|
+
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
|
|
922
|
+
if (!requireOwnerToken(req, res)) return;
|
|
923
|
+
const bridgeFile = body.file;
|
|
924
|
+
const bridgePrompt = body.prompt;
|
|
925
|
+
const bridgeRef = body.ref;
|
|
926
|
+
const bridgeRole = body.role;
|
|
927
|
+
const bridgeContext = body.context;
|
|
928
|
+
let bridgePromptFinal = bridgePrompt;
|
|
929
|
+
if (!bridgePromptFinal && bridgeFile) {
|
|
930
|
+
try { bridgePromptFinal = fs.readFileSync(bridgeFile, "utf-8").trim(); } catch (e) {
|
|
931
|
+
res.writeHead(400); res.end(JSON.stringify({ error: `Cannot read file: ${e.message}` })); return;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (!bridgePromptFinal && !bridgeRef) { res.writeHead(400); res.end(JSON.stringify({ error: "prompt, file, or ref required" })); return; }
|
|
935
|
+
try {
|
|
936
|
+
const agentMod = await import(pathToFileURL(path.join(path.dirname(import.meta.url.replace("file:///", "").replace(/\//g, path.sep)), "..", "agent", "index.mjs")).href);
|
|
937
|
+
if (agentMod.init) await agentMod.init();
|
|
938
|
+
const toolArgs = {};
|
|
939
|
+
if (bridgePromptFinal) toolArgs.prompt = bridgePromptFinal;
|
|
940
|
+
if (bridgeRef) toolArgs.ref = bridgeRef;
|
|
941
|
+
if (bridgeRole) toolArgs.role = bridgeRole;
|
|
942
|
+
if (bridgeContext) toolArgs.context = bridgeContext;
|
|
943
|
+
const notifyFn = (text, extraMeta) => {
|
|
944
|
+
sendNotifyToParent("notifications/claude/channel", {
|
|
945
|
+
content: text,
|
|
946
|
+
meta: {
|
|
947
|
+
user: "mixdog-agent",
|
|
948
|
+
user_id: "system",
|
|
949
|
+
ts: new Date().toISOString(),
|
|
950
|
+
...(extraMeta || {})
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
};
|
|
954
|
+
const BRIDGE_HTTP_TIMEOUT_MS = 10 * 60 * 1000; // 10 min
|
|
955
|
+
const bridgeAbort = new AbortController();
|
|
956
|
+
const bridgeTimer = setTimeout(() => bridgeAbort.abort(new Error("bridge HTTP timeout")), BRIDGE_HTTP_TIMEOUT_MS);
|
|
957
|
+
const onReqClose = () => bridgeAbort.abort(new Error("client disconnected"));
|
|
958
|
+
req.on("close", onReqClose);
|
|
959
|
+
let result;
|
|
960
|
+
try {
|
|
961
|
+
result = await Promise.race([
|
|
962
|
+
agentMod.handleToolCall("bridge", toolArgs, { notifyFn, requestSignal: bridgeAbort.signal }),
|
|
963
|
+
new Promise((_, reject) => bridgeAbort.signal.addEventListener("abort", () => reject(bridgeAbort.signal.reason), { once: true })),
|
|
964
|
+
]);
|
|
965
|
+
} finally {
|
|
966
|
+
clearTimeout(bridgeTimer);
|
|
967
|
+
req.removeListener("close", onReqClose);
|
|
968
|
+
}
|
|
969
|
+
res.writeHead(200);
|
|
970
|
+
res.end(JSON.stringify(result));
|
|
971
|
+
} catch (e) {
|
|
972
|
+
res.writeHead(500); res.end(JSON.stringify({ error: e.message })); return;
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
"/bridge/activate": async (req, res, body) => {
|
|
976
|
+
if (!requireOwnerToken(req, res)) return;
|
|
977
|
+
const active = Boolean(body.active);
|
|
978
|
+
const wasActive = channelBridgeActive;
|
|
979
|
+
channelBridgeActive = active;
|
|
980
|
+
writeBridgeState(active);
|
|
981
|
+
if (!active && wasActive) {
|
|
982
|
+
// Mirror the MCP activate_channel_bridge deactivate path: tear down
|
|
983
|
+
// owner-side runtime (Discord/scheduler/webhook/event/owner-HTTP/
|
|
984
|
+
// heartbeat) so a deactivated bridge doesn't keep running and this
|
|
985
|
+
// owner can't later proxyMode against its own port.
|
|
986
|
+
stopServerTyping();
|
|
987
|
+
try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
|
|
988
|
+
process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
res.writeHead(200);
|
|
992
|
+
res.end(JSON.stringify({ ok: true, active: channelBridgeActive }));
|
|
993
|
+
},
|
|
994
|
+
"/mcp": async (req, res, body) => {
|
|
995
|
+
if (req.method === "POST") {
|
|
996
|
+
// Require owner-token header to prevent unauthenticated local MCP dispatch.
|
|
997
|
+
if (!requireOwnerToken(req, res)) return;
|
|
998
|
+
const httpMcp = createHttpMcpServer();
|
|
999
|
+
const httpTransport = new StreamableHTTPServerTransport({
|
|
1000
|
+
sessionIdGenerator: void 0,
|
|
1001
|
+
enableJsonResponse: true
|
|
1002
|
+
});
|
|
1003
|
+
res.on("close", () => {
|
|
1004
|
+
httpTransport.close();
|
|
1005
|
+
void httpMcp.close();
|
|
1006
|
+
});
|
|
1007
|
+
await httpMcp.connect(httpTransport);
|
|
1008
|
+
await httpTransport.handleRequest(req, res, body);
|
|
1009
|
+
} else {
|
|
1010
|
+
res.writeHead(405);
|
|
1011
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
1012
|
+
}
|
|
1013
|
+
},
|
|
1014
|
+
"/recap/reset": async (req, res /*, body*/) => {
|
|
1015
|
+
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ error: "POST required" })); return; }
|
|
1016
|
+
if (!requireOwnerToken(req, res)) return;
|
|
1017
|
+
// Called by hooks/session-start.cjs on `/clear` (matcher startup|clear).
|
|
1018
|
+
// The session-start hook runs in a separate cjs process with no IPC
|
|
1019
|
+
// handle to this forked channels child, so it can't drop recap
|
|
1020
|
+
// status directly. Reset to an `empty` baseline so the statusline
|
|
1021
|
+
// doesn't carry the prior session's `injected`/`error` recap badge
|
|
1022
|
+
// into the cleared session.
|
|
1023
|
+
const now = Date.now();
|
|
1024
|
+
recapState.state = 'empty';
|
|
1025
|
+
recapState.running = false;
|
|
1026
|
+
recapState.startedAt = null;
|
|
1027
|
+
recapState.lastCompletedAt = now;
|
|
1028
|
+
recapState.updatedAt = now;
|
|
1029
|
+
recapState.errorMessage = null;
|
|
1030
|
+
sendRecapStateToParent();
|
|
1031
|
+
res.writeHead(200);
|
|
1032
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1033
|
+
},
|
|
1034
|
+
"/cycle1": async (req, res, body) => {
|
|
1035
|
+
if (req.method !== "POST") { res.writeHead(405); res.end(JSON.stringify({ ok: false, reason: "method-not-allowed", error: "POST required" })); return; }
|
|
1036
|
+
if (!requireOwnerToken(req, res)) return;
|
|
1037
|
+
const tCycleEntry = Date.now();
|
|
1038
|
+
const timeoutMs = Number(body?.timeout_ms) > 0 ? Math.min(60000, Number(body.timeout_ms)) : 15000;
|
|
1039
|
+
// IPC timer must outlive the worker-side deadline so a graceful
|
|
1040
|
+
// {timedOutWaiting:true} resolve has time to traverse IPC before
|
|
1041
|
+
// the channel timer rejects with memory-timeout. Without the
|
|
1042
|
+
// buffer, the worker resolves at deadline-0ms and the local
|
|
1043
|
+
// setTimeout fires at deadline+0ms in the same tick — race won by
|
|
1044
|
+
// whichever scheduler ordering wins, turning intended 200 flags
|
|
1045
|
+
// into 503 responses.
|
|
1046
|
+
const ipcTimeoutMs = timeoutMs + 2000;
|
|
1047
|
+
try {
|
|
1048
|
+
// Carry the caller deadline through to the memory worker so a
|
|
1049
|
+
// pending cycle1 in-flight is awaited under the same budget.
|
|
1050
|
+
// Without this, when the previous cycle1's LLM call lives past
|
|
1051
|
+
// 60s, every later SessionStart slot stacks another full 60s
|
|
1052
|
+
// wait behind the same zombie promise.
|
|
1053
|
+
const result = await callMemoryAction(
|
|
1054
|
+
'cycle1',
|
|
1055
|
+
{ ...(body?.args || {}), _callerDeadlineMs: timeoutMs },
|
|
1056
|
+
ipcTimeoutMs,
|
|
1057
|
+
);
|
|
1058
|
+
// A successful IPC round-trip can still carry a nested MCP error
|
|
1059
|
+
// envelope ({ isError: true }) when the memory worker served the
|
|
1060
|
+
// call but the action failed — e.g. a promoted fork-proxy whose
|
|
1061
|
+
// local `db` is still null. Surfacing that as outer { ok: true }
|
|
1062
|
+
// masks the failure and makes session-start log a phantom success.
|
|
1063
|
+
// Return a transient 503 so the hook's 503-retry path (which gates
|
|
1064
|
+
// only on statusCode===503) re-polls instead of trusting it.
|
|
1065
|
+
if (result && typeof result === 'object' && result.isError === true) {
|
|
1066
|
+
const nestedText = Array.isArray(result.content)
|
|
1067
|
+
? result.content.map(c => (c && c.text) || '').join(' ').trim()
|
|
1068
|
+
: '';
|
|
1069
|
+
try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry} nestedError=1\n`); } catch {}
|
|
1070
|
+
res.writeHead(503);
|
|
1071
|
+
res.end(JSON.stringify({ ok: false, reason: 'memory-not-ready', error: nestedText || 'memory cycle1 returned isError' }));
|
|
1072
|
+
} else {
|
|
1073
|
+
try { process.stderr.write(`[cycle1-time] route ms=${Date.now() - tCycleEntry}\n`); } catch {}
|
|
1074
|
+
res.writeHead(200);
|
|
1075
|
+
res.end(JSON.stringify({ ok: true, result }));
|
|
1076
|
+
}
|
|
1077
|
+
} catch (e) {
|
|
1078
|
+
// Classify transient/unavailable failures so the session-start hook
|
|
1079
|
+
// (and other 503-retry callers) can distinguish boot-time races from
|
|
1080
|
+
// IPC-layer faults and timeouts. All four reasons stay on 503 to
|
|
1081
|
+
// preserve the hook retry contract (hooks/session-start.cjs:516
|
|
1082
|
+
// gates only on statusCode===503); only the `reason` label changes.
|
|
1083
|
+
//
|
|
1084
|
+
// Source → reason mapping (upstream messages from server.mjs
|
|
1085
|
+
// callWorker at 457-490 and local callMemoryAction at 169-187):
|
|
1086
|
+
// server.mjs:470 "not ready (still booting)" → memory-not-ready
|
|
1087
|
+
// server.mjs:464/467 "not available (...)" → worker-unavailable
|
|
1088
|
+
// server.mjs:435 "exited unexpectedly" → worker-unavailable
|
|
1089
|
+
// local "not a worker process" guard → worker-unavailable
|
|
1090
|
+
// server.mjs:483 "IPC channel full or closed" → ipc-error
|
|
1091
|
+
// server.mjs:488 "send failed: ..." → ipc-error
|
|
1092
|
+
// server.mjs:475 "worker ... call ... timed out" → memory-timeout
|
|
1093
|
+
// local "memory_call <action> timed out after Nms" → memory-timeout
|
|
1094
|
+
const msg = e?.message || String(e);
|
|
1095
|
+
let reason;
|
|
1096
|
+
if (/worker memory not ready/i.test(msg)) {
|
|
1097
|
+
reason = 'memory-not-ready';
|
|
1098
|
+
} else if (/worker memory (IPC channel|send failed)/i.test(msg)) {
|
|
1099
|
+
reason = 'ipc-error';
|
|
1100
|
+
} else if (/timed out/i.test(msg)) {
|
|
1101
|
+
reason = 'memory-timeout';
|
|
1102
|
+
} else if (msg.includes('restart cap exceeded') || msg.includes('degraded')) {
|
|
1103
|
+
// Permanent degraded state: restart cap hit or boot-time init failure.
|
|
1104
|
+
// Use a distinct reason so callers can fail-fast without retrying.
|
|
1105
|
+
// NOTE: checked before 'not available' — the error message
|
|
1106
|
+
// "worker memory not available (restart cap exceeded)" contains both
|
|
1107
|
+
// substrings and must land in 'memory-degraded', not 'worker-unavailable'.
|
|
1108
|
+
reason = 'memory-degraded';
|
|
1109
|
+
} else if (msg.includes('worker memory not available') || msg.includes('worker memory exited unexpectedly') || msg.includes('not a worker process')) {
|
|
1110
|
+
reason = 'worker-unavailable';
|
|
1111
|
+
}
|
|
1112
|
+
const transient = Boolean(reason);
|
|
1113
|
+
res.writeHead(transient ? 503 : 500);
|
|
1114
|
+
res.end(JSON.stringify({ ok: false, reason, error: msg }));
|
|
1115
|
+
}
|
|
1116
|
+
},
|
|
1117
|
+
"/rebind": async (req, res, body) => {
|
|
1118
|
+
if (!requireOwnerToken(req, res)) return;
|
|
1119
|
+
const channelId = statusState.read().channelId;
|
|
1120
|
+
if (!channelId) {
|
|
1121
|
+
res.writeHead(200);
|
|
1122
|
+
res.end(JSON.stringify({ rebound: false, reason: "no channelId" }));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
const previousPath = getPersistedTranscriptPath();
|
|
1126
|
+
const explicitTranscriptPath = typeof body?.transcriptPath === "string" ? body.transcriptPath.trim() : "";
|
|
1127
|
+
const bound = await rebindTranscriptContext(channelId, {
|
|
1128
|
+
previousPath,
|
|
1129
|
+
persistStatus: true,
|
|
1130
|
+
catchUp: true,
|
|
1131
|
+
...(explicitTranscriptPath ? { transcriptPath: explicitTranscriptPath } : {})
|
|
1132
|
+
});
|
|
1133
|
+
const reboundChanged = Boolean(bound) && bound !== previousPath;
|
|
1134
|
+
res.writeHead(200);
|
|
1135
|
+
res.end(JSON.stringify({ rebound: reboundChanged, path: bound || null }));
|
|
1136
|
+
},
|
|
1137
|
+
};
|
|
1138
|
+
const BACKEND_DEPENDENT_PATHS = new Set([
|
|
1139
|
+
"/send",
|
|
1140
|
+
"/react",
|
|
1141
|
+
"/edit",
|
|
1142
|
+
"/fetch",
|
|
1143
|
+
"/download",
|
|
1144
|
+
"/typing/start",
|
|
1145
|
+
"/typing/stop",
|
|
1146
|
+
"/mcp"
|
|
1147
|
+
]);
|
|
1148
|
+
async function ownerRequestHandler(req, res) {
|
|
1149
|
+
res.setHeader("Content-Type", "application/json");
|
|
1150
|
+
let body = {};
|
|
1151
|
+
if (req.method === "POST") {
|
|
1152
|
+
const chunks = [];
|
|
1153
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
1154
|
+
try {
|
|
1155
|
+
const rawBody = Buffer.concat(chunks).toString();
|
|
1156
|
+
body = rawBody.trim() ? JSON.parse(rawBody) : {};
|
|
1157
|
+
} catch {
|
|
1158
|
+
res.writeHead(400);
|
|
1159
|
+
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1`);
|
|
1165
|
+
if (BACKEND_DEPENDENT_PATHS.has(url.pathname) && !bridgeRuntimeConnected) {
|
|
1166
|
+
res.writeHead(503);
|
|
1167
|
+
res.end(JSON.stringify({ ok: false, reason: "backend-not-ready" }));
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const handler = OWNER_ROUTES[url.pathname];
|
|
1171
|
+
if (handler) {
|
|
1172
|
+
await handler(req, res, body, url);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
res.writeHead(404);
|
|
1176
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
1177
|
+
} catch (err) {
|
|
1178
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1179
|
+
res.writeHead(500);
|
|
1180
|
+
res.end(JSON.stringify({ error: msg }));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
async function startOwnerHttpServer() {
|
|
1184
|
+
if (ownerHttpServer) return ownerHttpServer.address().port;
|
|
1185
|
+
// Generate a fresh cryptographic owner-secret BEFORE the listener accepts
|
|
1186
|
+
// traffic so requireOwnerToken always has a real secret to compare. Stored
|
|
1187
|
+
// in a 0o600 sidecar file (owner-secret-<pid>.json) under RUNTIME_ROOT so
|
|
1188
|
+
// only the same UID + same active owner pid can read it back. /ping does
|
|
1189
|
+
// NOT return this value — only the public instanceId.
|
|
1190
|
+
if (!OWNER_SECRET) {
|
|
1191
|
+
OWNER_SECRET = crypto.randomBytes(32).toString("hex");
|
|
1192
|
+
try { publishOwnerSecret(OWNER_SECRET); }
|
|
1193
|
+
catch (e) {
|
|
1194
|
+
process.stderr.write(`mixdog: failed to publish owner secret: ${e?.message || e}\n`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
const server = http.createServer(ownerRequestHandler);
|
|
1198
|
+
for (let port = PROXY_PORT_MIN; port <= PROXY_PORT_MAX; port++) {
|
|
1199
|
+
if (await tryListenPort(server, port)) {
|
|
1200
|
+
ownerHttpServer = server;
|
|
1201
|
+
process.stderr.write(`mixdog: owner HTTP server listening on 127.0.0.1:${port}
|
|
1202
|
+
`);
|
|
1203
|
+
return port;
|
|
1204
|
+
}
|
|
1205
|
+
server.removeAllListeners("error");
|
|
1206
|
+
}
|
|
1207
|
+
throw new Error(`no available port in range ${PROXY_PORT_MIN}-${PROXY_PORT_MAX}`);
|
|
1208
|
+
}
|
|
1209
|
+
function stopOwnerHttpServer() {
|
|
1210
|
+
if (!ownerHttpServer) return;
|
|
1211
|
+
ownerHttpServer.close();
|
|
1212
|
+
ownerHttpServer = null;
|
|
1213
|
+
// Drop the per-process secret + sidecar file. A future startOwnerHttpServer()
|
|
1214
|
+
// call regenerates a fresh one, so a stale standby that read the old secret
|
|
1215
|
+
// before the restart cannot authenticate against the new owner.
|
|
1216
|
+
OWNER_SECRET = "";
|
|
1217
|
+
try { clearOwnerSecret(); } catch {}
|
|
1218
|
+
globalThis.__mixdogBeaconRealHandler = null;
|
|
1219
|
+
globalThis.__mixdogBeacon = null;
|
|
1220
|
+
}
|
|
1221
|
+
function logOwnership(note) {
|
|
1222
|
+
if (lastOwnershipNote === note) return;
|
|
1223
|
+
lastOwnershipNote = note;
|
|
1224
|
+
process.stderr.write(`[ownership] ${note}
|
|
1225
|
+
`);
|
|
1226
|
+
}
|
|
1227
|
+
function currentOwnerState() {
|
|
1228
|
+
const active = readActiveInstance();
|
|
1229
|
+
return {
|
|
1230
|
+
active,
|
|
1231
|
+
owned: active?.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
function getBridgeOwnershipSnapshot() {
|
|
1235
|
+
return currentOwnerState();
|
|
1236
|
+
}
|
|
1237
|
+
// MIXDOG_PIN_OWNER=1 in the owning process writes `pinned:true` into
|
|
1238
|
+
// active-instance.json. Pinned owners ignore the 10 s stale window — they
|
|
1239
|
+
// only relinquish ownership when their OS process actually dies. Set per
|
|
1240
|
+
// session (env var on the Claude Code shell) to lock that Lead as the
|
|
1241
|
+
// schedule/webhook receiver across multi-session use.
|
|
1242
|
+
function canStealOwnership(active) {
|
|
1243
|
+
if (!active) return true;
|
|
1244
|
+
if (active.instanceId === INSTANCE_ID || getActiveOwnerPid(active) === TERMINAL_LEAD_PID) return true;
|
|
1245
|
+
if (active.pinned) {
|
|
1246
|
+
const pinnedPid = getActiveOwnerPid(active);
|
|
1247
|
+
if (!pinnedPid) return true;
|
|
1248
|
+
try { process.kill(pinnedPid, 0); return false; }
|
|
1249
|
+
catch { return true; }
|
|
1250
|
+
}
|
|
1251
|
+
if (Date.now() - active.updatedAt > ACTIVE_OWNER_STALE_MS) return true;
|
|
1252
|
+
const ownerPid = getActiveOwnerPid(active);
|
|
1253
|
+
try {
|
|
1254
|
+
if (!ownerPid) throw new Error("missing owner pid");
|
|
1255
|
+
process.kill(ownerPid, 0);
|
|
1256
|
+
return false;
|
|
1257
|
+
} catch {
|
|
1258
|
+
return true;
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
function claimBridgeOwnership(reason) {
|
|
1262
|
+
refreshActiveInstance(INSTANCE_ID);
|
|
1263
|
+
logOwnership(`claimed owner (${reason})`);
|
|
1264
|
+
}
|
|
1265
|
+
function noteStartupHandoff(previous) {
|
|
1266
|
+
if (!previous) return;
|
|
1267
|
+
if (previous.instanceId === INSTANCE_ID) return;
|
|
1268
|
+
if (getActiveOwnerPid(previous) === TERMINAL_LEAD_PID) return;
|
|
1269
|
+
logOwnership(`startup handoff from ${previous.instanceId}`);
|
|
1270
|
+
}
|
|
1271
|
+
async function bindPersistedTranscriptIfAny() {
|
|
1272
|
+
// Resolve channelId first from persisted status; fall back to the most
|
|
1273
|
+
// recent status-*.json snapshot, then to the configured main channel when
|
|
1274
|
+
// the bridge is active. No exists-gate here — once we have a channelId,
|
|
1275
|
+
// hand off to rebindTranscriptContext(), which owns the 30-attempt retry
|
|
1276
|
+
// for transcripts that are not yet on disk at boot/activate time.
|
|
1277
|
+
let currentStatus = statusState.read();
|
|
1278
|
+
if (!currentStatus.channelId) {
|
|
1279
|
+
try {
|
|
1280
|
+
const files = fs.readdirSync(RUNTIME_ROOT).filter((f) => f.startsWith("status-") && f.endsWith(".json")).map((f) => {
|
|
1281
|
+
const full = path.join(RUNTIME_ROOT, f);
|
|
1282
|
+
return { path: full, mtime: fs.statSync(full).mtimeMs };
|
|
1283
|
+
}).sort((a, b) => b.mtime - a.mtime);
|
|
1284
|
+
for (const { path: fp } of files) {
|
|
1285
|
+
try {
|
|
1286
|
+
const data = JSON.parse(fs.readFileSync(fp, "utf8"));
|
|
1287
|
+
if (data.channelId) {
|
|
1288
|
+
statusState.update((state) => {
|
|
1289
|
+
Object.assign(state, data);
|
|
1290
|
+
});
|
|
1291
|
+
currentStatus = statusState.read();
|
|
1292
|
+
process.stderr.write(`mixdog: restored status from ${fp}
|
|
1293
|
+
`);
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
} catch {
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
} catch {
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (!currentStatus.channelId && channelBridgeActive) {
|
|
1303
|
+
const chCfg = config.channelsConfig;
|
|
1304
|
+
const mainLabel = config.mainChannel ?? "main";
|
|
1305
|
+
const mainEntry = chCfg?.[mainLabel];
|
|
1306
|
+
const mainId = mainEntry?.channelId;
|
|
1307
|
+
if (mainId) {
|
|
1308
|
+
statusState.update((state) => {
|
|
1309
|
+
state.channelId = mainId;
|
|
1310
|
+
});
|
|
1311
|
+
currentStatus = statusState.read();
|
|
1312
|
+
process.stderr.write(`mixdog: auto-bound to main channel ${mainId}
|
|
1313
|
+
`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
if (!currentStatus.channelId) return;
|
|
1317
|
+
const bound = await rebindTranscriptContext(currentStatus.channelId, {
|
|
1318
|
+
previousPath: getPersistedTranscriptPath(),
|
|
1319
|
+
persistStatus: true,
|
|
1320
|
+
catchUpFromPersisted: true
|
|
1321
|
+
});
|
|
1322
|
+
if (bound) {
|
|
1323
|
+
process.stderr.write(`mixdog: initial transcript bind: ${bound}
|
|
1324
|
+
`);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
function shouldStartEventPipelineRuntime() {
|
|
1328
|
+
return config.webhook?.enabled === true || (Array.isArray(config.events?.rules) && config.events.rules.length > 0);
|
|
1329
|
+
}
|
|
1330
|
+
function ensureEventPipelineRuntime() {
|
|
1331
|
+
if (!eventPipeline) {
|
|
1332
|
+
eventPipeline = new EventPipeline(config.events, config.channelsConfig);
|
|
1333
|
+
wireEventQueueHandlers(eventPipeline.getQueue());
|
|
1334
|
+
}
|
|
1335
|
+
return eventPipeline;
|
|
1336
|
+
}
|
|
1337
|
+
function ensureWebhookServerRuntime() {
|
|
1338
|
+
if (!webhookServer) {
|
|
1339
|
+
// Pass top-level normalized config so the webhook gate reads the new
|
|
1340
|
+
// top-level `quiet` subtree (and `webhook.respectQuiet`) introduced in
|
|
1341
|
+
// 0.1.62. See applyDefaults() in lib/config.mjs.
|
|
1342
|
+
webhookServer = new WebhookServer(config.webhook, { quiet: config.quiet ?? null });
|
|
1343
|
+
}
|
|
1344
|
+
wireWebhookHandlers();
|
|
1345
|
+
return webhookServer;
|
|
1346
|
+
}
|
|
1347
|
+
function stopWebhookAndEventRuntime() {
|
|
1348
|
+
if (webhookServer) {
|
|
1349
|
+
webhookServer.stop();
|
|
1350
|
+
webhookServer = null;
|
|
1351
|
+
}
|
|
1352
|
+
if (eventPipeline) {
|
|
1353
|
+
eventPipeline.stop();
|
|
1354
|
+
eventPipeline = null;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
|
|
1358
|
+
if (shouldStartEventPipelineRuntime()) {
|
|
1359
|
+
const pipeline = ensureEventPipelineRuntime();
|
|
1360
|
+
if (reload) {
|
|
1361
|
+
pipeline.reloadConfig(config.events, config.channelsConfig);
|
|
1362
|
+
wireEventQueueHandlers(pipeline.getQueue());
|
|
1363
|
+
}
|
|
1364
|
+
pipeline.start();
|
|
1365
|
+
} else if (eventPipeline) {
|
|
1366
|
+
eventPipeline.stop();
|
|
1367
|
+
eventPipeline = null;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (config.webhook?.enabled === true) {
|
|
1371
|
+
const server = ensureWebhookServerRuntime();
|
|
1372
|
+
if (reload) {
|
|
1373
|
+
// server.reloadConfig is async (it awaits the current server's
|
|
1374
|
+
// close() before re-listening). Chain start() onto its resolution
|
|
1375
|
+
// so we don't race the bound port — calling start() synchronously
|
|
1376
|
+
// here would re-listen before close() finishes and surface
|
|
1377
|
+
// EADDRINUSE on the same port.
|
|
1378
|
+
server.reloadConfig(config.webhook, { quiet: config.quiet ?? null }, {
|
|
1379
|
+
autoStart: false
|
|
1380
|
+
}).then(() => {
|
|
1381
|
+
// A stopWebhookAndEventRuntime() / deactivate landing during the async
|
|
1382
|
+
// close()+reload window nulls out webhookServer (and webhook.enabled may
|
|
1383
|
+
// have flipped off). Without this guard the resolved continuation would
|
|
1384
|
+
// re-listen and resurrect an orphan listener that no teardown tracks.
|
|
1385
|
+
if (webhookServer !== server || config.webhook?.enabled !== true) {
|
|
1386
|
+
try { server.stop(); } catch {}
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
wireWebhookHandlers();
|
|
1390
|
+
server.start();
|
|
1391
|
+
}).catch((err) => {
|
|
1392
|
+
process.stderr.write(`mixdog channels: webhook reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
1393
|
+
});
|
|
1394
|
+
} else {
|
|
1395
|
+
server.start();
|
|
1396
|
+
}
|
|
1397
|
+
} else if (webhookServer) {
|
|
1398
|
+
webhookServer.stop();
|
|
1399
|
+
webhookServer = null;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
async function startOwnedRuntime(options = {}) {
|
|
1403
|
+
if (bridgeRuntimeConnected) return;
|
|
1404
|
+
if (bridgeRuntimeStarting) return;
|
|
1405
|
+
if (!channelBridgeActive) return;
|
|
1406
|
+
bridgeRuntimeStarting = true;
|
|
1407
|
+
_ownedRuntimeStopRequested = false;
|
|
1408
|
+
// Advertise active-instance.json BEFORE backend connect so peers can
|
|
1409
|
+
// discover this owner (httpPort) immediately. backendReady=false marks
|
|
1410
|
+
// the partial state until backend.connect() succeeds.
|
|
1411
|
+
let httpPort;
|
|
1412
|
+
try {
|
|
1413
|
+
httpPort = await startOwnerHttpServer();
|
|
1414
|
+
} catch (e) {
|
|
1415
|
+
process.stderr.write(`mixdog: HTTP server start failed (non-fatal): ${e instanceof Error ? e.message : String(e)}
|
|
1416
|
+
`);
|
|
1417
|
+
}
|
|
1418
|
+
refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: false });
|
|
1419
|
+
startOwnerHeartbeat();
|
|
1420
|
+
// Re-check after each post-connect await so a stopOwnedRuntime() landing
|
|
1421
|
+
// mid-start cannot be overridden by the resuming start (scheduler/snapshot/
|
|
1422
|
+
// webhook/binding launches below would revive owner state after stop).
|
|
1423
|
+
// Idempotent: stop's sync teardown already ran; re-running disconnect +
|
|
1424
|
+
// teardown is safe and covers both the pre-connected window (stop could
|
|
1425
|
+
// not disconnect an in-flight backend) and the post-connected window
|
|
1426
|
+
// (stop did disconnect; redo to be defensive).
|
|
1427
|
+
const bailIfStopRequested = async () => {
|
|
1428
|
+
if (!_ownedRuntimeStopRequested) return false;
|
|
1429
|
+
try { await backend.disconnect(); } catch {}
|
|
1430
|
+
try { stopOwnerHttpServer(); } catch {}
|
|
1431
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
1432
|
+
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
1433
|
+
try { clearActiveInstance(INSTANCE_ID); } catch {}
|
|
1434
|
+
bridgeRuntimeConnected = false;
|
|
1435
|
+
_ownedRuntimeStopRequested = false;
|
|
1436
|
+
return true;
|
|
1437
|
+
};
|
|
1438
|
+
// Await backend.connect() so callers (and bindingReady) only resolve after
|
|
1439
|
+
// the Discord binding is real. Previously this was fire-and-forget and
|
|
1440
|
+
// refreshBridgeOwnership returned immediately, letting bindingReady fire
|
|
1441
|
+
// before backend listeners were attached.
|
|
1442
|
+
try {
|
|
1443
|
+
await backend.connect();
|
|
1444
|
+
if (await bailIfStopRequested()) return;
|
|
1445
|
+
bridgeRuntimeConnected = true;
|
|
1446
|
+
refreshActiveInstance(INSTANCE_ID, { ...httpPort ? { httpPort } : {}, backendReady: true });
|
|
1447
|
+
proxyMode = false;
|
|
1448
|
+
// initProviders must complete before scheduler.start() — otherwise the
|
|
1449
|
+
// scheduler's first fire can land before the registry is populated and
|
|
1450
|
+
// return `Provider "<name>" not found or not enabled`. The previous
|
|
1451
|
+
// fire-and-forget call let scheduler.start() race ahead of init.
|
|
1452
|
+
try {
|
|
1453
|
+
const agentCfg = loadAgentConfig();
|
|
1454
|
+
await initProviders(agentCfg.providers || {});
|
|
1455
|
+
} catch (e) {
|
|
1456
|
+
process.stderr.write(`mixdog: initProviders failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1457
|
+
}
|
|
1458
|
+
if (await bailIfStopRequested()) return;
|
|
1459
|
+
scheduler.start();
|
|
1460
|
+
startSnapshotWriter(scheduler);
|
|
1461
|
+
syncOwnedWebhookAndEventRuntime();
|
|
1462
|
+
if (options.restoreBinding !== false) bindPersistedTranscriptIfAny().catch((e) => {
|
|
1463
|
+
process.stderr.write(`mixdog: bindPersistedTranscriptIfAny failed (non-fatal): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1464
|
+
});
|
|
1465
|
+
process.stderr.write(`mixdog: running with ${backend.name} backend\n`);
|
|
1466
|
+
logOwnership(`active owner lead=${TERMINAL_LEAD_PID} pid=${process.pid}`);
|
|
1467
|
+
} catch (e) {
|
|
1468
|
+
process.stderr.write(`mixdog: backend connect failed (non-fatal, cycle1/MCP still up): ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1469
|
+
// Roll back partial owner-side state advertised before connect() ran:
|
|
1470
|
+
// HTTP server, heartbeat, and active-instance entry. Without this cleanup
|
|
1471
|
+
// stopOwnedRuntime() at shutdown will short-circuit on !bridgeRuntimeConnected
|
|
1472
|
+
// and leave the port bound + active-instance.json stale.
|
|
1473
|
+
try { stopOwnerHttpServer(); } catch {}
|
|
1474
|
+
try { stopOwnerHeartbeat(); } catch {}
|
|
1475
|
+
try { releaseOwnedChannelLocks(INSTANCE_ID); } catch {}
|
|
1476
|
+
try { clearActiveInstance(INSTANCE_ID); } catch {}
|
|
1477
|
+
} finally {
|
|
1478
|
+
bridgeRuntimeStarting = false;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
async function stopOwnedRuntime(reason) {
|
|
1482
|
+
// startOwnedRuntime() advertises owner HTTP/heartbeat/active-instance and
|
|
1483
|
+
// claims channel locks BEFORE awaiting backend.connect(). If shutdown lands
|
|
1484
|
+
// during that window (bridgeRuntimeStarting=true, bridgeRuntimeConnected
|
|
1485
|
+
// still false) we still need to tear that partial state down — otherwise
|
|
1486
|
+
// the port stays bound and active-instance.json stays stale.
|
|
1487
|
+
if (!bridgeRuntimeConnected && !bridgeRuntimeStarting) return;
|
|
1488
|
+
// If a start is in flight (bridgeRuntimeStarting=true), signal the in-flight
|
|
1489
|
+
// startOwnedRuntime() to abort right after its backend.connect() resolves.
|
|
1490
|
+
// Without this the in-flight start re-marks connected and re-launches
|
|
1491
|
+
// scheduler/webhook/heartbeat after we tear them down here.
|
|
1492
|
+
if (bridgeRuntimeStarting) _ownedRuntimeStopRequested = true;
|
|
1493
|
+
const wasConnected = bridgeRuntimeConnected;
|
|
1494
|
+
stopServerTyping();
|
|
1495
|
+
// Release the transcript fs.watch handle plus the forwarder's debounce/retry
|
|
1496
|
+
// timers on standby. Without this the watcher keeps firing scheduleWatchFlush
|
|
1497
|
+
// and the drain/retry timers stay live after ownership is dropped, leaking a
|
|
1498
|
+
// file handle + timers for the rest of the process lifetime.
|
|
1499
|
+
try { forwarder.stopWatch(); } catch {}
|
|
1500
|
+
stopOwnerHttpServer();
|
|
1501
|
+
stopOwnerHeartbeat();
|
|
1502
|
+
scheduler.stop();
|
|
1503
|
+
stopSnapshotWriter();
|
|
1504
|
+
stopWebhookAndEventRuntime();
|
|
1505
|
+
releaseOwnedChannelLocks(INSTANCE_ID);
|
|
1506
|
+
clearActiveInstance(INSTANCE_ID);
|
|
1507
|
+
try {
|
|
1508
|
+
// Only disconnect the backend when connect() actually completed; calling
|
|
1509
|
+
// disconnect() mid-connect races the connect promise.
|
|
1510
|
+
if (wasConnected) await backend.disconnect();
|
|
1511
|
+
} finally {
|
|
1512
|
+
bridgeRuntimeConnected = false;
|
|
1513
|
+
logOwnership(`standby: ${reason}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
function refreshBridgeOwnershipSafe(options = {}) {
|
|
1517
|
+
refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
|
|
1518
|
+
}
|
|
1519
|
+
function startOwnerHeartbeat() {
|
|
1520
|
+
if (ownerHeartbeatTimer) return;
|
|
1521
|
+
ownerHeartbeatTimer = setInterval(() => {
|
|
1522
|
+
try { refreshActiveInstance(INSTANCE_ID); }
|
|
1523
|
+
catch (e) {
|
|
1524
|
+
process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
1525
|
+
}
|
|
1526
|
+
}, OWNER_HEARTBEAT_INTERVAL_MS);
|
|
1527
|
+
ownerHeartbeatTimer.unref?.();
|
|
1528
|
+
}
|
|
1529
|
+
function stopOwnerHeartbeat() {
|
|
1530
|
+
if (!ownerHeartbeatTimer) return;
|
|
1531
|
+
clearInterval(ownerHeartbeatTimer);
|
|
1532
|
+
ownerHeartbeatTimer = null;
|
|
1533
|
+
}
|
|
1534
|
+
async function refreshBridgeOwnership(options = {}) {
|
|
1535
|
+
// Coalesce concurrent callers onto the in-flight refresh so backend tool
|
|
1536
|
+
// calls landing during normal login wait for the same connect attempt
|
|
1537
|
+
// instead of returning early and observing spurious auto-connect failure.
|
|
1538
|
+
if (bridgeOwnershipRefreshInFlight) return bridgeOwnershipRefreshInFlight;
|
|
1539
|
+
bridgeOwnershipRefreshInFlight = (async () => {
|
|
1540
|
+
if (!channelBridgeActive) {
|
|
1541
|
+
const { active: active2 } = currentOwnerState();
|
|
1542
|
+
if (active2?.httpPort && !proxyMode) {
|
|
1543
|
+
const alive = await pingOwner(active2.httpPort);
|
|
1544
|
+
if (alive) {
|
|
1545
|
+
proxyMode = true;
|
|
1546
|
+
ownerHttpPort = active2.httpPort;
|
|
1547
|
+
logOwnership(`non-channel session \u2014 proxy mode via ${active2.instanceId}`);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
const { active, owned } = currentOwnerState();
|
|
1553
|
+
const activeHttpPort = Number(active?.httpPort) || 0;
|
|
1554
|
+
let activeHttpChecked = false;
|
|
1555
|
+
let activeHttpAlive = false;
|
|
1556
|
+
const checkActiveHttp = async () => {
|
|
1557
|
+
if (!activeHttpPort) return false;
|
|
1558
|
+
if (!activeHttpChecked) {
|
|
1559
|
+
activeHttpAlive = await pingOwner(activeHttpPort);
|
|
1560
|
+
activeHttpChecked = true;
|
|
1561
|
+
}
|
|
1562
|
+
return activeHttpAlive;
|
|
1563
|
+
};
|
|
1564
|
+
const enterProxyMode = (note) => {
|
|
1565
|
+
proxyMode = true;
|
|
1566
|
+
ownerHttpPort = activeHttpPort;
|
|
1567
|
+
if (note) logOwnership(note);
|
|
1568
|
+
};
|
|
1569
|
+
if (proxyMode && !owned && activeHttpPort) {
|
|
1570
|
+
const alive = await checkActiveHttp();
|
|
1571
|
+
if (!alive) {
|
|
1572
|
+
process.stderr.write(`[ownership] owner ping failed, attempting takeover
|
|
1573
|
+
`);
|
|
1574
|
+
proxyMode = false;
|
|
1575
|
+
ownerHttpPort = 0;
|
|
1576
|
+
claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
|
|
1577
|
+
const next2 = currentOwnerState();
|
|
1578
|
+
if (next2.owned) {
|
|
1579
|
+
refreshActiveInstance(INSTANCE_ID);
|
|
1580
|
+
await startOwnedRuntime(options);
|
|
1581
|
+
}
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
// Active owner is alive but may have rebound to a new port since the
|
|
1585
|
+
// previous refresh (owner restart on a different PROXY_PORT). Sync
|
|
1586
|
+
// ownerHttpPort so subsequent proxyRequest() hits the new port instead
|
|
1587
|
+
// of the stale value cached at proxy-mode entry.
|
|
1588
|
+
if (ownerHttpPort !== activeHttpPort) {
|
|
1589
|
+
ownerHttpPort = activeHttpPort;
|
|
1590
|
+
logOwnership(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
|
|
1591
|
+
}
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
if (!owned && activeHttpPort) {
|
|
1595
|
+
const alive = await checkActiveHttp();
|
|
1596
|
+
if (alive) {
|
|
1597
|
+
enterProxyMode(`proxy mode via owner ${active.instanceId} port ${activeHttpPort}`);
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
const updatedAt = Number(active?.updatedAt);
|
|
1601
|
+
const activeAgeMs = Number.isFinite(updatedAt) ? Date.now() - updatedAt : Number.POSITIVE_INFINITY;
|
|
1602
|
+
if (active?.backendReady === true || activeAgeMs > ACTIVE_OWNER_STALE_MS) {
|
|
1603
|
+
logOwnership(`owner ${active.instanceId} port ${activeHttpPort} unreachable`);
|
|
1604
|
+
claimBridgeOwnership(`owner ${active.instanceId} unreachable`);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (!owned && canStealOwnership(active)) {
|
|
1608
|
+
claimBridgeOwnership(active ? `takeover from ${active.instanceId}` : "startup");
|
|
1609
|
+
}
|
|
1610
|
+
const next = currentOwnerState();
|
|
1611
|
+
if (next.owned) {
|
|
1612
|
+
refreshActiveInstance(INSTANCE_ID);
|
|
1613
|
+
await startOwnedRuntime(options);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
if (bridgeRuntimeConnected) {
|
|
1617
|
+
const reason = next.active?.instanceId ? `newer server ${next.active.instanceId}` : "no active owner";
|
|
1618
|
+
await stopOwnedRuntime(reason);
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
if (next.active?.httpPort && !proxyMode) {
|
|
1622
|
+
const alive = await pingOwner(next.active.httpPort);
|
|
1623
|
+
if (alive) {
|
|
1624
|
+
proxyMode = true;
|
|
1625
|
+
ownerHttpPort = next.active.httpPort;
|
|
1626
|
+
logOwnership(`proxy mode via owner ${next.active.instanceId} port ${next.active.httpPort}`);
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
if (next.active?.instanceId) {
|
|
1631
|
+
logOwnership(`standby under owner ${next.active.instanceId}`);
|
|
1632
|
+
}
|
|
1633
|
+
})();
|
|
1634
|
+
try {
|
|
1635
|
+
return await bridgeOwnershipRefreshInFlight;
|
|
1636
|
+
} finally {
|
|
1637
|
+
bridgeOwnershipRefreshInFlight = null;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
// ── inject_command helpers ─────────────────────────────────────────
|
|
1642
|
+
// Resolve the host Claude Code console PID. The mcp child knows its supervisor
|
|
1643
|
+
// PID via MIXDOG_SUPERVISOR_PID env; the supervisor's parent is the CC console.
|
|
1644
|
+
// Cached after first resolution because the relation cannot change for the
|
|
1645
|
+
// lifetime of this process.
|
|
1646
|
+
let _ccPidCache = null;
|
|
1647
|
+
function _resolveClaudeCodePid() {
|
|
1648
|
+
if (_ccPidCache) return _ccPidCache;
|
|
1649
|
+
const supPid = Number(process.env.MIXDOG_SUPERVISOR_PID);
|
|
1650
|
+
if (!Number.isFinite(supPid) || supPid <= 0) {
|
|
1651
|
+
throw new Error("MIXDOG_SUPERVISOR_PID env not set; cannot resolve CC PID");
|
|
1652
|
+
}
|
|
1653
|
+
if (process.platform !== "win32") {
|
|
1654
|
+
throw new Error("inject_command CC PID resolution is Windows-only");
|
|
1655
|
+
}
|
|
1656
|
+
const r = spawnSync("powershell", ["-NoProfile", "-Command",
|
|
1657
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId=${supPid}").ParentProcessId`,
|
|
1658
|
+
], { encoding: "utf8", windowsHide: true });
|
|
1659
|
+
const ppid = Number.parseInt((r.stdout || "").trim(), 10);
|
|
1660
|
+
if (!Number.isFinite(ppid) || ppid <= 0) {
|
|
1661
|
+
throw new Error(`cannot resolve supervisor (pid=${supPid}) parent process`);
|
|
1662
|
+
}
|
|
1663
|
+
_ccPidCache = ppid;
|
|
1664
|
+
return ppid;
|
|
1665
|
+
}
|
|
1666
|
+
function _injectScriptPath() {
|
|
1667
|
+
const root = process.env.CLAUDE_PLUGIN_ROOT;
|
|
1668
|
+
if (!root) throw new Error("CLAUDE_PLUGIN_ROOT env not set");
|
|
1669
|
+
const p = path.join(root, "scripts", "inject-input.ps1");
|
|
1670
|
+
if (!fs.existsSync(p)) throw new Error(`inject-input.ps1 missing at ${p}`);
|
|
1671
|
+
return p;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
async function reloadRuntimeConfig() {
|
|
1675
|
+
const previousBackend = backend;
|
|
1676
|
+
const previousBackendName = previousBackend?.name || "";
|
|
1677
|
+
config = loadConfig();
|
|
1678
|
+
scheduler.reloadConfig(
|
|
1679
|
+
config.nonInteractive ?? [],
|
|
1680
|
+
config.interactive ?? [],
|
|
1681
|
+
// channelsConfig: channel-label resolution only.
|
|
1682
|
+
config.channelsConfig,
|
|
1683
|
+
// 0.1.62: top-level normalized config (quiet/schedules).
|
|
1684
|
+
config,
|
|
1685
|
+
{ restart: bridgeRuntimeConnected }
|
|
1686
|
+
);
|
|
1687
|
+
const nextBackend = createBackend(config);
|
|
1688
|
+
const backendChanged = (nextBackend?.name || "") !== previousBackendName;
|
|
1689
|
+
if (backendChanged) {
|
|
1690
|
+
const shouldRestart = bridgeRuntimeConnected || bridgeRuntimeStarting;
|
|
1691
|
+
if (shouldRestart) await stopOwnedRuntime("backend config changed");
|
|
1692
|
+
backend = nextBackend;
|
|
1693
|
+
if (shouldRestart) refreshBridgeOwnershipSafe({ restoreBinding: false });
|
|
1694
|
+
} else if (nextBackend !== previousBackend) {
|
|
1695
|
+
try { await nextBackend.disconnect?.(); } catch {}
|
|
1696
|
+
}
|
|
1697
|
+
if (bridgeRuntimeConnected) {
|
|
1698
|
+
syncOwnedWebhookAndEventRuntime({ reload: true });
|
|
1699
|
+
} else {
|
|
1700
|
+
stopWebhookAndEventRuntime();
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
function injectAndRecord(channelId, name, content, options) {
|
|
1704
|
+
// Strip soft-warn marker blocks (Tool-loop / Repeated-input / legacy
|
|
1705
|
+
// Repeated-tool / Mixed-tool / Tool-budget / Same-file multi-chunk /
|
|
1706
|
+
// Bash file-lookup / Iteration / 0-match advisory) from anywhere in the
|
|
1707
|
+
// outbound body. Markers are
|
|
1708
|
+
// intentionally prepended onto tool RESULTS upstream (tool-loop-guard.mjs
|
|
1709
|
+
// build*Warn) so the model
|
|
1710
|
+
// self-corrects, but bridge roles commonly echo them and we don't want them
|
|
1711
|
+
// surfacing in Discord / Lead channel push.
|
|
1712
|
+
if (typeof content === 'string') content = stripSoftWarns(content);
|
|
1713
|
+
// Skip-protocol guard: bridge workers (webhook-handler / scheduler-task)
|
|
1714
|
+
// prefix `[meta:silent]` on the first line to opt out
|
|
1715
|
+
// of Lead inject for genuine no-op results (label-only events, dedup,
|
|
1716
|
+
// "nothing to report"). The body still goes to Discord for audit; only
|
|
1717
|
+
// the Lead-context inject is suppressed. See rules/bridge/20-skip-protocol.md.
|
|
1718
|
+
if (typeof content === 'string') {
|
|
1719
|
+
const m = content.match(/^\s*\[meta:silent\][^\n]*\n?([\s\S]*)$/);
|
|
1720
|
+
if (m) {
|
|
1721
|
+
content = m[1];
|
|
1722
|
+
options = { ...(options || {}), silent_to_agent: true };
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
const ts = new Date().toISOString();
|
|
1726
|
+
const now = new Date();
|
|
1727
|
+
const timeLabel = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")} `;
|
|
1728
|
+
const sourceLabel = options?.type ? `${timeLabel}: ${options.type}` : timeLabel;
|
|
1729
|
+
const meta = { chat_id: channelId, user: sourceLabel, user_id: "system", ts };
|
|
1730
|
+
if (options?.instruction) meta.instruction = options.instruction;
|
|
1731
|
+
if (options?.type) meta.type = options.type;
|
|
1732
|
+
// `silent_to_agent` — lifecycle status pings (worker/iter/started echoes)
|
|
1733
|
+
// surface on Discord but should NOT land in Lead's context window. When
|
|
1734
|
+
// set, skip the parent-notify hop but keep the Discord-forward + event-log
|
|
1735
|
+
// record. The meta flag is also propagated downstream so consumers that
|
|
1736
|
+
// still see the notification (e.g. Lead itself if emission changes later)
|
|
1737
|
+
// can recognise and drop it. Default is false → legacy behaviour preserved.
|
|
1738
|
+
if (options?.silent_to_agent) meta.silent_to_agent = true;
|
|
1739
|
+
const silent = options?.silent_to_agent === true;
|
|
1740
|
+
if (!silent) {
|
|
1741
|
+
sendNotifyToParent("notifications/claude/channel", { content, meta });
|
|
1742
|
+
} else {
|
|
1743
|
+
forwardLifecycleToDiscord(channelId, content);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// Best-effort direct Discord emission for silent-to-agent lifecycle pings.
|
|
1748
|
+
// Only used when the parent-notify hop is skipped, so the user still sees
|
|
1749
|
+
// the status on Discord even though Lead will never echo it through the
|
|
1750
|
+
// transcript-tail forwarder. Falls back to a no-op when no channel is
|
|
1751
|
+
// resolvable — lifecycle pings are non-critical.
|
|
1752
|
+
function forwardLifecycleToDiscord(channelId, content) {
|
|
1753
|
+
try {
|
|
1754
|
+
// Skip rather than guess: lifecycle callers pass the channelId they own;
|
|
1755
|
+
// falling back to statusState.channelId can route to a stale/unrelated
|
|
1756
|
+
// channel when the caller did not supply one intentionally.
|
|
1757
|
+
const target = channelId || null;
|
|
1758
|
+
dropTrace("send.lifecycle.entry", { channelId: target || "(none)", bindingReadyStatus, backendPresent: !!backend?.sendMessage, preview: preview(content) });
|
|
1759
|
+
if (!target || !backend?.sendMessage) return;
|
|
1760
|
+
void bindingReady.then(() =>
|
|
1761
|
+
backend.sendMessage(target, content)
|
|
1762
|
+
.then(() => dropTrace("send.lifecycle.ok", { channelId: target }))
|
|
1763
|
+
.catch((err) => dropTrace("send.lifecycle.err", { channelId: target, err: String(err) }))
|
|
1764
|
+
).catch(() => {});
|
|
1765
|
+
} catch { /* best-effort */ }
|
|
1766
|
+
}
|
|
1767
|
+
scheduler.setInjectHandler((channelId, name, content, options) => {
|
|
1768
|
+
injectAndRecord(channelId, name, content, options);
|
|
1769
|
+
});
|
|
1770
|
+
scheduler.setSendHandler(async (channelId, text) => {
|
|
1771
|
+
// Skip protocol: a scheduler-task emitting `[meta:silent]` has nothing to
|
|
1772
|
+
// report — suppress the channel send entirely (no noise). Mirrors the
|
|
1773
|
+
// webhook delegate drop and injectAndRecord's silent handling.
|
|
1774
|
+
if (typeof text === "string" && /^\s*\[meta:silent\]/.test(text)) {
|
|
1775
|
+
dropTrace("send.scheduler.silent", { channelId });
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
dropTrace("send.scheduler.entry", { channelId, preview: preview(text) });
|
|
1779
|
+
await bindingReady;
|
|
1780
|
+
dropTrace("send.scheduler.ready", { channelId });
|
|
1781
|
+
try {
|
|
1782
|
+
await backend.sendMessage(channelId, text);
|
|
1783
|
+
dropTrace("send.scheduler.ok", { channelId });
|
|
1784
|
+
} catch (err) {
|
|
1785
|
+
dropTrace("send.scheduler.err", { channelId, err: String(err) });
|
|
1786
|
+
throw err;
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
function wireWebhookHandlers() {
|
|
1790
|
+
if (!webhookServer) return;
|
|
1791
|
+
webhookServer.setEventPipeline(eventPipeline);
|
|
1792
|
+
webhookServer.setBridgeDispatch(async ({ role, preset, prompt, cwd, context }) => {
|
|
1793
|
+
// Delegate-mode webhook → bridge orchestrator. Each bridge progress /
|
|
1794
|
+
// final event is forwarded to the Lead via the same channel-notify
|
|
1795
|
+
// path used by schedule & event-queue (injectAndRecord). Silent
|
|
1796
|
+
// lifecycle pings keep routing only to Discord.
|
|
1797
|
+
const agentMod = await import("../agent/index.mjs");
|
|
1798
|
+
const channelId = resolveWebhookChannelId(context?.channel);
|
|
1799
|
+
const endpoint = context?.endpoint || "unknown";
|
|
1800
|
+
const event = context?.event || null;
|
|
1801
|
+
const deliveryId = context?.deliveryId || null;
|
|
1802
|
+
const label = `webhook:${endpoint}`;
|
|
1803
|
+
const instruction = `Webhook review from role=${role} on endpoint "${endpoint}"`
|
|
1804
|
+
+ (event ? ` (event=${event})` : "")
|
|
1805
|
+
+ (deliveryId ? ` (delivery=${deliveryId})` : "")
|
|
1806
|
+
+ ". Relay the finding to the user naturally — summarize clearly, call out any issues, and note what needs a decision.";
|
|
1807
|
+
const notifyFn = (text, meta = {}) => {
|
|
1808
|
+
if (!text) return;
|
|
1809
|
+
// Webhook skip protocol: when the bridge worker emits a `[meta:silent]`
|
|
1810
|
+
// marker (optionally behind model/role tag prefixes), the event is a
|
|
1811
|
+
// no-op (label-only, dedup, "nothing to report"). Drop the message
|
|
1812
|
+
// entirely — neither Lead inject nor Discord forward — instead of the
|
|
1813
|
+
// partial `silent_to_agent` semantics that still audit to Discord.
|
|
1814
|
+
const raw = String(text);
|
|
1815
|
+
if (/^\s*(?:\[[^\]\n]+\]\s*)*\[meta:silent\]/.test(raw)) return;
|
|
1816
|
+
// Lifecycle pings (started / iter echoes, marked silent_to_agent) are
|
|
1817
|
+
// channel noise for an automated webhook review — drop them entirely so
|
|
1818
|
+
// a skip stays fully silent and only the final answer reaches the
|
|
1819
|
+
// channel. The final [meta:silent] skip result is already dropped above.
|
|
1820
|
+
if (meta?.silent_to_agent === true) return;
|
|
1821
|
+
injectAndRecord(channelId, label, raw, {
|
|
1822
|
+
type: "webhook",
|
|
1823
|
+
instruction,
|
|
1824
|
+
});
|
|
1825
|
+
};
|
|
1826
|
+
// Per-terminal cwd under the daemon's single channels worker. A webhook
|
|
1827
|
+
// result is injected to ownerConn() — the connection whose session.leadPid
|
|
1828
|
+
// equals active-instance ownerLeadPid — so the worker must run in THAT
|
|
1829
|
+
// owner terminal's cwd. Read the sentinel keyed by ownerLeadPid; cwd-tool
|
|
1830
|
+
// writes session-cwd-<leadPid>.txt per connection, so write and read meet
|
|
1831
|
+
// on the same leadPid key no matter which terminal holds the owner seat.
|
|
1832
|
+
// Falls back to the session entry position; never the plugin CACHE root.
|
|
1833
|
+
const ownerPid = getActiveOwnerPid(readActiveInstance());
|
|
1834
|
+
const ownerCwd = (ownerPid && readLastSessionCwd(ownerPid)) || captureOriginalUserCwd();
|
|
1835
|
+
return agentMod.handleToolCall(
|
|
1836
|
+
"bridge",
|
|
1837
|
+
{ role, preset, prompt, cwd: cwd || ownerCwd },
|
|
1838
|
+
{ notifyFn },
|
|
1839
|
+
);
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
function resolveWebhookChannelId(channelLabel) {
|
|
1843
|
+
// Fail closed: route only to channels explicitly present in config —
|
|
1844
|
+
// the endpoint's owner-configured `channel`, else the `main` channel.
|
|
1845
|
+
// Runtime / persisted-status fallbacks are never used (they could route
|
|
1846
|
+
// a delivery to an arbitrary or stale channel). The endpoint channel is
|
|
1847
|
+
// owner-authored config, not attacker payload, so honoring it is safe.
|
|
1848
|
+
const channels = config?.channelsConfig || {};
|
|
1849
|
+
if (channelLabel && channels[channelLabel]?.channelId) return channels[channelLabel].channelId;
|
|
1850
|
+
return channels.main?.channelId || "";
|
|
1851
|
+
}
|
|
1852
|
+
function wireEventQueueHandlers(eventQueue) {
|
|
1853
|
+
if (!eventQueue) return;
|
|
1854
|
+
eventQueue.setInjectHandler((channelId, name, content, options) => {
|
|
1855
|
+
injectAndRecord(channelId, name, content, options);
|
|
1856
|
+
});
|
|
1857
|
+
// Defensive ownership probe: the queue tick should only run in the active
|
|
1858
|
+
// owner process. Standby / proxy instances see bridgeRuntimeConnected=false
|
|
1859
|
+
// or proxyMode=true and will skip the tick even if an errant start() slipped
|
|
1860
|
+
// through.
|
|
1861
|
+
eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
|
|
1862
|
+
forwarder.setOwnerGetter(() => bridgeRuntimeConnected && !proxyMode);
|
|
1863
|
+
}
|
|
1864
|
+
function editDiscordMessage(channelId, messageId, label) {
|
|
1865
|
+
// Behavior-preserving: route through the backend abstraction (which uses
|
|
1866
|
+
// discord.js under the hood) instead of issuing a raw REST PATCH. Errors
|
|
1867
|
+
// are swallowed to stderr to match the prior fire-and-forget shape — the
|
|
1868
|
+
// call site never awaited the HTTPS request either.
|
|
1869
|
+
if (!getDiscordToken()) return;
|
|
1870
|
+
const text = `\u{1F510} **Permission Request** \u2014 ${label}`;
|
|
1871
|
+
void backend.editMessage(channelId, messageId, text, { components: [] }).catch((err) => {
|
|
1872
|
+
process.stderr.write(`mixdog: editDiscordMessage failed: ${err}
|
|
1873
|
+
`);
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
backend.onModalRequest = async (rawInteraction) => {
|
|
1877
|
+
if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
|
|
1878
|
+
refreshBridgeOwnershipSafe();
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder } = await import("discord.js");
|
|
1882
|
+
const customId = rawInteraction.customId;
|
|
1883
|
+
const channelId = rawInteraction.channelId ?? "";
|
|
1884
|
+
pendingSetup.rememberMessage(rawInteraction.user.id, channelId, rawInteraction.message?.id);
|
|
1885
|
+
const modalSpec = buildModalRequestSpec(
|
|
1886
|
+
customId,
|
|
1887
|
+
pendingSetup.get(rawInteraction.user.id, channelId),
|
|
1888
|
+
loadProfileConfig()
|
|
1889
|
+
);
|
|
1890
|
+
if (!modalSpec) return;
|
|
1891
|
+
const modal = new ModalBuilder().setCustomId(modalSpec.customId).setTitle(modalSpec.title);
|
|
1892
|
+
const rows = modalSpec.fields.map(
|
|
1893
|
+
(field) => new ActionRowBuilder().addComponents((() => {
|
|
1894
|
+
const input = new TextInputBuilder().setCustomId(field.id).setLabel(field.label).setStyle(TextInputStyle.Short).setRequired(field.required);
|
|
1895
|
+
if (field.value) input.setValue(field.value);
|
|
1896
|
+
return input;
|
|
1897
|
+
})())
|
|
1898
|
+
);
|
|
1899
|
+
modal.addComponents(...rows);
|
|
1900
|
+
await rawInteraction.showModal(modal);
|
|
1901
|
+
};
|
|
1902
|
+
const pendingPermRequests = new Map();
|
|
1903
|
+
const TOOL_EXEC_CONSUMER_MARKER = path.join(RUNTIME_ROOT, '.tool-exec-consumer');
|
|
1904
|
+
function refreshToolExecConsumerMarker() {
|
|
1905
|
+
try {
|
|
1906
|
+
if (pendingPermRequests.size > 0) {
|
|
1907
|
+
fs.writeFileSync(TOOL_EXEC_CONSUMER_MARKER, String(Date.now()));
|
|
1908
|
+
} else {
|
|
1909
|
+
try { fs.unlinkSync(TOOL_EXEC_CONSUMER_MARKER); } catch {}
|
|
1910
|
+
}
|
|
1911
|
+
} catch {}
|
|
1912
|
+
}
|
|
1913
|
+
// Watch for terminal-approved tool executions. The PostToolUse hook writes a
|
|
1914
|
+
// signal file per tool call; when we see one, find the oldest pending perm
|
|
1915
|
+
// request with a matching tool name and mark its Discord message as
|
|
1916
|
+
// "Allowed (terminal)" so users don't see stale active buttons.
|
|
1917
|
+
try {
|
|
1918
|
+
try { if (!fs.existsSync(RUNTIME_ROOT)) fs.mkdirSync(RUNTIME_ROOT, { recursive: true }); } catch {}
|
|
1919
|
+
const SIGNAL_RE = /^tool-exec-\d+-[0-9a-f]+\.signal$/;
|
|
1920
|
+
fs.watch(RUNTIME_ROOT, { persistent: false }, (eventType, filename) => {
|
|
1921
|
+
if (!filename || !SIGNAL_RE.test(filename)) return;
|
|
1922
|
+
setTimeout(() => {
|
|
1923
|
+
try {
|
|
1924
|
+
const signalPath = path.join(RUNTIME_ROOT, filename);
|
|
1925
|
+
let raw;
|
|
1926
|
+
try { raw = fs.readFileSync(signalPath, 'utf8'); } catch { return; }
|
|
1927
|
+
let payload;
|
|
1928
|
+
try { payload = JSON.parse(raw); } catch { return; }
|
|
1929
|
+
const toolName = payload?.toolName;
|
|
1930
|
+
if (!toolName) return;
|
|
1931
|
+
const sigFilePath = payload?.filePath || '';
|
|
1932
|
+
let oldestKey = null;
|
|
1933
|
+
let oldestEntry = null;
|
|
1934
|
+
for (const [k, v] of pendingPermRequests) {
|
|
1935
|
+
if (v.toolName !== toolName) continue;
|
|
1936
|
+
// Bind on filePath too. If both sides are empty (non-file tools
|
|
1937
|
+
// like Bash), toolName alone is the match. Otherwise both must
|
|
1938
|
+
// equal — prevents two concurrent Edit/Write requests from
|
|
1939
|
+
// cross-approving each other.
|
|
1940
|
+
const vFilePath = v.filePath || '';
|
|
1941
|
+
if (vFilePath || sigFilePath) {
|
|
1942
|
+
if (vFilePath !== sigFilePath) continue;
|
|
1943
|
+
}
|
|
1944
|
+
if (!oldestEntry || v.createdAt < oldestEntry.createdAt) {
|
|
1945
|
+
oldestKey = k;
|
|
1946
|
+
oldestEntry = v;
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
// No matching pending request — leave the signal on disk so a
|
|
1950
|
+
// bridge role hook (or other consumer) gets a chance to claim it.
|
|
1951
|
+
if (!oldestKey || !oldestEntry) return;
|
|
1952
|
+
if (oldestEntry.channelId && oldestEntry.messageId) {
|
|
1953
|
+
try {
|
|
1954
|
+
editDiscordMessage(oldestEntry.channelId, oldestEntry.messageId, 'Allowed (terminal)');
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
try { process.stderr.write(`mixdog channels: tool-exec signal edit failed: ${err && err.message || err}\n`); } catch {}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
pendingPermRequests.delete(oldestKey);
|
|
1960
|
+
refreshToolExecConsumerMarker();
|
|
1961
|
+
// Only unlink once we've confirmed the match and handled it.
|
|
1962
|
+
try { fs.unlinkSync(signalPath); } catch {}
|
|
1963
|
+
} catch (err) {
|
|
1964
|
+
try { process.stderr.write(`mixdog channels: tool-exec signal handler error: ${err && err.message || err}\n`); } catch {}
|
|
1965
|
+
}
|
|
1966
|
+
}, 50);
|
|
1967
|
+
});
|
|
1968
|
+
// Stale-signal sweeper: any signal file older than 60s is removed so
|
|
1969
|
+
// unclaimed files don't accumulate on disk. Runs every 30s.
|
|
1970
|
+
setInterval(() => {
|
|
1971
|
+
try {
|
|
1972
|
+
const now = Date.now();
|
|
1973
|
+
const entries = fs.readdirSync(RUNTIME_ROOT);
|
|
1974
|
+
for (const name of entries) {
|
|
1975
|
+
if (!SIGNAL_RE.test(name)) continue;
|
|
1976
|
+
const p = path.join(RUNTIME_ROOT, name);
|
|
1977
|
+
try {
|
|
1978
|
+
const st = fs.statSync(p);
|
|
1979
|
+
if (now - st.mtimeMs > 60_000) {
|
|
1980
|
+
try { fs.unlinkSync(p); } catch {}
|
|
1981
|
+
}
|
|
1982
|
+
} catch {}
|
|
1983
|
+
}
|
|
1984
|
+
} catch {}
|
|
1985
|
+
}, 30_000)?.unref?.();
|
|
1986
|
+
} catch (err) {
|
|
1987
|
+
try { process.stderr.write(`mixdog channels: tool-exec signal watcher setup failed: ${err && err.message || err}\n`); } catch {}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
backend.onInteraction = (interaction) => {
|
|
1991
|
+
// Channel-route permission reply. Custom_id format: perm-ch-{request_id}-{allow|session|deny}.
|
|
1992
|
+
// request_id is the 5-letter short ID CC generates via shortRequestId().
|
|
1993
|
+
// Emit notifications/claude/channel/permission back to Claude Code; the race
|
|
1994
|
+
// logic in interactiveHandler.ts resolves the pending request and dismisses
|
|
1995
|
+
// every other racer (local dialog, bridge, hook, classifier).
|
|
1996
|
+
if (interaction.customId?.startsWith("perm-ch-")) {
|
|
1997
|
+
const match = interaction.customId.match(/^perm-ch-([a-km-z]{5})-(allow|session|deny)$/);
|
|
1998
|
+
if (!match) return;
|
|
1999
|
+
const [, requestId, action] = match;
|
|
2000
|
+
const access = config.access;
|
|
2001
|
+
if (access?.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
|
|
2002
|
+
process.stderr.write(`mixdog: perm-ch button rejected — user ${interaction.userId} not in allowFrom\n`);
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
const pending = pendingPermRequests.get(requestId);
|
|
2006
|
+
pendingPermRequests.delete(requestId);
|
|
2007
|
+
refreshToolExecConsumerMarker();
|
|
2008
|
+
const params = { request_id: requestId };
|
|
2009
|
+
if (action === 'deny') {
|
|
2010
|
+
params.behavior = 'deny';
|
|
2011
|
+
} else if (action === 'session') {
|
|
2012
|
+
params.behavior = 'allow';
|
|
2013
|
+
const toolName = pending?.toolName;
|
|
2014
|
+
if (toolName) {
|
|
2015
|
+
params.updatedPermissions = [{ type: 'addRules', rules: [{ toolName }], behavior: 'allow', destination: 'session' }];
|
|
2016
|
+
}
|
|
2017
|
+
} else {
|
|
2018
|
+
params.behavior = 'allow';
|
|
2019
|
+
}
|
|
2020
|
+
sendNotifyToParent('notifications/claude/channel/permission', params);
|
|
2021
|
+
const labels = { allow: 'Approved', session: 'Session Approved', deny: 'Denied' };
|
|
2022
|
+
if (interaction.message?.id && interaction.channelId) {
|
|
2023
|
+
editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
|
|
2024
|
+
}
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
if (interaction.customId?.startsWith("perm-")) {
|
|
2028
|
+
const match = interaction.customId.match(/^perm-([0-9a-f]{32})-(allow|session|deny)$/);
|
|
2029
|
+
if (!match) return;
|
|
2030
|
+
const [, uuid, action] = match;
|
|
2031
|
+
const access = config.access;
|
|
2032
|
+
if (!access) {
|
|
2033
|
+
fs.appendFileSync(_bootLog, `[${localTimestamp()}] perm interaction dropped: no access config
|
|
2034
|
+
`);
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (access.allowFrom?.length > 0 && !access.allowFrom.includes(interaction.userId)) {
|
|
2038
|
+
process.stderr.write(`mixdog: perm button rejected \u2014 user ${interaction.userId} not in allowFrom
|
|
2039
|
+
`);
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const resultPaths = [getPermissionResultPath(INSTANCE_ID, uuid)];
|
|
2043
|
+
const leadInstanceId = String(TERMINAL_LEAD_PID);
|
|
2044
|
+
if (leadInstanceId && leadInstanceId !== INSTANCE_ID) {
|
|
2045
|
+
resultPaths.push(getPermissionResultPath(leadInstanceId, uuid));
|
|
2046
|
+
}
|
|
2047
|
+
for (const resultPath of resultPaths) {
|
|
2048
|
+
try {
|
|
2049
|
+
fs.writeFileSync(resultPath, action, { flag: "wx" });
|
|
2050
|
+
} catch (e) {
|
|
2051
|
+
if (e.code !== "EEXIST") {
|
|
2052
|
+
process.stderr.write(`mixdog: writePermissionResult failed: ${e.message}\n`);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
const labels = { allow: "Approved", session: "Session Approved", deny: "Denied" };
|
|
2057
|
+
if (interaction.message?.id && interaction.channelId) {
|
|
2058
|
+
editDiscordMessage(interaction.channelId, interaction.message.id, labels[action] || action);
|
|
2059
|
+
}
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
|
|
2063
|
+
refreshBridgeOwnershipSafe();
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
scheduler.noteActivity();
|
|
2067
|
+
if (interaction.customId === "stop_task") {
|
|
2068
|
+
controlClaudeSession(INSTANCE_ID, { type: "interrupt" })
|
|
2069
|
+
.catch(err => process.stderr.write(`[channels] controlClaudeSession rejected: ${err?.message || err}\n`));
|
|
2070
|
+
writeTextFile(TURN_END_FILE, String(Date.now()));
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
sendNotifyToParent("notifications/claude/channel", {
|
|
2074
|
+
content: `[interaction] ${interaction.type}: ${interaction.customId}${interaction.values ? " values=" + interaction.values.join(",") : ""}`,
|
|
2075
|
+
meta: {
|
|
2076
|
+
chat_id: interaction.channelId,
|
|
2077
|
+
user: `interaction:${interaction.type}`,
|
|
2078
|
+
user_id: interaction.userId,
|
|
2079
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2080
|
+
interaction_type: interaction.type,
|
|
2081
|
+
custom_id: interaction.customId,
|
|
2082
|
+
...interaction.values ? { values: interaction.values.join(",") } : {},
|
|
2083
|
+
...interaction.message ? { message_id: interaction.message.id } : {}
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
};
|
|
2087
|
+
function isVoiceAttachment(contentType) {
|
|
2088
|
+
if (typeof contentType !== 'string') return false;
|
|
2089
|
+
const ct = contentType.toLowerCase();
|
|
2090
|
+
return ct.startsWith("audio/") || ct.startsWith("application/ogg");
|
|
2091
|
+
}
|
|
2092
|
+
function runCmd(cmd, args, capture = false) {
|
|
2093
|
+
return new Promise((resolve, reject) => {
|
|
2094
|
+
const proc = spawn(cmd, args, {
|
|
2095
|
+
stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore",
|
|
2096
|
+
windowsHide: true
|
|
2097
|
+
});
|
|
2098
|
+
let out = "";
|
|
2099
|
+
if (capture && proc.stdout) proc.stdout.on("data", (d) => {
|
|
2100
|
+
out += d;
|
|
2101
|
+
});
|
|
2102
|
+
proc.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} exit ${code}`)));
|
|
2103
|
+
proc.on("error", reject);
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
let resolvedWhisperLanguage = null;
|
|
2107
|
+
function normalizeWhisperLanguage(value) {
|
|
2108
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
2109
|
+
if (!raw || raw === "auto") return null;
|
|
2110
|
+
if (raw.startsWith("ko")) return "ko";
|
|
2111
|
+
if (raw.startsWith("ja")) return "ja";
|
|
2112
|
+
if (raw.startsWith("en")) return "en";
|
|
2113
|
+
if (raw.startsWith("zh")) return "zh";
|
|
2114
|
+
if (raw.startsWith("de")) return "de";
|
|
2115
|
+
if (raw.startsWith("fr")) return "fr";
|
|
2116
|
+
if (raw.startsWith("es")) return "es";
|
|
2117
|
+
if (raw.startsWith("it")) return "it";
|
|
2118
|
+
if (raw.startsWith("pt")) return "pt";
|
|
2119
|
+
if (raw.startsWith("ru")) return "ru";
|
|
2120
|
+
return raw;
|
|
2121
|
+
}
|
|
2122
|
+
function detectDeviceLanguage() {
|
|
2123
|
+
if (resolvedWhisperLanguage) return resolvedWhisperLanguage;
|
|
2124
|
+
const candidates = [
|
|
2125
|
+
process.env.MIXDOG_CHANNELS_WHISPER_LANGUAGE,
|
|
2126
|
+
process.env.LC_ALL,
|
|
2127
|
+
process.env.LC_MESSAGES,
|
|
2128
|
+
process.env.LANG,
|
|
2129
|
+
Intl.DateTimeFormat().resolvedOptions().locale
|
|
2130
|
+
];
|
|
2131
|
+
for (const candidate of candidates) {
|
|
2132
|
+
const normalized = normalizeWhisperLanguage(candidate);
|
|
2133
|
+
if (normalized) {
|
|
2134
|
+
resolvedWhisperLanguage = normalized;
|
|
2135
|
+
return normalized;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
resolvedWhisperLanguage = "auto";
|
|
2139
|
+
return resolvedWhisperLanguage;
|
|
2140
|
+
}
|
|
2141
|
+
// ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
|
|
2142
|
+
const _voiceTranscriptionQueue = (() => {
|
|
2143
|
+
let running = 0;
|
|
2144
|
+
const pending = [];
|
|
2145
|
+
function drain() {
|
|
2146
|
+
const limit = config.voice?.transcription?.maxConcurrency ?? 1;
|
|
2147
|
+
while (running < limit && pending.length > 0) {
|
|
2148
|
+
const { fn, resolve, reject } = pending.shift();
|
|
2149
|
+
running++;
|
|
2150
|
+
fn().then(resolve, reject).finally(() => { running--; drain(); });
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
return function enqueue(fn) {
|
|
2154
|
+
return new Promise((resolve, reject) => { pending.push({ fn, resolve, reject }); drain(); });
|
|
2155
|
+
};
|
|
2156
|
+
})();
|
|
2157
|
+
|
|
2158
|
+
// ── wav + transcript cache keyed by attachment id ──
|
|
2159
|
+
const _voiceWavCache = new Map(); // attachmentId → wavPath
|
|
2160
|
+
const _voiceTranscriptCache = new Map(); // attachmentId → transcript string
|
|
2161
|
+
const _voiceInflight = new Map(); // attachmentId → Promise<string|null>
|
|
2162
|
+
const _voiceFfmpegInflight = new Map(); // attachmentId|wavPath → Promise<void> single-flight ffmpeg
|
|
2163
|
+
|
|
2164
|
+
async function _probeAudioDurationSec(filePath) {
|
|
2165
|
+
try {
|
|
2166
|
+
const ffprobePath = (() => { try { return _require('ffprobe-static').path; } catch { return 'ffprobe'; } })();
|
|
2167
|
+
return await new Promise((resolve, reject) => {
|
|
2168
|
+
const args = ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath];
|
|
2169
|
+
let out = '';
|
|
2170
|
+
const proc = spawn(ffprobePath, args, { windowsHide: true });
|
|
2171
|
+
proc.stdout.on('data', (d) => { out += d; });
|
|
2172
|
+
proc.on('close', (code) => { code === 0 ? resolve(parseFloat(out.trim()) || null) : reject(new Error(`ffprobe exit ${code}`)); });
|
|
2173
|
+
proc.on('error', reject);
|
|
2174
|
+
});
|
|
2175
|
+
} catch {
|
|
2176
|
+
return null;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
async function transcribeVoice(audioPath, { attachmentId } = {}) {
|
|
2181
|
+
// ── size gate (config: voice.transcription.maxFileSizeMB) ──
|
|
2182
|
+
const maxSizeBytes = (config.voice?.transcription?.maxFileSizeMB ?? 0) * 1024 * 1024;
|
|
2183
|
+
if (maxSizeBytes > 0) {
|
|
2184
|
+
try {
|
|
2185
|
+
const stat = await fs.promises.stat(audioPath);
|
|
2186
|
+
if (stat.size > maxSizeBytes) {
|
|
2187
|
+
process.stderr.write(`mixdog: voice.transcription skipped — file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB > ${config.voice.transcription.maxFileSizeMB} MB): ${audioPath}\n`);
|
|
2188
|
+
return null;
|
|
2189
|
+
}
|
|
2190
|
+
} catch { /* stat failure: proceed */ }
|
|
2191
|
+
}
|
|
2192
|
+
// ── duration gate (config: voice.transcription.maxDurationSec) ──
|
|
2193
|
+
const maxDurationSec = config.voice?.transcription?.maxDurationSec ?? 0;
|
|
2194
|
+
if (maxDurationSec > 0) {
|
|
2195
|
+
const dur = await _probeAudioDurationSec(audioPath);
|
|
2196
|
+
if (dur !== null && dur > maxDurationSec) {
|
|
2197
|
+
process.stderr.write(`mixdog: voice.transcription skipped — audio too long (${dur.toFixed(1)}s > ${maxDurationSec}s): ${audioPath}\n`);
|
|
2198
|
+
return null;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
// ── transcript cache hit ──
|
|
2202
|
+
if (attachmentId && _voiceTranscriptCache.has(attachmentId)) {
|
|
2203
|
+
process.stderr.write(`mixdog: voice.transcription cache hit (${attachmentId})\n`);
|
|
2204
|
+
return _voiceTranscriptCache.get(attachmentId);
|
|
2205
|
+
}
|
|
2206
|
+
if (attachmentId && _voiceInflight.has(attachmentId)) {
|
|
2207
|
+
return _voiceInflight.get(attachmentId);
|
|
2208
|
+
}
|
|
2209
|
+
const p = _voiceTranscriptionQueue(() => _doTranscribeVoice(audioPath, attachmentId));
|
|
2210
|
+
if (attachmentId) {
|
|
2211
|
+
_voiceInflight.set(attachmentId, p);
|
|
2212
|
+
p.catch((err) => {
|
|
2213
|
+
try { process.stderr.write(`mixdog: voice.transcription inflight rejection: ${err?.stack || err}\n`); } catch {}
|
|
2214
|
+
}).finally(() => _voiceInflight.delete(attachmentId));
|
|
2215
|
+
}
|
|
2216
|
+
return p;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
async function _doTranscribeVoice(audioPath, attachmentId) {
|
|
2220
|
+
try {
|
|
2221
|
+
const runtime = resolveVoiceRuntime(DATA_DIR);
|
|
2222
|
+
if (!runtime?.installed) {
|
|
2223
|
+
const missing = [runtime?.binary ? null : 'binary', runtime?.model ? null : 'model', runtime?.ffmpeg ? null : 'ffmpeg'].filter(Boolean).join(' + ');
|
|
2224
|
+
throw new Error(`voice runtime not installed (missing: ${missing}) — open the setup wizard and click "Install voice"`);
|
|
2225
|
+
}
|
|
2226
|
+
const whisperCmd = runtime.whisperCmd;
|
|
2227
|
+
const modelPath = runtime.modelPath;
|
|
2228
|
+
const ffmpegPath = runtime.ffmpegPath;
|
|
2229
|
+
const lang = normalizeWhisperLanguage(config.voice?.language) ?? detectDeviceLanguage();
|
|
2230
|
+
const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
|
|
2231
|
+
const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
|
|
2232
|
+
// ── wav cache keyed by attachment id ──
|
|
2233
|
+
let wavPath;
|
|
2234
|
+
if (attachmentId && _voiceWavCache.has(attachmentId)) {
|
|
2235
|
+
wavPath = _voiceWavCache.get(attachmentId);
|
|
2236
|
+
if (!fs.existsSync(wavPath)) {
|
|
2237
|
+
_voiceWavCache.delete(attachmentId);
|
|
2238
|
+
wavPath = undefined;
|
|
2239
|
+
} else {
|
|
2240
|
+
process.stderr.write(`mixdog: voice.transcription wav cache hit (${attachmentId})\n`);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
if (!wavPath) {
|
|
2244
|
+
wavPath = audioPath.replace(/\.[^.]+$/, ".wav");
|
|
2245
|
+
const sampleRate = config.voice?.transcription?.sampleRate ?? 16000;
|
|
2246
|
+
const channels = config.voice?.transcription?.channels ?? 1;
|
|
2247
|
+
// Single-flight: parallel callers for the same key share one ffmpeg spawn.
|
|
2248
|
+
const _ffmpegKey = attachmentId || wavPath;
|
|
2249
|
+
if (_voiceFfmpegInflight.has(_ffmpegKey)) {
|
|
2250
|
+
await _voiceFfmpegInflight.get(_ffmpegKey);
|
|
2251
|
+
} else {
|
|
2252
|
+
const _ffmpegPromise = runCmd(ffmpegPath, ["-i", audioPath, "-ar", String(sampleRate), "-ac", String(channels), "-threads", String(threadCount), "-y", wavPath]);
|
|
2253
|
+
_voiceFfmpegInflight.set(_ffmpegKey, _ffmpegPromise);
|
|
2254
|
+
try {
|
|
2255
|
+
await _ffmpegPromise;
|
|
2256
|
+
if (attachmentId) _voiceWavCache.set(attachmentId, wavPath);
|
|
2257
|
+
} finally {
|
|
2258
|
+
_voiceFfmpegInflight.delete(_ffmpegKey);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
process.stderr.write(`mixdog: voice.transcription start runtime=${runtime.kind} cmd=${path.basename(whisperCmd)}\n`);
|
|
2263
|
+
await ensureReady({ serverCmd: runtime.serverCmd, modelPath, threadCount, host: '127.0.0.1' });
|
|
2264
|
+
const text = await transcribe(wavPath, { language: lang });
|
|
2265
|
+
const result = text.trim() || null;
|
|
2266
|
+
if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
|
|
2267
|
+
return result;
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
if (err?.message?.startsWith('voice runtime not installed')) throw err; // propagate setup errors; caller posts user-visible failure
|
|
2270
|
+
process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
|
|
2271
|
+
return null;
|
|
2272
|
+
}
|
|
2273
|
+
}
|
|
2274
|
+
import { TOOL_DEFS } from './tool-defs.mjs';
|
|
2275
|
+
function createHttpMcpServer() {
|
|
2276
|
+
const s = new Server(
|
|
2277
|
+
{ name: "mixdog", version: PLUGIN_VERSION },
|
|
2278
|
+
{ capabilities: { tools: {} }, instructions: INSTRUCTIONS }
|
|
2279
|
+
);
|
|
2280
|
+
s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
|
|
2281
|
+
s.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
2282
|
+
const toolName = req.params.name;
|
|
2283
|
+
const args = req.params.arguments ?? {};
|
|
2284
|
+
return handleToolCallWithBridgeRetry(toolName, args);
|
|
2285
|
+
});
|
|
2286
|
+
return s;
|
|
2287
|
+
}
|
|
2288
|
+
// Tool dispatch in worker mode goes through the IPC `call` handler at the
|
|
2289
|
+
// bottom of this file (parent's `callWorker` → `handleToolCall`). The HTTP
|
|
2290
|
+
// MCP path uses its own short-lived `Server` instance built by
|
|
2291
|
+
// `createHttpMcpServer()` above. There is no orphan worker-level Server.
|
|
2292
|
+
const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
|
|
2293
|
+
// ── Backend-tool dispatch helpers ───────────────────────────────────────────
|
|
2294
|
+
// Each helper transparently routes through proxyRequest() when this instance
|
|
2295
|
+
// is in proxyMode (non-owner), or through the local backend otherwise. The
|
|
2296
|
+
// MCP-result formatting (text shape, cache invalidation, isError flag) is
|
|
2297
|
+
// shared so both branches produce byte-identical output.
|
|
2298
|
+
// schedule_status / schedule_control share their result-formatting between
|
|
2299
|
+
// the local (owner) MCP case handlers and the owner-side HTTP routes that
|
|
2300
|
+
// serve proxied standby sessions. Keeping the body here makes both paths
|
|
2301
|
+
// byte-identical and reads the LIVE scheduler.
|
|
2302
|
+
function scheduleStatusResult() {
|
|
2303
|
+
const statuses = scheduler.getStatus();
|
|
2304
|
+
if (statuses.length === 0) {
|
|
2305
|
+
return { content: [{ type: "text", text: "no schedules configured" }] };
|
|
2306
|
+
}
|
|
2307
|
+
const lines = statuses.map((s) => {
|
|
2308
|
+
const state = s.running ? " [RUNNING]" : "";
|
|
2309
|
+
const last = s.lastFired ? ` (last: ${s.lastFired})` : "";
|
|
2310
|
+
return ` ${s.name} ${s.time} ${s.days} (${s.type})${state}${last}`;
|
|
2311
|
+
});
|
|
2312
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
2313
|
+
}
|
|
2314
|
+
function scheduleControlResult(args) {
|
|
2315
|
+
const scName = args.name;
|
|
2316
|
+
const action = args.action;
|
|
2317
|
+
// Validate that the named schedule actually exists.
|
|
2318
|
+
const _scAll = [...(scheduler.nonInteractive || []), ...(scheduler.interactive || [])];
|
|
2319
|
+
const _scKnown = _scAll.some(s => s.name === scName);
|
|
2320
|
+
if (!_scKnown) {
|
|
2321
|
+
return { content: [{ type: "text", text: `schedule_control: unknown schedule "${scName}" — use schedule_status to list valid names` }], isError: true };
|
|
2322
|
+
}
|
|
2323
|
+
if (action === "defer") {
|
|
2324
|
+
const minutes = args.minutes ?? 30;
|
|
2325
|
+
if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) {
|
|
2326
|
+
return { content: [{ type: "text", text: `schedule_control: minutes must be a positive number, got ${JSON.stringify(minutes)}` }], isError: true };
|
|
2327
|
+
}
|
|
2328
|
+
scheduler.defer(scName, minutes);
|
|
2329
|
+
return { content: [{ type: "text", text: `deferred "${scName}" for ${minutes} minutes` }] };
|
|
2330
|
+
} else if (action === "skip_today") {
|
|
2331
|
+
scheduler.skipToday(scName);
|
|
2332
|
+
return { content: [{ type: "text", text: `skipped "${scName}" for today` }] };
|
|
2333
|
+
}
|
|
2334
|
+
return { content: [{ type: "text", text: `unknown action: ${action}` }], isError: true };
|
|
2335
|
+
}
|
|
2336
|
+
async function dispatchReply(args) {
|
|
2337
|
+
const sendOpts = {
|
|
2338
|
+
replyTo: args.reply_to,
|
|
2339
|
+
files: args.files ?? [],
|
|
2340
|
+
embeds: args.embeds ?? [],
|
|
2341
|
+
components: args.components ?? []
|
|
2342
|
+
};
|
|
2343
|
+
let ids;
|
|
2344
|
+
if (proxyMode) {
|
|
2345
|
+
const proxyResult = await proxyRequest("/send", "POST", {
|
|
2346
|
+
chatId: args.chat_id,
|
|
2347
|
+
text: args.text,
|
|
2348
|
+
opts: sendOpts
|
|
2349
|
+
});
|
|
2350
|
+
if (!proxyResult.ok) {
|
|
2351
|
+
return { content: [{ type: "text", text: `proxy reply failed: ${proxyResult.error}` }], isError: true };
|
|
2352
|
+
}
|
|
2353
|
+
ids = proxyResult.data?.sentIds ?? [];
|
|
2354
|
+
} else {
|
|
2355
|
+
// Pre-send activity bump keeps idle gating consistent during the await.
|
|
2356
|
+
scheduler.noteActivity();
|
|
2357
|
+
const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
|
|
2358
|
+
// Lead-originated reply via proxy-mode MCP — bump activity.
|
|
2359
|
+
scheduler.noteActivity();
|
|
2360
|
+
ids = sendResult.sentIds;
|
|
2361
|
+
}
|
|
2362
|
+
const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
|
|
2363
|
+
return { content: [{ type: "text", text }] };
|
|
2364
|
+
}
|
|
2365
|
+
async function dispatchFetch(args) {
|
|
2366
|
+
const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
|
|
2367
|
+
const limit = args.limit ?? 20;
|
|
2368
|
+
let msgs;
|
|
2369
|
+
if (proxyMode) {
|
|
2370
|
+
const proxyResult = await proxyRequest(`/fetch?channel=${encodeURIComponent(channelId)}&limit=${limit}`, "GET");
|
|
2371
|
+
if (!proxyResult.ok) {
|
|
2372
|
+
return { content: [{ type: "text", text: `proxy fetch failed: ${proxyResult.error}` }], isError: true };
|
|
2373
|
+
}
|
|
2374
|
+
msgs = proxyResult.data?.messages ?? [];
|
|
2375
|
+
// recordFetchedMessages already ran on the owner side (/fetch route).
|
|
2376
|
+
} else {
|
|
2377
|
+
msgs = await backend.fetchMessages(channelId, limit);
|
|
2378
|
+
recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
|
|
2379
|
+
}
|
|
2380
|
+
const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
|
|
2381
|
+
const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
|
|
2382
|
+
return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
|
|
2383
|
+
}).join("\n");
|
|
2384
|
+
return { content: [{ type: "text", text }] };
|
|
2385
|
+
}
|
|
2386
|
+
async function dispatchReact(args) {
|
|
2387
|
+
if (proxyMode) {
|
|
2388
|
+
const proxyResult = await proxyRequest("/react", "POST", {
|
|
2389
|
+
chatId: args.chat_id,
|
|
2390
|
+
messageId: args.message_id,
|
|
2391
|
+
emoji: args.emoji
|
|
2392
|
+
});
|
|
2393
|
+
if (!proxyResult.ok) {
|
|
2394
|
+
return { content: [{ type: "text", text: `proxy react failed: ${proxyResult.error}` }], isError: true };
|
|
2395
|
+
}
|
|
2396
|
+
} else {
|
|
2397
|
+
await backend.react(args.chat_id, args.message_id, args.emoji);
|
|
2398
|
+
}
|
|
2399
|
+
return { content: [{ type: "text", text: "reacted" }] };
|
|
2400
|
+
}
|
|
2401
|
+
async function dispatchEditMessage(args) {
|
|
2402
|
+
const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
|
|
2403
|
+
let id;
|
|
2404
|
+
if (proxyMode) {
|
|
2405
|
+
const proxyResult = await proxyRequest("/edit", "POST", {
|
|
2406
|
+
chatId: args.chat_id,
|
|
2407
|
+
messageId: args.message_id,
|
|
2408
|
+
text: args.text,
|
|
2409
|
+
opts
|
|
2410
|
+
});
|
|
2411
|
+
if (!proxyResult.ok) {
|
|
2412
|
+
return { content: [{ type: "text", text: `proxy edit failed: ${proxyResult.error}` }], isError: true };
|
|
2413
|
+
}
|
|
2414
|
+
id = proxyResult.data?.id;
|
|
2415
|
+
} else {
|
|
2416
|
+
id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
|
|
2417
|
+
}
|
|
2418
|
+
return { content: [{ type: "text", text: `edited (id: ${id})` }] };
|
|
2419
|
+
}
|
|
2420
|
+
async function dispatchDownloadAttachment(args) {
|
|
2421
|
+
let files;
|
|
2422
|
+
if (proxyMode) {
|
|
2423
|
+
const proxyResult = await proxyRequest("/download", "POST", {
|
|
2424
|
+
chatId: args.chat_id,
|
|
2425
|
+
messageId: args.message_id
|
|
2426
|
+
});
|
|
2427
|
+
if (!proxyResult.ok) {
|
|
2428
|
+
return { content: [{ type: "text", text: `proxy download failed: ${proxyResult.error}` }], isError: true };
|
|
2429
|
+
}
|
|
2430
|
+
files = proxyResult.data?.files ?? [];
|
|
2431
|
+
} else {
|
|
2432
|
+
files = await backend.downloadAttachment(args.chat_id, args.message_id);
|
|
2433
|
+
}
|
|
2434
|
+
if (files.length === 0) {
|
|
2435
|
+
return { content: [{ type: "text", text: "message has no attachments" }] };
|
|
2436
|
+
}
|
|
2437
|
+
const lines = files.map(
|
|
2438
|
+
(f) => ` ${f.path} (${f.name}, ${f.contentType}, ${(f.size / 1024).toFixed(0)}KB)`
|
|
2439
|
+
);
|
|
2440
|
+
// Each downloaded file lands on the local FS; if any of them
|
|
2441
|
+
// had a stale prefetch entry from a prior session, drop it so
|
|
2442
|
+
// the next prefetch sees the fresh contents.
|
|
2443
|
+
for (const f of files) {
|
|
2444
|
+
if (f && typeof f.path === "string" && f.path) {
|
|
2445
|
+
invalidatePrefetchCache(f.path);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
return { content: [{ type: "text", text: `downloaded ${files.length} attachment(s):
|
|
2449
|
+
${lines.join("\n")}` }] };
|
|
2450
|
+
}
|
|
2451
|
+
async function handleToolCall(name, args, signal) {
|
|
2452
|
+
if (_channelsDegraded) {
|
|
2453
|
+
return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
|
|
2454
|
+
}
|
|
2455
|
+
let result;
|
|
2456
|
+
try {
|
|
2457
|
+
switch (name) {
|
|
2458
|
+
case "reply":
|
|
2459
|
+
result = await dispatchReply(args);
|
|
2460
|
+
break;
|
|
2461
|
+
case "fetch":
|
|
2462
|
+
result = await dispatchFetch(args);
|
|
2463
|
+
break;
|
|
2464
|
+
case "react":
|
|
2465
|
+
result = await dispatchReact(args);
|
|
2466
|
+
break;
|
|
2467
|
+
case "edit_message":
|
|
2468
|
+
result = await dispatchEditMessage(args);
|
|
2469
|
+
break;
|
|
2470
|
+
case "download_attachment":
|
|
2471
|
+
result = await dispatchDownloadAttachment(args);
|
|
2472
|
+
break;
|
|
2473
|
+
case "schedule_status": {
|
|
2474
|
+
if (proxyMode) {
|
|
2475
|
+
const proxyResult = await proxyRequest("/schedule-status", "GET");
|
|
2476
|
+
if (!proxyResult.ok) {
|
|
2477
|
+
result = { content: [{ type: "text", text: `proxy schedule_status failed: ${proxyResult.error}` }], isError: true };
|
|
2478
|
+
break;
|
|
2479
|
+
}
|
|
2480
|
+
result = proxyResult.data?.result ?? { content: [{ type: "text", text: "no schedules configured" }] };
|
|
2481
|
+
} else {
|
|
2482
|
+
result = scheduleStatusResult();
|
|
2483
|
+
}
|
|
2484
|
+
break;
|
|
2485
|
+
}
|
|
2486
|
+
case "trigger_schedule": {
|
|
2487
|
+
if (proxyMode) {
|
|
2488
|
+
const proxyResult = await proxyRequest("/trigger-schedule", "POST", { name: args.name });
|
|
2489
|
+
if (!proxyResult.ok) {
|
|
2490
|
+
result = { content: [{ type: "text", text: `proxy trigger_schedule failed: ${proxyResult.error}` }], isError: true };
|
|
2491
|
+
break;
|
|
2492
|
+
}
|
|
2493
|
+
const triggerResult = proxyResult.data?.result;
|
|
2494
|
+
result = { content: [{ type: "text", text: triggerResult == null ? "" : String(triggerResult) }] };
|
|
2495
|
+
} else {
|
|
2496
|
+
const triggerResult = await scheduler.triggerManual(args.name);
|
|
2497
|
+
result = { content: [{ type: "text", text: triggerResult }] };
|
|
2498
|
+
}
|
|
2499
|
+
break;
|
|
2500
|
+
}
|
|
2501
|
+
case "schedule_control": {
|
|
2502
|
+
if (proxyMode) {
|
|
2503
|
+
const proxyResult = await proxyRequest("/schedule-control", "POST", {
|
|
2504
|
+
name: args.name,
|
|
2505
|
+
action: args.action,
|
|
2506
|
+
minutes: args.minutes
|
|
2507
|
+
});
|
|
2508
|
+
if (!proxyResult.ok) {
|
|
2509
|
+
result = { content: [{ type: "text", text: `proxy schedule_control failed: ${proxyResult.error}` }], isError: true };
|
|
2510
|
+
break;
|
|
2511
|
+
}
|
|
2512
|
+
result = proxyResult.data?.result ?? { content: [{ type: "text", text: `unknown action: ${args.action}` }], isError: true };
|
|
2513
|
+
} else {
|
|
2514
|
+
result = scheduleControlResult(args);
|
|
2515
|
+
}
|
|
2516
|
+
break;
|
|
2517
|
+
}
|
|
2518
|
+
case "activate_channel_bridge": {
|
|
2519
|
+
if (proxyMode) {
|
|
2520
|
+
const proxyRes = await proxyRequest("/bridge/activate", "POST", { active: args.active === true });
|
|
2521
|
+
if (!proxyRes.ok) {
|
|
2522
|
+
result = { content: [{ type: "text", text: `proxy bridge activate failed: ${proxyRes.error}` }], isError: true };
|
|
2523
|
+
} else {
|
|
2524
|
+
channelBridgeActive = Boolean(args.active);
|
|
2525
|
+
writeBridgeState(channelBridgeActive);
|
|
2526
|
+
// Remote owner just deactivated and is tearing its owner-HTTP
|
|
2527
|
+
// server down. Drop our proxy pointer so subsequent direct
|
|
2528
|
+
// tool calls don't route through proxyRequest() to a port
|
|
2529
|
+
// about to close (ECONNREFUSED) or stripped of auth (401).
|
|
2530
|
+
if (!args.active) {
|
|
2531
|
+
proxyMode = false;
|
|
2532
|
+
ownerHttpPort = 0;
|
|
2533
|
+
}
|
|
2534
|
+
result = { content: [{ type: "text", text: `channel bridge ${args.active ? "activated" : "deactivated"}` }] };
|
|
2535
|
+
}
|
|
2536
|
+
} else {
|
|
2537
|
+
const active = args.active === true;
|
|
2538
|
+
const wasActive = channelBridgeActive;
|
|
2539
|
+
channelBridgeActive = active;
|
|
2540
|
+
writeBridgeState(active);
|
|
2541
|
+
if (active && !wasActive) {
|
|
2542
|
+
refreshBridgeOwnershipSafe({ restoreBinding: true });
|
|
2543
|
+
}
|
|
2544
|
+
if (!active && wasActive) {
|
|
2545
|
+
stopServerTyping();
|
|
2546
|
+
// Tear down the owner-side runtime so Discord/scheduler/webhook/
|
|
2547
|
+
// event-pipeline/owner-HTTP/heartbeat don't keep running on a
|
|
2548
|
+
// deactivated bridge (and to prevent this owner from later
|
|
2549
|
+
// entering proxyMode against its own port).
|
|
2550
|
+
try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
|
|
2551
|
+
process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
|
|
2552
|
+
}
|
|
2553
|
+
// Also clear proxyMode/ownerHttpPort. Without this, a session
|
|
2554
|
+
// that was acting as proxy when deactivate landed keeps the
|
|
2555
|
+
// stale flag + port set; later direct tool calls then route
|
|
2556
|
+
// through proxyRequest() to a port whose owner has just been
|
|
2557
|
+
// stopped or stripped of auth, returning ECONNREFUSED/401.
|
|
2558
|
+
if (proxyMode) {
|
|
2559
|
+
proxyMode = false;
|
|
2560
|
+
ownerHttpPort = 0;
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
|
|
2564
|
+
}
|
|
2565
|
+
break;
|
|
2566
|
+
}
|
|
2567
|
+
case "reload_config": {
|
|
2568
|
+
await reloadRuntimeConfig();
|
|
2569
|
+
result = { content: [{ type: "text", text: "config reloaded \u2014 schedules, webhooks, and events re-registered" }] };
|
|
2570
|
+
break;
|
|
2571
|
+
}
|
|
2572
|
+
case "inject_command": {
|
|
2573
|
+
const cmd = String(args?.command || "").trim();
|
|
2574
|
+
const ALLOW = new Set(["reload-plugins", "clear"]);
|
|
2575
|
+
if (!ALLOW.has(cmd)) {
|
|
2576
|
+
result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
|
|
2577
|
+
break;
|
|
2578
|
+
}
|
|
2579
|
+
if (process.platform !== "win32") {
|
|
2580
|
+
result = { content: [{ type: "text", text: "inject_command: Windows-only (uses AttachConsole + WriteConsoleInputW)" }], isError: true };
|
|
2581
|
+
break;
|
|
2582
|
+
}
|
|
2583
|
+
try {
|
|
2584
|
+
const ccPid = _resolveClaudeCodePid();
|
|
2585
|
+
const scriptPath = _injectScriptPath();
|
|
2586
|
+
const r = spawnSync("powershell", [
|
|
2587
|
+
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
|
|
2588
|
+
"-TargetPid", String(ccPid), "-Text", `/${cmd}\r`,
|
|
2589
|
+
], { encoding: "utf8", windowsHide: true });
|
|
2590
|
+
if (r.status !== 0) {
|
|
2591
|
+
const detail = (r.stderr || r.stdout || "").trim().slice(0, 400);
|
|
2592
|
+
result = { content: [{ type: "text", text: `inject_command failed (status=${r.status}): ${detail}` }], isError: true };
|
|
2593
|
+
break;
|
|
2594
|
+
}
|
|
2595
|
+
result = { content: [{ type: "text", text: `injected /${cmd} into CC pid=${ccPid}` }] };
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
result = { content: [{ type: "text", text: `inject_command error: ${err?.message || err}` }], isError: true };
|
|
2598
|
+
}
|
|
2599
|
+
break;
|
|
2600
|
+
}
|
|
2601
|
+
// memory — handled by memory-service.mjs MCP
|
|
2602
|
+
default:
|
|
2603
|
+
result = {
|
|
2604
|
+
content: [{ type: "text", text: `unknown tool: ${name}` }],
|
|
2605
|
+
isError: true
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
} catch (err) {
|
|
2609
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2610
|
+
result = {
|
|
2611
|
+
content: [{ type: "text", text: `${name} failed: ${msg}` }],
|
|
2612
|
+
isError: true
|
|
2613
|
+
};
|
|
2614
|
+
}
|
|
2615
|
+
return result;
|
|
2616
|
+
}
|
|
2617
|
+
// Bridge auto-connect retry + forwarder-aware tool dispatch wrapper. Used by
|
|
2618
|
+
// both the HTTP MCP path (createHttpMcpServer's CallTool handler can call this)
|
|
2619
|
+
// and the worker IPC handler at the bottom of this file. The pre-v0.6.7 code
|
|
2620
|
+
// registered this on the orphan worker-level `Server`, which never had a
|
|
2621
|
+
// transport, so the wrapper never actually fired. Centralised here for reuse.
|
|
2622
|
+
// Last timestamp a forwardNewText() call was dispatched (debounce for item 4).
|
|
2623
|
+
let _lastForwardMs = 0;
|
|
2624
|
+
|
|
2625
|
+
async function handleToolCallWithBridgeRetry(toolName, args, signal) {
|
|
2626
|
+
// Debounce: only forward when ≥250 ms have elapsed since the last forward,
|
|
2627
|
+
// to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
|
|
2628
|
+
const now = Date.now();
|
|
2629
|
+
if (now - _lastForwardMs >= 250) {
|
|
2630
|
+
_lastForwardMs = now;
|
|
2631
|
+
await forwarder.forwardNewText();
|
|
2632
|
+
}
|
|
2633
|
+
if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected && !proxyMode) {
|
|
2634
|
+
// Do NOT pre-claim ownership here. claimBridgeOwnership() overwrites the
|
|
2635
|
+
// active-instance advert immediately, which kicks a live owner offline if
|
|
2636
|
+
// refreshBridgeOwnership() would have otherwise discovered them via
|
|
2637
|
+
// pingOwner() and entered proxyMode. Let refreshBridgeOwnership() below
|
|
2638
|
+
// ping/proxy the existing owner first and only fall through to a takeover
|
|
2639
|
+
// when the live owner is unreachable.
|
|
2640
|
+
for (let i = 0; i < 2 && !bridgeRuntimeConnected && !proxyMode; i++) {
|
|
2641
|
+
try {
|
|
2642
|
+
await refreshBridgeOwnership();
|
|
2643
|
+
} catch {
|
|
2644
|
+
}
|
|
2645
|
+
if (!bridgeRuntimeConnected && !proxyMode) await new Promise((r) => setTimeout(r, 300));
|
|
2646
|
+
}
|
|
2647
|
+
if (!bridgeRuntimeConnected && !proxyMode) {
|
|
2648
|
+
return {
|
|
2649
|
+
content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
|
|
2650
|
+
isError: true
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
const result = await handleToolCall(toolName, args, signal);
|
|
2655
|
+
const toolLine = OutputForwarder.buildToolLine(toolName, args);
|
|
2656
|
+
if (toolLine) {
|
|
2657
|
+
// Distinct from the dispatch-log ok line (server-main.mjs): this forwards
|
|
2658
|
+
// a human-readable tool summary to Discord for the user, not operator stdout.
|
|
2659
|
+
void forwarder.forwardToolLog(toolLine, toolName, args);
|
|
2660
|
+
}
|
|
2661
|
+
return result;
|
|
2662
|
+
}
|
|
2663
|
+
const INBOUND_DEDUP_TTL = 5 * 6e4;
|
|
2664
|
+
const inboundSeen = /* @__PURE__ */ new Map();
|
|
2665
|
+
const INBOUND_DEDUP_DIR = path.join(os.tmpdir(), "mixdog-inbound");
|
|
2666
|
+
ensureDir(INBOUND_DEDUP_DIR);
|
|
2667
|
+
function writeChannelOwner(channelId) {
|
|
2668
|
+
const ownerPath = getChannelOwnerPath(channelId);
|
|
2669
|
+
try {
|
|
2670
|
+
fs.writeFileSync(ownerPath, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, updatedAt: Date.now() }));
|
|
2671
|
+
return true;
|
|
2672
|
+
} catch {
|
|
2673
|
+
return false;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
function shouldDropDuplicateInbound(msg) {
|
|
2677
|
+
const key = `${msg.chatId}:${msg.messageId}`;
|
|
2678
|
+
const now = Date.now();
|
|
2679
|
+
if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
|
|
2680
|
+
inboundSeen.set(key, now);
|
|
2681
|
+
const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
|
|
2682
|
+
try {
|
|
2683
|
+
fs.writeFileSync(marker, String(now), { flag: "wx" });
|
|
2684
|
+
} catch (e) {
|
|
2685
|
+
if (e.code === "EEXIST") {
|
|
2686
|
+
try {
|
|
2687
|
+
const stat = fs.statSync(marker);
|
|
2688
|
+
if (now - stat.mtimeMs < INBOUND_DEDUP_TTL) return true;
|
|
2689
|
+
} catch {}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
if (Math.random() < 0.1) {
|
|
2693
|
+
try {
|
|
2694
|
+
for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
|
|
2695
|
+
const fp = path.join(INBOUND_DEDUP_DIR, f);
|
|
2696
|
+
try {
|
|
2697
|
+
if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
|
|
2698
|
+
} catch {
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
} catch {
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
for (const [k, t] of inboundSeen) {
|
|
2705
|
+
if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
|
|
2706
|
+
}
|
|
2707
|
+
return false;
|
|
2708
|
+
}
|
|
2709
|
+
function resolveInboundRoute(chatId, parentChatId) {
|
|
2710
|
+
const main = config.channelsConfig?.main;
|
|
2711
|
+
const findEntry = (id) => {
|
|
2712
|
+
if (!id || !config.channelsConfig) return null;
|
|
2713
|
+
if (typeof main === "object" && main !== null && main.channelId === id) {
|
|
2714
|
+
return { label: "main", entry: main };
|
|
2715
|
+
}
|
|
2716
|
+
for (const [label, entry] of Object.entries(config.channelsConfig)) {
|
|
2717
|
+
if (typeof entry === "object" && entry !== null && entry.channelId === id) {
|
|
2718
|
+
return { label, entry };
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
return null;
|
|
2722
|
+
};
|
|
2723
|
+
// Prefer a direct channelsConfig match on the thread/channel id; fall back
|
|
2724
|
+
// to the parent channel id so thread messages inherit the parent's label
|
|
2725
|
+
// and mode (e.g. monitor) instead of being routed as untagged interactive.
|
|
2726
|
+
const direct = findEntry(chatId);
|
|
2727
|
+
if (direct) {
|
|
2728
|
+
const mode = direct.entry.mode === "monitor" ? "monitor" : (direct.entry.mode || "interactive");
|
|
2729
|
+
return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: direct.label, sourceMode: mode };
|
|
2730
|
+
}
|
|
2731
|
+
if (parentChatId) {
|
|
2732
|
+
const viaParent = findEntry(parentChatId);
|
|
2733
|
+
if (viaParent) {
|
|
2734
|
+
const mode = viaParent.entry.mode === "monitor" ? "monitor" : (viaParent.entry.mode || "interactive");
|
|
2735
|
+
return { targetChatId: chatId, sourceChatId: parentChatId, sourceLabel: viaParent.label, sourceMode: mode };
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2738
|
+
return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: undefined, sourceMode: "interactive" };
|
|
2739
|
+
}
|
|
2740
|
+
const inboundQueue = (() => {
|
|
2741
|
+
let tail = Promise.resolve();
|
|
2742
|
+
let _iqDepth = 0;
|
|
2743
|
+
const _IQ_MAX_DEPTH = 1000;
|
|
2744
|
+
return (fn) => {
|
|
2745
|
+
if (_iqDepth >= _IQ_MAX_DEPTH) {
|
|
2746
|
+
try { process.stderr.write(`mixdog: inboundQueue overflow (depth=${_iqDepth}), dropping message\n`); } catch {}
|
|
2747
|
+
return;
|
|
2748
|
+
}
|
|
2749
|
+
_iqDepth++;
|
|
2750
|
+
tail = Promise.resolve(tail).then(fn).catch((err) => {
|
|
2751
|
+
try { process.stderr.write(`mixdog: inboundQueue error: ${err && err.message || err}\n`); } catch {}
|
|
2752
|
+
}).finally(() => { _iqDepth--; });
|
|
2753
|
+
};
|
|
2754
|
+
})();
|
|
2755
|
+
// ── Reverse-lookup channelId → human label from channelsConfig ──────────────
|
|
2756
|
+
function labelForChannelId(channelId) {
|
|
2757
|
+
if (!channelId || !config.channelsConfig) return channelId;
|
|
2758
|
+
for (const [label, entry] of Object.entries(config.channelsConfig)) {
|
|
2759
|
+
if (entry?.channelId === channelId) return label;
|
|
2760
|
+
}
|
|
2761
|
+
return channelId;
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
backend.onMessage = (msg) => {
|
|
2765
|
+
const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
|
|
2766
|
+
const onMessageAtMs = Date.now();
|
|
2767
|
+
if (!bridgeRuntimeConnected || !getBridgeOwnershipSnapshot().owned) {
|
|
2768
|
+
refreshBridgeOwnershipSafe();
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
if (!channelBridgeActive) return;
|
|
2772
|
+
if (shouldDropDuplicateInbound(msg)) return;
|
|
2773
|
+
recordFetchedMessages(msg.chatId, labelForChannelId(msg.chatId), [{ id: msg.messageId }], { markRead: true });
|
|
2774
|
+
if (!writeChannelOwner(msg.chatId)) return;
|
|
2775
|
+
const route = resolveInboundRoute(msg.chatId, msg.parentChatId);
|
|
2776
|
+
scheduler.noteActivity();
|
|
2777
|
+
startServerTyping(route.targetChatId);
|
|
2778
|
+
backend.resetSendCount();
|
|
2779
|
+
// Pin the prior turn's bound channel before this fire-and-forget flush so the
|
|
2780
|
+
// imminent rebind below (which mutates forwarder.channelId synchronously)
|
|
2781
|
+
// cannot redirect the previous turn's final output to the new channel.
|
|
2782
|
+
const priorForwardChannelId = forwarder.channelId || null;
|
|
2783
|
+
void forwarder.forwardFinalText(0, priorForwardChannelId).catch((err) => {
|
|
2784
|
+
try { process.stderr.write(`mixdog: forwardFinalText rejection: ${err?.stack || err}\n`); } catch {}
|
|
2785
|
+
}).finally(() => forwarder.reset());
|
|
2786
|
+
const previousPath = getPersistedTranscriptPath();
|
|
2787
|
+
let boundTranscript = null;
|
|
2788
|
+
let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
|
|
2789
|
+
if (transcriptPath) {
|
|
2790
|
+
boundTranscript = {
|
|
2791
|
+
sessionId: sessionIdFromTranscriptPath(transcriptPath),
|
|
2792
|
+
sessionCwd: statusState.read().sessionCwd ?? null,
|
|
2793
|
+
transcriptPath,
|
|
2794
|
+
exists: true
|
|
2795
|
+
};
|
|
2796
|
+
} else {
|
|
2797
|
+
boundTranscript = discoverSessionBoundTranscript();
|
|
2798
|
+
transcriptPath = pickUsableTranscriptPath(boundTranscript, previousPath);
|
|
2799
|
+
// Only fall back to latest-by-mtime when discovery did NOT produce a
|
|
2800
|
+
// confident, existing current-session transcript. detectCurrentSessionTranscript()
|
|
2801
|
+
// already weighs mtime (with a 30s decisive threshold) against active-pid /
|
|
2802
|
+
// cwd affinity, so overriding a real detected binding with the raw newest
|
|
2803
|
+
// file would clobber the current session with an unrelated, more-recently
|
|
2804
|
+
// touched transcript (wrong-session forward).
|
|
2805
|
+
if (!boundTranscript?.exists) {
|
|
2806
|
+
const latestByMtime = findLatestTranscriptByMtime(boundTranscript?.sessionCwd);
|
|
2807
|
+
if (latestByMtime && latestByMtime !== transcriptPath) {
|
|
2808
|
+
transcriptPath = latestByMtime;
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
if (transcriptPath) {
|
|
2813
|
+
applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
|
|
2814
|
+
} else {
|
|
2815
|
+
refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId });
|
|
2816
|
+
}
|
|
2817
|
+
void (async () => {
|
|
2818
|
+
try {
|
|
2819
|
+
await backend.react(msg.chatId, msg.messageId, "\u{1F914}");
|
|
2820
|
+
} catch {
|
|
2821
|
+
}
|
|
2822
|
+
statusState.update((state) => {
|
|
2823
|
+
state.channelId = route.targetChatId;
|
|
2824
|
+
state.userMessageId = msg.messageId;
|
|
2825
|
+
state.emoji = "\u{1F914}";
|
|
2826
|
+
state.sentCount = 0;
|
|
2827
|
+
state.sessionIdle = false;
|
|
2828
|
+
if (transcriptPath) state.transcriptPath = transcriptPath;
|
|
2829
|
+
else delete state.transcriptPath;
|
|
2830
|
+
state.sessionCwd = boundTranscript?.sessionCwd ?? null;
|
|
2831
|
+
});
|
|
2832
|
+
if (!boundTranscript?.exists) {
|
|
2833
|
+
await rebindTranscriptContext(route.targetChatId, {
|
|
2834
|
+
previousPath: transcriptPath,
|
|
2835
|
+
catchUp: true,
|
|
2836
|
+
persistStatus: true
|
|
2837
|
+
});
|
|
2838
|
+
}
|
|
2839
|
+
})();
|
|
2840
|
+
const queuedAtMs = Date.now();
|
|
2841
|
+
const preQueueMs = queuedAtMs - onMessageAtMs;
|
|
2842
|
+
const gatewayToQueueMs = queuedAtMs - receivedAtMs;
|
|
2843
|
+
if (preQueueMs > 250 || gatewayToQueueMs > 500) {
|
|
2844
|
+
process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
|
|
2845
|
+
}
|
|
2846
|
+
inboundQueue(() => handleInbound(msg, route, {
|
|
2847
|
+
sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
|
|
2848
|
+
receivedAtMs,
|
|
2849
|
+
queuedAtMs
|
|
2850
|
+
}).catch((err) => {
|
|
2851
|
+
process.stderr.write(`mixdog: handleInbound error: ${err}
|
|
2852
|
+
`);
|
|
2853
|
+
}).finally(() => {
|
|
2854
|
+
stopServerTyping();
|
|
2855
|
+
}));
|
|
2856
|
+
};
|
|
2857
|
+
async function handleInbound(msg, route, options = {}) {
|
|
2858
|
+
const handleStartMs = Date.now();
|
|
2859
|
+
let text = msg.text;
|
|
2860
|
+
const voiceAtts = msg.attachments.filter((a) => isVoiceAttachment(a.contentType));
|
|
2861
|
+
if (voiceAtts.length > 0) {
|
|
2862
|
+
try {
|
|
2863
|
+
const files = await backend.downloadAttachment(msg.chatId, msg.messageId);
|
|
2864
|
+
// concurrency handled inside transcribeVoice queue; loop is sequential so last att wins
|
|
2865
|
+
for (const f of voiceAtts.map(a => files.find(df => df.id === a.id) ?? null).filter(Boolean)) {
|
|
2866
|
+
const _t0 = Date.now();
|
|
2867
|
+
const transcript = await transcribeVoice(f.path, { attachmentId: f.id });
|
|
2868
|
+
const _elapsed = Date.now() - _t0;
|
|
2869
|
+
if (transcript) {
|
|
2870
|
+
text = transcript;
|
|
2871
|
+
process.stderr.write(`mixdog: voice.transcription ok (${f.name}, ${_elapsed}ms): ${transcript.slice(0, 50)}\n`);
|
|
2872
|
+
} else {
|
|
2873
|
+
process.stderr.write(`mixdog: voice.transcription empty (${f.name})\n`);
|
|
2874
|
+
text = text || "[voice message \u2014 transcription failed]";
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
process.stderr.write(`mixdog: voice.transcription error: ${err}\n`);
|
|
2879
|
+
text = text || `[voice message \u2014 transcription error: ${err?.message || err}]`;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
const hasVoiceAtt = voiceAtts.length > 0;
|
|
2883
|
+
const attMeta = msg.attachments.length > 0 && !hasVoiceAtt ? {
|
|
2884
|
+
attachment_count: String(msg.attachments.length),
|
|
2885
|
+
attachments: msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(0)}KB)`).join("; ")
|
|
2886
|
+
} : {};
|
|
2887
|
+
const messageBody = route.sourceMode === "monitor" && route.sourceLabel ? `[monitor:${route.sourceLabel}] ${text}` : text;
|
|
2888
|
+
const now = (/* @__PURE__ */ new Date()).toLocaleString();
|
|
2889
|
+
const notificationMeta = {
|
|
2890
|
+
chat_id: route.targetChatId,
|
|
2891
|
+
message_id: msg.messageId,
|
|
2892
|
+
user: msg.user,
|
|
2893
|
+
user_id: msg.userId,
|
|
2894
|
+
ts: msg.ts,
|
|
2895
|
+
...route.sourceMode === "monitor" ? {
|
|
2896
|
+
source_chat_id: route.sourceChatId,
|
|
2897
|
+
source_mode: route.sourceMode,
|
|
2898
|
+
...route.sourceLabel ? { source_label: route.sourceLabel } : {}
|
|
2899
|
+
} : {},
|
|
2900
|
+
...attMeta,
|
|
2901
|
+
...msg.imagePath ? { image_path: msg.imagePath } : {}
|
|
2902
|
+
};
|
|
2903
|
+
const notificationContent = `[${now}]
|
|
2904
|
+
${messageBody}`;
|
|
2905
|
+
sendNotifyToParent("notifications/claude/channel", {
|
|
2906
|
+
content: notificationContent,
|
|
2907
|
+
meta: notificationMeta
|
|
2908
|
+
});
|
|
2909
|
+
const notifiedAtMs = Date.now();
|
|
2910
|
+
const receivedAtMs = Number.isFinite(options.receivedAtMs) ? options.receivedAtMs : handleStartMs;
|
|
2911
|
+
const queuedAtMs = Number.isFinite(options.queuedAtMs) ? options.queuedAtMs : handleStartMs;
|
|
2912
|
+
const queueMs = handleStartMs - queuedAtMs;
|
|
2913
|
+
const handleMs = notifiedAtMs - handleStartMs;
|
|
2914
|
+
const totalMs = notifiedAtMs - receivedAtMs;
|
|
2915
|
+
if (queueMs > 250 || handleMs > 250 || totalMs > 500) {
|
|
2916
|
+
process.stderr.write(`mixdog: inbound latency delivered total=${totalMs}ms queue=${queueMs}ms handle=${handleMs}ms channel=${route.targetChatId} attachments=${msg.attachments.length}\n`);
|
|
2917
|
+
}
|
|
2918
|
+
void memoryAppendEntry({
|
|
2919
|
+
ts: msg.ts,
|
|
2920
|
+
role: "user",
|
|
2921
|
+
content: messageBody,
|
|
2922
|
+
sourceRef: `discord:${route.targetChatId}#${msg.messageId}`,
|
|
2923
|
+
sessionId: `discord:${route.targetChatId}`,
|
|
2924
|
+
cwd: statusState.read().sessionCwd,
|
|
2925
|
+
});
|
|
2926
|
+
}
|
|
2927
|
+
async function init(_sharedMcp) {
|
|
2928
|
+
// _sharedMcp is no longer used. Notifications now flow via IPC to the parent
|
|
2929
|
+
// (sendNotifyToParent above). The parameter is retained for backward
|
|
2930
|
+
// compatibility with any caller that still passes a Server reference.
|
|
2931
|
+
scheduler.setInjectHandler((channelId, name, content, options) => {
|
|
2932
|
+
injectAndRecord(channelId, name, content, options);
|
|
2933
|
+
});
|
|
2934
|
+
}
|
|
2935
|
+
async function start() {
|
|
2936
|
+
channelBridgeActive = true;
|
|
2937
|
+
writeBridgeState(true);
|
|
2938
|
+
await refreshBridgeOwnership({ restoreBinding: true });
|
|
2939
|
+
// Pre-warm the whisper-server manager once at owner startup so the first
|
|
2940
|
+
// voice transcription does not pay cold-start cost. Non-blocking: failures
|
|
2941
|
+
// (e.g. runtime not installed) are swallowed; per-request ensureReady retries.
|
|
2942
|
+
void (async () => {
|
|
2943
|
+
try {
|
|
2944
|
+
const runtime = resolveVoiceRuntime(DATA_DIR);
|
|
2945
|
+
if (!runtime?.installed) return;
|
|
2946
|
+
const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
|
|
2947
|
+
const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
|
|
2948
|
+
await ensureReady({ serverCmd: runtime.serverCmd, modelPath: runtime.modelPath, threadCount, host: '127.0.0.1' });
|
|
2949
|
+
} catch (err) {
|
|
2950
|
+
try { process.stderr.write(`mixdog: voice.transcription pre-warm skipped: ${err}\n`); } catch {}
|
|
2951
|
+
}
|
|
2952
|
+
})();
|
|
2953
|
+
}
|
|
2954
|
+
async function stop() {
|
|
2955
|
+
try { await stopVoiceWhisperServer(); } catch {}
|
|
2956
|
+
await stopOwnedRuntime("unified server stop");
|
|
2957
|
+
cleanupInstanceRuntimeFiles(INSTANCE_ID);
|
|
2958
|
+
if (bridgeOwnershipTimer) {
|
|
2959
|
+
clearInterval(bridgeOwnershipTimer);
|
|
2960
|
+
bridgeOwnershipTimer = null;
|
|
2961
|
+
}
|
|
2962
|
+
if (turnEndWatcher) {
|
|
2963
|
+
try { turnEndWatcher.close(); } catch {}
|
|
2964
|
+
turnEndWatcher = null;
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2967
|
+
{
|
|
2968
|
+
let detectChannelFlag = function() {
|
|
2969
|
+
const isWin = process.platform === "win32";
|
|
2970
|
+
const flagRe = /--channels\b|--dangerously-load-development-channels\b/;
|
|
2971
|
+
if (process.env.MIXDOG_CHANNEL_FLAG === "1") return true;
|
|
2972
|
+
if (process.env.MIXDOG_CHANNEL_FLAG === "0") return false;
|
|
2973
|
+
if (isWin) {
|
|
2974
|
+
// Single CIM snapshot + in-process chain walk: one powershell.exe spawn
|
|
2975
|
+
// instead of up to 12 synchronous wmic/powershell spawns. Snapshots all
|
|
2976
|
+
// processes into a map, walks from process.ppid up to 6 ancestors
|
|
2977
|
+
// (closest first), and emits each ancestor CommandLine on its own line
|
|
2978
|
+
// for the same flagRe test below. Any failure returns false.
|
|
2979
|
+
try {
|
|
2980
|
+
const ps = [
|
|
2981
|
+
'$procs = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine;',
|
|
2982
|
+
'$map = @{};',
|
|
2983
|
+
'foreach ($p in $procs) { $map[[int]$p.ProcessId] = $p }',
|
|
2984
|
+
`$cur = ${Number(process.ppid)};`,
|
|
2985
|
+
'for ($i = 0; $i -lt 6; $i++) {',
|
|
2986
|
+
' if (-not $cur -or $cur -le 1) { break }',
|
|
2987
|
+
' $p = $map[[int]$cur]; if ($null -eq $p) { break }',
|
|
2988
|
+
' [Console]::WriteLine($p.CommandLine);',
|
|
2989
|
+
' $next = [int]$p.ParentProcessId;',
|
|
2990
|
+
' if ($next -eq [int]$cur -or $next -le 1) { break }',
|
|
2991
|
+
' $cur = $next',
|
|
2992
|
+
'}',
|
|
2993
|
+
].join(" ");
|
|
2994
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-Command", ps], {
|
|
2995
|
+
encoding: "utf8",
|
|
2996
|
+
timeout: 5e3,
|
|
2997
|
+
windowsHide: true,
|
|
2998
|
+
});
|
|
2999
|
+
const out = String(r.stdout || "");
|
|
3000
|
+
for (const line of out.split(/\r?\n/)) {
|
|
3001
|
+
if (flagRe.test(line)) return true;
|
|
3002
|
+
}
|
|
3003
|
+
} catch {}
|
|
3004
|
+
return false;
|
|
3005
|
+
}
|
|
3006
|
+
let pid = process.ppid;
|
|
3007
|
+
for (let depth = 0; pid && pid > 1 && depth < 6; depth++) {
|
|
3008
|
+
try {
|
|
3009
|
+
const cmdLine = execSync(`ps -p ${pid} -o args=`, { encoding: "utf8", timeout: 3e3, windowsHide: true });
|
|
3010
|
+
if (flagRe.test(cmdLine)) return true;
|
|
3011
|
+
pid = parseInt(execSync(`ps -p ${pid} -o ppid=`, { encoding: "utf8", timeout: 3e3, windowsHide: true }).trim(), 10);
|
|
3012
|
+
} catch {
|
|
3013
|
+
break;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
return false;
|
|
3017
|
+
};
|
|
3018
|
+
_channelFlagDetected = detectChannelFlag();
|
|
3019
|
+
fs.appendFileSync(_bootLog, `[${localTimestamp()}] channelFlag: ${_channelFlagDetected}
|
|
3020
|
+
`);
|
|
3021
|
+
if (_channelFlagDetected) {
|
|
3022
|
+
channelBridgeActive = true;
|
|
3023
|
+
fs.appendFileSync(_bootLog, `[${localTimestamp()}] channel mode detected \u2014 bridge auto-activated
|
|
3024
|
+
`);
|
|
3025
|
+
}
|
|
3026
|
+
writeBridgeState(channelBridgeActive);
|
|
3027
|
+
const previousOwner = readActiveInstance();
|
|
3028
|
+
noteStartupHandoff(previousOwner);
|
|
3029
|
+
// Do not claim ownership just because this terminal is channel-capable.
|
|
3030
|
+
// refreshBridgeOwnership() below pings/proxies a live owner first and only
|
|
3031
|
+
// claims when there is no reachable active owner or the record is stale.
|
|
3032
|
+
const _bindingReadyStart = Date.now();
|
|
3033
|
+
void refreshBridgeOwnership({ restoreBinding: true }).then(
|
|
3034
|
+
(v) => {
|
|
3035
|
+
bindingReadyStatus = "resolved";
|
|
3036
|
+
dropTrace("bindingReady.resolve", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus });
|
|
3037
|
+
_bindingReadyResolve(v);
|
|
3038
|
+
},
|
|
3039
|
+
(e) => {
|
|
3040
|
+
bindingReadyStatus = "rejected";
|
|
3041
|
+
dropTrace("bindingReady.reject", { elapsedMs: Date.now() - _bindingReadyStart, status: bindingReadyStatus, err: String(e) });
|
|
3042
|
+
_bindingReadyResolve(e);
|
|
3043
|
+
}
|
|
3044
|
+
);
|
|
3045
|
+
bridgeOwnershipTimer = setInterval(() => {
|
|
3046
|
+
refreshBridgeOwnershipSafe();
|
|
3047
|
+
}, 3e3);
|
|
3048
|
+
// Hook/statusline IPC is owned by the MCP parent process so it is available
|
|
3049
|
+
// before channels finishes bridge ownership and backend startup.
|
|
3050
|
+
const configPath = path.join(DATA_DIR, "mixdog-config.json");
|
|
3051
|
+
let reloadDebounce = null;
|
|
3052
|
+
let configWatcher = null;
|
|
3053
|
+
try {
|
|
3054
|
+
configWatcher = fs.watch(configPath, () => {
|
|
3055
|
+
if (reloadDebounce) clearTimeout(reloadDebounce);
|
|
3056
|
+
reloadDebounce = setTimeout(() => {
|
|
3057
|
+
reloadRuntimeConfig().catch(() => {});
|
|
3058
|
+
}, 500);
|
|
3059
|
+
});
|
|
3060
|
+
} catch {
|
|
3061
|
+
}
|
|
3062
|
+
process.on("exit", () => {
|
|
3063
|
+
if (configWatcher) { try { configWatcher.close(); } catch {} }
|
|
3064
|
+
if (bridgeOwnershipTimer) { clearInterval(bridgeOwnershipTimer); }
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
// ── IPC worker mode ──────────────────────────────────────────────
|
|
3068
|
+
if (_isWorkerMode && process.send) {
|
|
3069
|
+
// SIGTERM/SIGINT/IPC shutdown handler — mirrors src/memory/index.mjs pattern.
|
|
3070
|
+
// Cleans up in-progress webhook/scheduler state, removes runtime files, then exits.
|
|
3071
|
+
let _channelsStopInFlight = false
|
|
3072
|
+
const _channelsShutdownHandler = async (sig) => {
|
|
3073
|
+
if (_channelsStopInFlight) {
|
|
3074
|
+
process.stderr.write(`[channels-worker] ${sig} — shutdown already in flight, ignoring\n`)
|
|
3075
|
+
return
|
|
3076
|
+
}
|
|
3077
|
+
_channelsStopInFlight = true
|
|
3078
|
+
process.stderr.write(`[channels-worker] received ${sig} — shutting down cleanly\n`)
|
|
3079
|
+
try { await stopVoiceWhisperServer() } catch (e) {
|
|
3080
|
+
process.stderr.write(`[channels-worker] stopVoiceWhisperServer() error on ${sig}: ${e && (e.message || e)}\n`)
|
|
3081
|
+
}
|
|
3082
|
+
try { await stop() } catch (e) {
|
|
3083
|
+
process.stderr.write(`[channels-worker] stop() error on ${sig}: ${e && (e.message || e)}\n`)
|
|
3084
|
+
}
|
|
3085
|
+
try { cleanupInstanceRuntimeFiles(INSTANCE_ID) } catch {}
|
|
3086
|
+
try { clearServerPid() } catch {}
|
|
3087
|
+
process.exit(0)
|
|
3088
|
+
}
|
|
3089
|
+
process.on('SIGTERM', () => _channelsShutdownHandler('SIGTERM'))
|
|
3090
|
+
process.on('SIGINT', () => _channelsShutdownHandler('SIGINT'))
|
|
3091
|
+
|
|
3092
|
+
// Map of callId → AbortController for in-flight IPC calls.
|
|
3093
|
+
const _inFlightChannelCalls = new Map()
|
|
3094
|
+
|
|
3095
|
+
process.on('message', async (msg) => {
|
|
3096
|
+
// Parent-initiated graceful shutdown — mirrors memory worker IPC pattern.
|
|
3097
|
+
if (msg && msg.type === 'shutdown') {
|
|
3098
|
+
process.stderr.write('[channels-worker] received IPC shutdown — calling stop()\n')
|
|
3099
|
+
_channelsShutdownHandler('IPC:shutdown')
|
|
3100
|
+
return
|
|
3101
|
+
}
|
|
3102
|
+
// Silent-to-agent lifecycle forward — parent (server.mjs) asks the
|
|
3103
|
+
// channels worker to post status pings to the active bridge Discord
|
|
3104
|
+
// channel without the Lead-notify hop. Best-effort: unknown channel or
|
|
3105
|
+
// backend failure is swallowed; lifecycle pings are non-critical.
|
|
3106
|
+
if (msg && msg.type === 'forward_to_discord') {
|
|
3107
|
+
try {
|
|
3108
|
+
const target = msg.channelId
|
|
3109
|
+
|| (statusState?.read?.().channelId)
|
|
3110
|
+
|| null;
|
|
3111
|
+
if (target && backend?.sendMessage && typeof msg.content === 'string' && msg.content) {
|
|
3112
|
+
await backend.sendMessage(target, msg.content).catch(() => {});
|
|
3113
|
+
}
|
|
3114
|
+
} catch { /* best-effort */ }
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
// Claude Code permission request → Discord Allow/Deny prompt.
|
|
3118
|
+
// Parent (server.mjs) receives notifications/claude/channel/permission_request
|
|
3119
|
+
// from Claude Code and forwards the params here. We post a buttoned message;
|
|
3120
|
+
// button clicks are handled in backend.onInteraction and sent back to CC as
|
|
3121
|
+
// notifications/claude/channel/permission via sendNotifyToParent.
|
|
3122
|
+
if (msg && msg.type === 'permission_request_inbound') {
|
|
3123
|
+
try {
|
|
3124
|
+
const { request_id, tool_name, description, input_preview } = msg.params || {};
|
|
3125
|
+
// tool_input arrives via the passthrough() schema in server.mjs when
|
|
3126
|
+
// Claude Code includes it in the permission_request notification.
|
|
3127
|
+
// Used to bind the pendingPermRequest to a specific file so two
|
|
3128
|
+
// concurrent Edit/Write requests cannot cross-approve via the
|
|
3129
|
+
// terminal signal.
|
|
3130
|
+
const toolInputParam = (msg.params && (msg.params.tool_input || msg.params.toolInput)) || {};
|
|
3131
|
+
const filePathParam = toolInputParam.file_path || '';
|
|
3132
|
+
if (!request_id || !tool_name) return;
|
|
3133
|
+
if (pendingPermRequests.size > 100) {
|
|
3134
|
+
const cutoff = Date.now() - 30 * 60 * 1000;
|
|
3135
|
+
for (const [k, v] of pendingPermRequests) {
|
|
3136
|
+
if (v.createdAt < cutoff) pendingPermRequests.delete(k);
|
|
3137
|
+
}
|
|
3138
|
+
refreshToolExecConsumerMarker();
|
|
3139
|
+
}
|
|
3140
|
+
const mainLabel = config?.mainChannel || 'main';
|
|
3141
|
+
const target = (statusState?.read?.().channelId)
|
|
3142
|
+
|| resolveChannelLabel(config?.channelsConfig, mainLabel)
|
|
3143
|
+
|| null;
|
|
3144
|
+
if (!target || !backend?.sendMessage) {
|
|
3145
|
+
process.stderr.write(`mixdog channels: permission_request dropped, no target channel (request_id=${request_id})\n`);
|
|
3146
|
+
return;
|
|
3147
|
+
}
|
|
3148
|
+
const lines = [`🔐 **Permission Request**`, `Tool: \`${tool_name}\``];
|
|
3149
|
+
if (description) lines.push(description);
|
|
3150
|
+
if (input_preview) lines.push('```\n' + String(input_preview).slice(0, 800) + '\n```');
|
|
3151
|
+
const content = lines.join('\n');
|
|
3152
|
+
const components = [{
|
|
3153
|
+
type: 1,
|
|
3154
|
+
components: [
|
|
3155
|
+
{ type: 2, style: 3, label: 'Allow', custom_id: `perm-ch-${request_id}-allow` },
|
|
3156
|
+
{ type: 2, style: 1, label: 'Session Allow', custom_id: `perm-ch-${request_id}-session` },
|
|
3157
|
+
{ type: 2, style: 4, label: 'Deny', custom_id: `perm-ch-${request_id}-deny` },
|
|
3158
|
+
],
|
|
3159
|
+
}];
|
|
3160
|
+
let sentIds = null;
|
|
3161
|
+
try {
|
|
3162
|
+
const sendResult = await backend.sendMessage(target, content, { components });
|
|
3163
|
+
sentIds = sendResult?.sentIds;
|
|
3164
|
+
} catch (err) {
|
|
3165
|
+
process.stderr.write(`mixdog channels: permission_request Discord send failed: ${err && err.message || err}\n`);
|
|
3166
|
+
return;
|
|
3167
|
+
}
|
|
3168
|
+
const messageId = Array.isArray(sentIds) && sentIds.length > 0 ? sentIds[0] : null;
|
|
3169
|
+
pendingPermRequests.set(request_id, {
|
|
3170
|
+
toolName: tool_name,
|
|
3171
|
+
filePath: filePathParam,
|
|
3172
|
+
createdAt: Date.now(),
|
|
3173
|
+
channelId: target,
|
|
3174
|
+
messageId,
|
|
3175
|
+
});
|
|
3176
|
+
refreshToolExecConsumerMarker();
|
|
3177
|
+
} catch (err) {
|
|
3178
|
+
try { process.stderr.write(`mixdog channels: permission_request handler error: ${err && err.message || err}\n`); } catch {}
|
|
3179
|
+
}
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
if (msg && msg.type === 'memory_call_response' && msg.callId) {
|
|
3183
|
+
// Response side of the worker → parent → memory bridge. Routed into
|
|
3184
|
+
// this existing listener (instead of a second process.on('message'))
|
|
3185
|
+
// to keep IPC dispatch in one place.
|
|
3186
|
+
const pending = _memoryCallPending.get(msg.callId);
|
|
3187
|
+
if (!pending) return;
|
|
3188
|
+
_memoryCallPending.delete(msg.callId);
|
|
3189
|
+
if (msg.ok) pending.resolve(msg.result);
|
|
3190
|
+
else pending.reject(new Error(msg.error || 'memory_call failed'));
|
|
3191
|
+
return;
|
|
3192
|
+
}
|
|
3193
|
+
if (msg.type === 'cancel' && msg.callId) {
|
|
3194
|
+
const entry = _inFlightChannelCalls.get(msg.callId)
|
|
3195
|
+
if (entry) {
|
|
3196
|
+
entry.abort()
|
|
3197
|
+
_inFlightChannelCalls.delete(msg.callId)
|
|
3198
|
+
}
|
|
3199
|
+
process.send({ type: 'result', callId: msg.callId, error: 'cancelled' })
|
|
3200
|
+
return
|
|
3201
|
+
}
|
|
3202
|
+
if (msg.type !== 'call' || !msg.callId) return
|
|
3203
|
+
try {
|
|
3204
|
+
const ac = new AbortController()
|
|
3205
|
+
_inFlightChannelCalls.set(msg.callId, ac)
|
|
3206
|
+
let result
|
|
3207
|
+
try {
|
|
3208
|
+
result = await handleToolCallWithBridgeRetry(msg.name, msg.args || {}, ac.signal)
|
|
3209
|
+
} finally {
|
|
3210
|
+
_inFlightChannelCalls.delete(msg.callId)
|
|
3211
|
+
}
|
|
3212
|
+
process.send({ type: 'result', callId: msg.callId, result })
|
|
3213
|
+
} catch (e) {
|
|
3214
|
+
process.send({ type: 'result', callId: msg.callId, error: e.message })
|
|
3215
|
+
}
|
|
3216
|
+
})
|
|
3217
|
+
process.send({ type: 'ready', channelFlag: _channelFlagDetected })
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
export {
|
|
3221
|
+
TOOL_DEFS,
|
|
3222
|
+
handleToolCall,
|
|
3223
|
+
init,
|
|
3224
|
+
INSTRUCTIONS as instructions,
|
|
3225
|
+
isChannelBridgeActive,
|
|
3226
|
+
isChannelsDegraded,
|
|
3227
|
+
start,
|
|
3228
|
+
stop
|
|
3229
|
+
};
|