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
package/server-main.mjs
ADDED
|
@@ -0,0 +1,3055 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* mixdog — MCP server entry point.
|
|
4
|
+
*
|
|
5
|
+
* Four modules (channels, memory, search, agent) exposed over a single
|
|
6
|
+
* MCP server. Tool routing is driven by the static manifest in tools.json,
|
|
7
|
+
* which records the owning module for every tool.
|
|
8
|
+
*
|
|
9
|
+
* Module lifecycle:
|
|
10
|
+
* • memory — eager init right after the MCP handshake completes,
|
|
11
|
+
* because channels depends on it for episode delivery.
|
|
12
|
+
* • channels — eager init (runs background workers: Discord gateway,
|
|
13
|
+
* scheduler, webhook, event pipeline). Started after memory is ready.
|
|
14
|
+
* • search / agent — eager init after MCP handshake.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
19
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
20
|
+
import { z } from 'zod'
|
|
21
|
+
import { fork, spawnSync } from 'child_process'
|
|
22
|
+
import { randomUUID, createHash } from 'crypto'
|
|
23
|
+
|
|
24
|
+
// Per-process instance id. One server-main = one stdio MCP client = one
|
|
25
|
+
// logical Claude Code session. memory.entries / trace.entries carry this id
|
|
26
|
+
// in the existing session_id column so multi-terminal usage keeps recall and
|
|
27
|
+
// cycle scope per-instance even though PG/memory worker are singletons.
|
|
28
|
+
// Workers inherit the same id via env (MIXDOG_SESSION_ID).
|
|
29
|
+
const SESSION_ID = randomUUID()
|
|
30
|
+
process.env.MIXDOG_OWNER_SESSION_ID = SESSION_ID
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync, watch as fsWatch, existsSync, unlinkSync, statSync, renameSync, createWriteStream, appendFileSync } from 'fs'
|
|
32
|
+
import { appendFile as appendFileAsync, writeFile as writeFileAsync } from 'fs/promises'
|
|
33
|
+
import { join, resolve as pathResolve } from 'path'
|
|
34
|
+
import { homedir, tmpdir } from 'os'
|
|
35
|
+
import { pathToFileURL } from 'url'
|
|
36
|
+
import { createRequire } from 'module'
|
|
37
|
+
import { resolvePluginData } from './src/shared/plugin-paths.mjs'
|
|
38
|
+
import { ensureDataSeeds } from './src/shared/seed.mjs'
|
|
39
|
+
import { readSection } from './src/shared/config.mjs'
|
|
40
|
+
import { withFileLockSync, writeJsonAtomicSync } from './src/shared/atomic-file.mjs'
|
|
41
|
+
import { loadConfig as loadSearchConfig } from './src/search/lib/config.mjs'
|
|
42
|
+
import { PROVIDER_CAPS } from './src/search/lib/backends/index.mjs'
|
|
43
|
+
import { captureOriginalUserCwd, pwd, rawUserCwd, readLastSessionCwd } from './src/shared/user-cwd.mjs'
|
|
44
|
+
import { resolveProjectId as _resolveProjectIdForBoot } from './src/memory/lib/project-id-resolver.mjs'
|
|
45
|
+
import { configureCacheStatsSnapshot } from './src/agent/orchestrator/session/read-dedup.mjs'
|
|
46
|
+
import { smartReadTruncate } from './src/agent/orchestrator/tools/builtin/read-formatting.mjs'
|
|
47
|
+
import { formatToolStartProgress } from './src/agent/orchestrator/tools/progress-message.mjs'
|
|
48
|
+
import { maybeRequestDefenderExclusion } from './src/setup/defender-exclusion.mjs'
|
|
49
|
+
import { isHookPipeServerStarted, startHookPipeServer, stopHookPipeServer } from './src/channels/lib/hook-pipe-server.mjs'
|
|
50
|
+
import { Session, SessionRegistry } from './src/daemon/session.mjs'
|
|
51
|
+
import { listen as daemonListen } from './src/daemon/transport.mjs'
|
|
52
|
+
import { FramedServerTransport } from './src/daemon/mcp-transport.mjs'
|
|
53
|
+
|
|
54
|
+
// silent_to_agent is an INTERNAL routing flag (consumed by the daemon router /
|
|
55
|
+
// agentNotify before any CC delivery). It must never cross to Claude Code,
|
|
56
|
+
// whose channel-notification schema is meta: Record<string,string> — a boolean
|
|
57
|
+
// would fail zod and silently drop the whole notification. Strip it from meta
|
|
58
|
+
// right before a notification is delivered to CC. (Routing has already read it.)
|
|
59
|
+
function channelNotifyParamsForCc(notification) {
|
|
60
|
+
const m = notification?.params?.meta
|
|
61
|
+
if (!m || typeof m !== 'object' || !('silent_to_agent' in m)) return notification
|
|
62
|
+
const { silent_to_agent, ...rest } = m
|
|
63
|
+
return { ...notification, params: { ...notification.params, meta: rest } }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Environment ──────────────────────────────────────────────────────
|
|
67
|
+
// Claude Code normally injects CLAUDE_PLUGIN_ROOT / CLAUDE_PLUGIN_DATA
|
|
68
|
+
// for relative-path plugin sources. For URL-based sources it may skip
|
|
69
|
+
// injection, so fall back to process.cwd() and the standard data path.
|
|
70
|
+
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || process.cwd()
|
|
71
|
+
const PLUGIN_DATA = resolvePluginData()
|
|
72
|
+
mkdirSync(PLUGIN_DATA, { recursive: true })
|
|
73
|
+
captureOriginalUserCwd() // seed single-source-of-truth cwd before any tool dispatch
|
|
74
|
+
// Session-cwd auto-init: when user-cwd.txt resolves to a directory that
|
|
75
|
+
// belongs to a known project (resolveProjectId returns non-null) OR has
|
|
76
|
+
// an ancestor `.git`, seed MIXDOG_SESSION_CWD so the cwd tool's `get`
|
|
77
|
+
// reports a usable session cwd from the first call. Skipped when the
|
|
78
|
+
// env var is already set (an outer supervisor / prior call already
|
|
79
|
+
// chose the session cwd).
|
|
80
|
+
;(() => {
|
|
81
|
+
if (typeof process.env.MIXDOG_SESSION_CWD === 'string' && process.env.MIXDOG_SESSION_CWD.length > 0) return
|
|
82
|
+
// Per-terminal restore: a dev-sync child restart drops the in-memory
|
|
83
|
+
// MIXDOG_SESSION_CWD the user chose via `cwd set` (the supervisor respawns
|
|
84
|
+
// this child with its OWN env, so the child's runtime mutation is gone).
|
|
85
|
+
// Re-seed from the supervisor-PID-keyed sentinel BEFORE the user-cwd.txt
|
|
86
|
+
// session-start default below. The key is per terminal, so this never
|
|
87
|
+
// picks up another terminal's cwd; readLastSessionCwd validates the dir
|
|
88
|
+
// still exists.
|
|
89
|
+
try {
|
|
90
|
+
const restored = readLastSessionCwd()
|
|
91
|
+
if (restored) {
|
|
92
|
+
process.env.MIXDOG_SESSION_CWD = restored
|
|
93
|
+
process.stderr.write(`[cwd-autoinit] restored MIXDOG_SESSION_CWD=${restored} (per-terminal)\n`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
let seed
|
|
98
|
+
try { seed = rawUserCwd() } catch { return }
|
|
99
|
+
if (!seed || typeof seed !== 'string') return
|
|
100
|
+
let qualifies = false
|
|
101
|
+
try { qualifies = _resolveProjectIdForBoot(seed) != null } catch {}
|
|
102
|
+
if (!qualifies) {
|
|
103
|
+
try {
|
|
104
|
+
let dir = seed
|
|
105
|
+
while (dir && dir !== pathResolve(dir, '..')) {
|
|
106
|
+
if (existsSync(join(dir, '.git'))) { qualifies = true; break }
|
|
107
|
+
const parent = pathResolve(dir, '..')
|
|
108
|
+
if (parent === dir) break
|
|
109
|
+
dir = parent
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
if (qualifies) {
|
|
114
|
+
process.env.MIXDOG_SESSION_CWD = seed
|
|
115
|
+
process.stderr.write(`[cwd-autoinit] seeded MIXDOG_SESSION_CWD=${seed}\n`)
|
|
116
|
+
}
|
|
117
|
+
})()
|
|
118
|
+
process.stderr.write(`[boot-time] tag=server-entry tMs=${Date.now()}\n`)
|
|
119
|
+
try { ensureDataSeeds(PLUGIN_DATA) } catch {}
|
|
120
|
+
configureCacheStatsSnapshot(PLUGIN_DATA)
|
|
121
|
+
|
|
122
|
+
// Hook/statusline IPC is a singleton named pipe. In multi-terminal mode only
|
|
123
|
+
// the active terminal owner may hold it; otherwise a standby process can steal
|
|
124
|
+
// statusline / hook traffic from the real active-instance owner.
|
|
125
|
+
let hookPipeStarted = false
|
|
126
|
+
let hookPipeOwnershipTimer = null
|
|
127
|
+
const HOOK_PIPE_OWNER_CHECK_MS = 1000
|
|
128
|
+
const HOOK_PIPE_LEAD_PID = (() => {
|
|
129
|
+
const pid = Number(process.env.MIXDOG_SUPERVISOR_PID)
|
|
130
|
+
return Number.isFinite(pid) && pid > 0 ? pid : process.pid
|
|
131
|
+
})()
|
|
132
|
+
const RUNTIME_ROOT = process.env.MIXDOG_RUNTIME_ROOT
|
|
133
|
+
? pathResolve(process.env.MIXDOG_RUNTIME_ROOT)
|
|
134
|
+
: join(tmpdir(), 'mixdog')
|
|
135
|
+
const ACTIVE_INSTANCE_FILE = join(RUNTIME_ROOT, 'active-instance.json')
|
|
136
|
+
|
|
137
|
+
function parsePositivePid(value) {
|
|
138
|
+
const pid = Number(value)
|
|
139
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isMemoryOwnerPidAlive(pid) {
|
|
143
|
+
if (pid == null) return false
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0)
|
|
146
|
+
return true
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (e && e.code === 'ESRCH') return false
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveOwnerHostPid() {
|
|
154
|
+
const fromEnv = parsePositivePid(process.env.MIXDOG_OWNER_HOST_PID)
|
|
155
|
+
if (fromEnv) return fromEnv
|
|
156
|
+
|
|
157
|
+
if (process.platform === 'win32') {
|
|
158
|
+
try {
|
|
159
|
+
const ps = [
|
|
160
|
+
'$procs = Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name;',
|
|
161
|
+
'$map = @{};',
|
|
162
|
+
'foreach ($p in $procs) { $map[[int]$p.ProcessId] = $p }',
|
|
163
|
+
`$cur = ${Number(process.pid)};`,
|
|
164
|
+
'for ($i = 0; $i -lt 16; $i++) {',
|
|
165
|
+
' $p = $map[$cur]; if ($null -eq $p) { break }',
|
|
166
|
+
" if ($p.Name -ieq 'claude.exe' -or $p.Name -ieq 'claude') { [Console]::Write($p.ProcessId); exit 0 }",
|
|
167
|
+
' $cur = [int]$p.ParentProcessId',
|
|
168
|
+
'}',
|
|
169
|
+
].join(' ')
|
|
170
|
+
const r = spawnSync('powershell.exe', ['-NoProfile', '-Command', ps], {
|
|
171
|
+
encoding: 'utf8',
|
|
172
|
+
timeout: 1500,
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
})
|
|
175
|
+
const pid = parsePositivePid(String(r.stdout || '').trim())
|
|
176
|
+
if (pid) return pid
|
|
177
|
+
} catch {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return parsePositivePid(process.ppid)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const OWNER_HOST_PID = resolveOwnerHostPid()
|
|
184
|
+
if (OWNER_HOST_PID) process.env.MIXDOG_OWNER_HOST_PID = String(OWNER_HOST_PID)
|
|
185
|
+
|
|
186
|
+
function activeOwnerLeadPid() {
|
|
187
|
+
try {
|
|
188
|
+
const active = JSON.parse(readFileSync(ACTIVE_INSTANCE_FILE, 'utf8'))
|
|
189
|
+
return parsePositivePid(active?.ownerLeadPid)
|
|
190
|
+
} catch {
|
|
191
|
+
return null
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function shouldOwnHookPipe(ownerPid = activeOwnerLeadPid()) {
|
|
196
|
+
if (!ownerPid) return process.env.MIXDOG_MULTI_INSTANCE !== '1'
|
|
197
|
+
return ownerPid === HOOK_PIPE_LEAD_PID
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function reconcileHookPipeOwnership(reason = 'timer') {
|
|
201
|
+
const ownerPid = activeOwnerLeadPid()
|
|
202
|
+
const shouldOwn = shouldOwnHookPipe(ownerPid)
|
|
203
|
+
const isStarted = isHookPipeServerStarted()
|
|
204
|
+
if (shouldOwn && !isStarted) {
|
|
205
|
+
try {
|
|
206
|
+
const server = startHookPipeServer()
|
|
207
|
+
hookPipeStarted = Boolean(server)
|
|
208
|
+
process.stderr.write(`[hook-pipe] owner lead=${HOOK_PIPE_LEAD_PID} start requested reason=${reason}\n`)
|
|
209
|
+
} catch (err) {
|
|
210
|
+
hookPipeStarted = false
|
|
211
|
+
try { process.stderr.write(`[hook-pipe] parent start failed: ${err?.message || err}\n`) } catch {}
|
|
212
|
+
}
|
|
213
|
+
} else if (!shouldOwn && (hookPipeStarted || isStarted)) {
|
|
214
|
+
try { stopHookPipeServer() } catch {}
|
|
215
|
+
hookPipeStarted = false
|
|
216
|
+
process.stderr.write(`[hook-pipe] standby lead=${HOOK_PIPE_LEAD_PID} released hook IPC reason=${reason}\n`)
|
|
217
|
+
} else if (!shouldOwn && reason === 'boot') {
|
|
218
|
+
process.stderr.write(`[hook-pipe] standby lead=${HOOK_PIPE_LEAD_PID} skip hook IPC owner=${ownerPid || 'none'}\n`)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
reconcileHookPipeOwnership('boot')
|
|
222
|
+
hookPipeOwnershipTimer = setInterval(() => reconcileHookPipeOwnership('owner-check'), HOOK_PIPE_OWNER_CHECK_MS)
|
|
223
|
+
try { hookPipeOwnershipTimer.unref?.() } catch {}
|
|
224
|
+
process.once('exit', () => {
|
|
225
|
+
if (hookPipeOwnershipTimer) {
|
|
226
|
+
try { clearInterval(hookPipeOwnershipTimer) } catch {}
|
|
227
|
+
hookPipeOwnershipTimer = null
|
|
228
|
+
}
|
|
229
|
+
if (hookPipeStarted) {
|
|
230
|
+
try { stopHookPipeServer() } catch {}
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// Singleton lock + lock-release exit handlers are owned by the prelude
|
|
235
|
+
// in server.mjs. server-main.mjs assumes the lock is already held.
|
|
236
|
+
|
|
237
|
+
globalThis.__tribFastEntry = true
|
|
238
|
+
|
|
239
|
+
// ── Module enable flags (B6 General toggles) ──────────────────────
|
|
240
|
+
// Snapshotted once at boot — toggling in the setup UI requires a full
|
|
241
|
+
// plugin restart to take effect. All four default to enabled:true when
|
|
242
|
+
// the `modules` section is absent (backcompat for pre-B6 configs).
|
|
243
|
+
const MODULE_NAMES = ['channels', 'memory', 'search', 'agent']
|
|
244
|
+
const MODULE_ENABLED = (() => {
|
|
245
|
+
const out = { channels: true, memory: true, search: true, agent: true }
|
|
246
|
+
try {
|
|
247
|
+
const raw = JSON.parse(readFileSync(join(PLUGIN_DATA, 'mixdog-config.json'), 'utf8'))
|
|
248
|
+
const mods = raw && typeof raw === 'object' ? raw.modules : null
|
|
249
|
+
if (mods && typeof mods === 'object') {
|
|
250
|
+
for (const name of MODULE_NAMES) {
|
|
251
|
+
const entry = mods[name]
|
|
252
|
+
if (entry && typeof entry === 'object' && entry.enabled === false) out[name] = false
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch { /* missing / malformed — keep all enabled */ }
|
|
256
|
+
return out
|
|
257
|
+
})()
|
|
258
|
+
const isModuleEnabled = (name) => MODULE_ENABLED[name] !== false
|
|
259
|
+
|
|
260
|
+
// ── Static manifest ─────────────────────────────────────────────────
|
|
261
|
+
// Dev-only self-heal: if a TOOL_DEFS source was edited after the last
|
|
262
|
+
// tools.json build, regenerate before loading so boot never serves a stale
|
|
263
|
+
// manifest — even when dev-sync wasn't run. The build script lives under
|
|
264
|
+
// dev/, absent from published packages (which ship a fresh prepublish
|
|
265
|
+
// manifest with no drift), so existsSync gates installed users out.
|
|
266
|
+
const _toolsJsonPath = join(PLUGIN_ROOT, 'tools.json')
|
|
267
|
+
let _selfHealError = null
|
|
268
|
+
try {
|
|
269
|
+
const _buildToolsScript = join(PLUGIN_ROOT, 'dev', 'scripts', 'build-tools-manifest.mjs')
|
|
270
|
+
if (existsSync(_buildToolsScript) && existsSync(_toolsJsonPath)) {
|
|
271
|
+
const _manifestMtime = statSync(_toolsJsonPath).mtimeMs
|
|
272
|
+
let _srcMtime = 0
|
|
273
|
+
for (const rel of [
|
|
274
|
+
'dev/scripts/build-tools-manifest.mjs',
|
|
275
|
+
'src/channels/index.mjs', 'src/channels/tool-defs.mjs',
|
|
276
|
+
'src/memory/index.mjs', 'src/memory/tool-defs.mjs',
|
|
277
|
+
'src/search/index.mjs', 'src/search/tool-defs.mjs', 'src/search/lib/providers.mjs',
|
|
278
|
+
'src/agent/index.mjs', 'src/agent/tool-defs.mjs',
|
|
279
|
+
'src/agent/orchestrator/tools/builtin.mjs', 'src/agent/orchestrator/tools/builtin/builtin-tools.mjs',
|
|
280
|
+
'src/agent/orchestrator/tools/code-graph-tool-defs.mjs',
|
|
281
|
+
'src/agent/orchestrator/tools/patch-tool-defs.mjs',
|
|
282
|
+
'src/agent/orchestrator/tools/host-input.mjs',
|
|
283
|
+
'src/agent/orchestrator/tools/cwd-tool.mjs',
|
|
284
|
+
]) {
|
|
285
|
+
try { _srcMtime = Math.max(_srcMtime, statSync(join(PLUGIN_ROOT, rel)).mtimeMs) } catch {}
|
|
286
|
+
}
|
|
287
|
+
if (_srcMtime > _manifestMtime) {
|
|
288
|
+
const _healEnv = { ...process.env }
|
|
289
|
+
delete _healEnv.CLAUDE_PLUGIN_DATA // route the rebuild's init writes to a throwaway temp dir
|
|
290
|
+
const _r = spawnSync('bun', [_buildToolsScript], { cwd: PLUGIN_ROOT, encoding: 'utf8', env: _healEnv, windowsHide: true })
|
|
291
|
+
if (_r.status !== 0) {
|
|
292
|
+
_selfHealError = new Error(`[mixdog] tools.json self-heal failed (status=${_r.status}); refusing to load possibly-stale/tampered manifest`)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
} catch { /* missing source/manifest — fall through to load the existing file */ }
|
|
297
|
+
if (_selfHealError) throw _selfHealError
|
|
298
|
+
let RAW_TOOL_DEFS
|
|
299
|
+
try {
|
|
300
|
+
RAW_TOOL_DEFS = JSON.parse(readFileSync(_toolsJsonPath, 'utf8'))
|
|
301
|
+
} catch (e) {
|
|
302
|
+
throw new Error('[mixdog] tools.json parse failure: ' + (e && e.message))
|
|
303
|
+
}
|
|
304
|
+
// Boot-time schema validation + module allowlist. A malformed or
|
|
305
|
+
// unknown-module entry aborts startup rather than silently shipping a
|
|
306
|
+
// broken/tampered manifest into TOOL_MODULE / TOOL_BY_NAME below.
|
|
307
|
+
const VALID_TOOL_MODULES = new Set([
|
|
308
|
+
...MODULE_NAMES, 'builtin', 'code_graph', 'patch', 'host_input', 'cwd',
|
|
309
|
+
])
|
|
310
|
+
if (!Array.isArray(RAW_TOOL_DEFS)) {
|
|
311
|
+
throw new Error('[mixdog] tools.json: top-level value is not an array')
|
|
312
|
+
}
|
|
313
|
+
for (const t of RAW_TOOL_DEFS) {
|
|
314
|
+
if (!t || typeof t !== 'object' || Array.isArray(t)) {
|
|
315
|
+
throw new Error(`[mixdog] tools.json: malformed entry (not an object): ${JSON.stringify(t)}`)
|
|
316
|
+
}
|
|
317
|
+
if (typeof t.name !== 'string' || !t.name) {
|
|
318
|
+
throw new Error(`[mixdog] tools.json: entry missing string "name": ${JSON.stringify(t)}`)
|
|
319
|
+
}
|
|
320
|
+
if (typeof t.description !== 'string') {
|
|
321
|
+
throw new Error(`[mixdog] tools.json: entry "${t.name}" missing string "description"`)
|
|
322
|
+
}
|
|
323
|
+
if (!t.inputSchema || typeof t.inputSchema !== 'object' || Array.isArray(t.inputSchema)) {
|
|
324
|
+
throw new Error(`[mixdog] tools.json: entry "${t.name}" missing object "inputSchema"`)
|
|
325
|
+
}
|
|
326
|
+
if (typeof t.module !== 'string' || !t.module) {
|
|
327
|
+
throw new Error(`[mixdog] tools.json: entry "${t.name}" missing string "module"`)
|
|
328
|
+
}
|
|
329
|
+
if (!VALID_TOOL_MODULES.has(t.module)) {
|
|
330
|
+
throw new Error(`[mixdog] tools.json: entry "${t.name}" has unknown module "${t.module}" (allowed: ${[...VALID_TOOL_MODULES].join(', ')})`)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// Hide tools belonging to disabled modules from BOTH the ListTools
|
|
334
|
+
// response AND the bridge's internal-tools registry. `builtin` / `lsp` /
|
|
335
|
+
// `bash_session` / `patch` are not module-gated — they ride along with
|
|
336
|
+
// the plugin regardless.
|
|
337
|
+
// Gate host_input on MIXDOG_ALLOW_HOST_INPUT env-var or
|
|
338
|
+
// modules.host_input.enabled config flag. Default: off.
|
|
339
|
+
const _hostInputAllowed = (() => {
|
|
340
|
+
if (process.env.MIXDOG_ALLOW_HOST_INPUT === '1') return true
|
|
341
|
+
try {
|
|
342
|
+
const raw = JSON.parse(readFileSync(join(PLUGIN_DATA, 'mixdog-config.json'), 'utf8'))
|
|
343
|
+
return !!(raw?.modules?.host_input?.enabled)
|
|
344
|
+
} catch { return false }
|
|
345
|
+
})()
|
|
346
|
+
const TOOL_DEFS = RAW_TOOL_DEFS.filter(t => {
|
|
347
|
+
if (t.module === 'host_input') return _hostInputAllowed
|
|
348
|
+
// Channels worker depends on memory worker for inbound routing / recall;
|
|
349
|
+
// the spawn gate below (channels gated on memoryOn) skips spawning when
|
|
350
|
+
// memory is disabled. Mirror that gate here so we don't advertise channel
|
|
351
|
+
// tools whose backing worker will never come up — calls would otherwise
|
|
352
|
+
// wait WORKER_NO_ENTRY_GRACE_MS and then reject.
|
|
353
|
+
if (t.module === 'channels' && !isModuleEnabled('memory')) return false
|
|
354
|
+
if (MODULE_NAMES.includes(t.module)) return isModuleEnabled(t.module)
|
|
355
|
+
return true
|
|
356
|
+
})
|
|
357
|
+
const TOOL_MODULE = Object.fromEntries(TOOL_DEFS.map(t => [t.name, t.module]))
|
|
358
|
+
const TOOL_BY_NAME = Object.fromEntries(TOOL_DEFS.map(t => [t.name, t]))
|
|
359
|
+
const PLUGIN_VERSION = JSON.parse(
|
|
360
|
+
readFileSync(join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8'),
|
|
361
|
+
).version
|
|
362
|
+
|
|
363
|
+
// ── Logging ──────────────────────────────────────────────────────────
|
|
364
|
+
const LOG_FILE = join(PLUGIN_DATA, 'mcp-debug.log')
|
|
365
|
+
const _logOwnerLeadPidRaw = Number(process.env.MIXDOG_SUPERVISOR_PID)
|
|
366
|
+
const LOG_OWNER_LEAD_PID = Number.isFinite(_logOwnerLeadPidRaw) && _logOwnerLeadPidRaw > 0
|
|
367
|
+
? _logOwnerLeadPidRaw
|
|
368
|
+
: process.pid
|
|
369
|
+
const LOG_CONTEXT = `lead=${LOG_OWNER_LEAD_PID} server=${process.pid} session=${SESSION_ID}`
|
|
370
|
+
const LOG_FILE_SCOPED = join(PLUGIN_DATA, `mcp-debug.${LOG_OWNER_LEAD_PID}.${process.pid}.log`)
|
|
371
|
+
// One-shot rotation: if mcp-debug.log >10 MB, rename to .1 (overwrite) and
|
|
372
|
+
// open a fresh file. Runs once at module init; never repeated per log() call.
|
|
373
|
+
try {
|
|
374
|
+
if (statSync(LOG_FILE).size > 10 * 1024 * 1024) renameSync(LOG_FILE, LOG_FILE + '.1')
|
|
375
|
+
} catch {}
|
|
376
|
+
try {
|
|
377
|
+
if (statSync(LOG_FILE_SCOPED).size > 10 * 1024 * 1024) renameSync(LOG_FILE_SCOPED, LOG_FILE_SCOPED + '.1')
|
|
378
|
+
} catch {}
|
|
379
|
+
|
|
380
|
+
// ── Application-level tool-error sink ────────────────────────────────────────
|
|
381
|
+
// Tool functions return `Error [code N]: ...` as plain strings instead of
|
|
382
|
+
// throwing — the dispatch logger therefore records them as `[dispatch] ok`
|
|
383
|
+
// and the disk has no trace of read/edit invariant failures (code 6/7/8/
|
|
384
|
+
// 9/10). Append one structured line per such result to tool-events.log
|
|
385
|
+
// next to mcp-debug.log. Best-effort: logger never breaks the tool.
|
|
386
|
+
const TOOL_EVENT_LOG = join(PLUGIN_DATA, 'tool-events.log');
|
|
387
|
+
// Lazy rotation for the application-error sink. mcp-debug.log uses the
|
|
388
|
+
// same 10 MiB threshold but tool-events.log is far less chatty (one line
|
|
389
|
+
// per app-level edit failure + one per session close), so a 1 MiB cap is
|
|
390
|
+
// roughly a month of activity. Rotation is one-step rename so callers
|
|
391
|
+
// never see a half-empty file mid-write.
|
|
392
|
+
const TOOL_EVENT_LOG_MAX_BYTES = 1024 * 1024;
|
|
393
|
+
function _rotateToolEventLogIfLarge() {
|
|
394
|
+
try {
|
|
395
|
+
const st = statSync(TOOL_EVENT_LOG);
|
|
396
|
+
if (st.size > TOOL_EVENT_LOG_MAX_BYTES) {
|
|
397
|
+
renameSync(TOOL_EVENT_LOG, TOOL_EVENT_LOG + '.1');
|
|
398
|
+
}
|
|
399
|
+
} catch { /* missing file or stat race — append will recreate */ }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function _recordToolApplicationError(name, result) {
|
|
403
|
+
try {
|
|
404
|
+
let text = null;
|
|
405
|
+
if (typeof result === 'string') {
|
|
406
|
+
text = result;
|
|
407
|
+
} else if (result && typeof result === 'object' && Array.isArray(result.content)) {
|
|
408
|
+
const t = result.content.find((c) => c && c.type === 'text' && typeof c.text === 'string');
|
|
409
|
+
if (t) text = t.text;
|
|
410
|
+
}
|
|
411
|
+
if (!text) return;
|
|
412
|
+
const m = text.match(/^Error \[code (\d+)\]:\s*([^\n]+)/);
|
|
413
|
+
if (!m) return;
|
|
414
|
+
_rotateToolEventLogIfLarge();
|
|
415
|
+
const ts = new Date().toISOString();
|
|
416
|
+
const msg = m[2].slice(0, 320);
|
|
417
|
+
appendFileSync(TOOL_EVENT_LOG, `[${ts}] [tool=${name}] [code=${m[1]}] ${msg}\n`);
|
|
418
|
+
} catch { /* logger never breaks the tool */ }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// R14: sanitize a single log field — strip ANSI escapes and escape control
|
|
422
|
+
// chars (CR, lone C0/C1) so attacker-controlled bytes from worker stderr can't
|
|
423
|
+
// forge new log lines, hide payloads with \r overwrites, or smuggle ANSI
|
|
424
|
+
// sequences into operator terminals tailing the log. Keep \t and \n intact:
|
|
425
|
+
// \t is benign in log payloads; \n is handled by the line-splitter upstream
|
|
426
|
+
// (writeWorkerLogChunk) and the writer appends its own \n.
|
|
427
|
+
function sanitizeLogField(text) {
|
|
428
|
+
if (text == null) return ''
|
|
429
|
+
let s = String(text)
|
|
430
|
+
// ANSI CSI: ESC [ params intermediates final.
|
|
431
|
+
s = s.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, (m) => '\\x1b' + m.slice(1))
|
|
432
|
+
// ANSI OSC: ESC ] ... BEL | ESC \.
|
|
433
|
+
s = s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, (m) => '\\x1b' + m.slice(1))
|
|
434
|
+
// Other single-char ESC sequences (Fe set).
|
|
435
|
+
s = s.replace(/\x1b[@-_]/g, (m) => '\\x1b' + m.slice(1))
|
|
436
|
+
// Escape lone CR — main log-injection vector (overwrites prior line in terminals).
|
|
437
|
+
s = s.replace(/\r/g, '\\r')
|
|
438
|
+
// Remaining C0 (except TAB \x09 and LF \x0A) and C1 control chars → \xNN.
|
|
439
|
+
s = s.replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, (c) => {
|
|
440
|
+
const code = c.charCodeAt(0)
|
|
441
|
+
return '\\x' + code.toString(16).padStart(2, '0')
|
|
442
|
+
})
|
|
443
|
+
return s
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── Buffered mcp-debug.log writer ────────────────────────────────────────────
|
|
447
|
+
// Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
|
|
448
|
+
// Drains synchronously on process exit so no log lines are lost.
|
|
449
|
+
let _logBuf = ''
|
|
450
|
+
let _logBytes = 0
|
|
451
|
+
let _logFlushTimer = null
|
|
452
|
+
let _logStream = null
|
|
453
|
+
let _logScopedStream = null
|
|
454
|
+
function _logGetStream() {
|
|
455
|
+
if (!_logStream) _logStream = createWriteStream(LOG_FILE, { flags: 'a' })
|
|
456
|
+
return _logStream
|
|
457
|
+
}
|
|
458
|
+
function _logGetScopedStream() {
|
|
459
|
+
if (!_logScopedStream) _logScopedStream = createWriteStream(LOG_FILE_SCOPED, { flags: 'a' })
|
|
460
|
+
return _logScopedStream
|
|
461
|
+
}
|
|
462
|
+
// Async WriteStream errors are not caught by callers of write(); attach
|
|
463
|
+
// 'error' listeners on lazy-create so a disk EIO/EPERM during async flush
|
|
464
|
+
// can't escape as uncaughtException. Best-effort: log and fall through to
|
|
465
|
+
// appendFileAsync on next write.
|
|
466
|
+
function _attachLogStreamErrorListener(stream, label) {
|
|
467
|
+
if (!stream) return
|
|
468
|
+
stream.on('error', (e) => {
|
|
469
|
+
try { process.stderr.write(`[server-main] ${label} stream error: ${e && (e.message || e)}\n`) } catch {}
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
function _logWriteAsync(line) {
|
|
473
|
+
try {
|
|
474
|
+
const s = _logGetStream()
|
|
475
|
+
if (!s.__mxErr) { _attachLogStreamErrorListener(s, 'mcp-debug'); s.__mxErr = true }
|
|
476
|
+
s.write(line)
|
|
477
|
+
} catch (e) {
|
|
478
|
+
appendFileAsync(LOG_FILE, line).catch(() => {})
|
|
479
|
+
}
|
|
480
|
+
try {
|
|
481
|
+
const s = _logGetScopedStream()
|
|
482
|
+
if (!s.__mxErr) { _attachLogStreamErrorListener(s, 'mcp-debug-scoped'); s.__mxErr = true }
|
|
483
|
+
s.write(line)
|
|
484
|
+
} catch (e) {
|
|
485
|
+
appendFileAsync(LOG_FILE_SCOPED, line).catch(() => {})
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
function _logLine(msg) {
|
|
489
|
+
return `[${new Date().toISOString()}] [${LOG_CONTEXT}] ${sanitizeLogField(msg)}\n`
|
|
490
|
+
}
|
|
491
|
+
function _logFlush() {
|
|
492
|
+
if (_logFlushTimer) { clearTimeout(_logFlushTimer); _logFlushTimer = null }
|
|
493
|
+
if (!_logBuf) return
|
|
494
|
+
_logWriteAsync(_logBuf)
|
|
495
|
+
_logBuf = ''
|
|
496
|
+
_logBytes = 0
|
|
497
|
+
}
|
|
498
|
+
// Synchronous exit flush — async WriteStream.write() can be dropped before drain.
|
|
499
|
+
function _logFlushSync() {
|
|
500
|
+
if (_logFlushTimer) { clearTimeout(_logFlushTimer); _logFlushTimer = null }
|
|
501
|
+
if (!_logBuf) return
|
|
502
|
+
try { appendFileSync(LOG_FILE, _logBuf) } catch {}
|
|
503
|
+
try { appendFileSync(LOG_FILE_SCOPED, _logBuf) } catch {}
|
|
504
|
+
_logBuf = ''
|
|
505
|
+
_logBytes = 0
|
|
506
|
+
}
|
|
507
|
+
function _logScheduleFlush() {
|
|
508
|
+
if (_logFlushTimer) return
|
|
509
|
+
_logFlushTimer = setTimeout(_logFlush, 1000)
|
|
510
|
+
if (_logFlushTimer.unref) _logFlushTimer.unref()
|
|
511
|
+
}
|
|
512
|
+
function _logAppend(line) {
|
|
513
|
+
_logBuf += line
|
|
514
|
+
_logBytes += Buffer.byteLength(line)
|
|
515
|
+
if (_logBytes >= 65536) { _logFlush(); return }
|
|
516
|
+
_logScheduleFlush()
|
|
517
|
+
}
|
|
518
|
+
process.on('exit', _logFlushSync)
|
|
519
|
+
// SIGTERM is wired to graceful shutdown() below (line ~1564). A separate
|
|
520
|
+
// _logFlushSync() + process.exit(0) handler used to short-circuit shutdown
|
|
521
|
+
// before workers/PG could drain — graceful path is preferred and ends with
|
|
522
|
+
// _logFlushSync() inside shutdown(). The 'exit' listener above stays as a
|
|
523
|
+
// catch-all for non-SIGTERM exits.
|
|
524
|
+
|
|
525
|
+
const log = msg => { _logAppend(_logLine(msg)) }
|
|
526
|
+
|
|
527
|
+
// ── Status HTTP server (forked child) ──────────────────────────────
|
|
528
|
+
// Start this before memory/channels so the terminal statusline gets its
|
|
529
|
+
// advert port during the first refresh tick. The rich bridge payload may be
|
|
530
|
+
// sparse until workers finish booting, but the statusline no longer waits on
|
|
531
|
+
// channels ownership before it has a data source.
|
|
532
|
+
const STATUS_ADVERTISE_DIR = join(homedir(), '.claude', 'mixdog-status')
|
|
533
|
+
const STATUS_ADVERTISE_PATH = join(STATUS_ADVERTISE_DIR, `${SESSION_ID}.json`)
|
|
534
|
+
let statusServerChild = null
|
|
535
|
+
let statusServerRestartTimer = null
|
|
536
|
+
let statusServerStopping = false
|
|
537
|
+
let recapStatusState = { state: 'idle', running: false, startedAt: null, lastCompletedAt: null, updatedAt: null, errorMessage: null }
|
|
538
|
+
|
|
539
|
+
function scheduleStatusServerRestart() {
|
|
540
|
+
if (statusServerStopping) return
|
|
541
|
+
if (statusServerChild) return
|
|
542
|
+
if (statusServerRestartTimer) return
|
|
543
|
+
statusServerRestartTimer = setTimeout(() => {
|
|
544
|
+
statusServerRestartTimer = null
|
|
545
|
+
spawnStatusServer()
|
|
546
|
+
}, 1000)
|
|
547
|
+
statusServerRestartTimer.unref?.()
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function normalizeRecapTimestamp(value) {
|
|
551
|
+
if (value === null || value === undefined || value === '') return null
|
|
552
|
+
const n = Number(value)
|
|
553
|
+
return Number.isFinite(n) ? n : null
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function sanitizeRecapStatusState(recap = {}) {
|
|
557
|
+
const validStates = new Set(['idle', 'running', 'injected', 'empty', 'error']);
|
|
558
|
+
const rawState = typeof recap.state === 'string' && validStates.has(recap.state) ? recap.state : 'idle';
|
|
559
|
+
return {
|
|
560
|
+
state: rawState,
|
|
561
|
+
running: recap.running === true,
|
|
562
|
+
startedAt: normalizeRecapTimestamp(recap.startedAt),
|
|
563
|
+
lastCompletedAt: normalizeRecapTimestamp(recap.lastCompletedAt),
|
|
564
|
+
updatedAt: normalizeRecapTimestamp(recap.updatedAt),
|
|
565
|
+
errorMessage: typeof recap.errorMessage === 'string' ? recap.errorMessage.slice(0, 200) : null,
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function forwardRecapStatusToStatusServer() {
|
|
570
|
+
if (!statusServerChild || !statusServerChild.connected) return
|
|
571
|
+
try {
|
|
572
|
+
statusServerChild.send({ type: 'recap_status', recap: recapStatusState })
|
|
573
|
+
} catch (e) {
|
|
574
|
+
log(`[status-server] recap status forward failed: ${e && (e.message || e) || e}`)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function spawnStatusServer() {
|
|
579
|
+
if (statusServerStopping) return
|
|
580
|
+
if (statusServerChild) return
|
|
581
|
+
if (statusServerRestartTimer) {
|
|
582
|
+
clearTimeout(statusServerRestartTimer)
|
|
583
|
+
statusServerRestartTimer = null
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
|
|
587
|
+
statusServerChild = fork(
|
|
588
|
+
join(PLUGIN_ROOT, 'src/status/server.mjs'),
|
|
589
|
+
[],
|
|
590
|
+
{
|
|
591
|
+
env: {
|
|
592
|
+
...process.env,
|
|
593
|
+
MIXDOG_STATUS_DATA_DIR: PLUGIN_DATA,
|
|
594
|
+
MIXDOG_OWNER_SESSION_ID: SESSION_ID,
|
|
595
|
+
MIXDOG_OWNER_LEAD_PID: process.env.MIXDOG_SUPERVISOR_PID || '',
|
|
596
|
+
MIXDOG_OWNER_HOST_PID: OWNER_HOST_PID ? String(OWNER_HOST_PID) : '',
|
|
597
|
+
MIXDOG_STATUS_ADVERTISE_PATH: STATUS_ADVERTISE_PATH,
|
|
598
|
+
},
|
|
599
|
+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
600
|
+
windowsHide: true,
|
|
601
|
+
}
|
|
602
|
+
)
|
|
603
|
+
// Split chunks on \n so a child line containing embedded newlines cannot
|
|
604
|
+
// forge unprefixed log entries via sanitizeLogField's preservation of \n.
|
|
605
|
+
const _emitStatusLines = (prefix, chunk) => {
|
|
606
|
+
const text = String(chunk)
|
|
607
|
+
if (!text) return
|
|
608
|
+
const lines = text.split(/\r?\n/)
|
|
609
|
+
// Drop a trailing empty token from a chunk that ended with \n; emit
|
|
610
|
+
// any non-empty residue too (incomplete final line still surfaces).
|
|
611
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
|
|
612
|
+
for (const line of lines) {
|
|
613
|
+
if (!line) continue
|
|
614
|
+
log(prefix ? `${prefix}${line}` : line)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
statusServerChild.stdout?.on('data', (d) => _emitStatusLines('', d))
|
|
618
|
+
statusServerChild.stderr?.on('data', (d) => _emitStatusLines('[status-server] stderr: ', d))
|
|
619
|
+
statusServerChild.on('error', (e) => {
|
|
620
|
+
log(`[status-server] child error: ${(e && (e.stack || e.message)) || e}`)
|
|
621
|
+
statusServerChild = null
|
|
622
|
+
try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
|
|
623
|
+
scheduleStatusServerRestart()
|
|
624
|
+
})
|
|
625
|
+
statusServerChild.on('exit', (code, signal) => {
|
|
626
|
+
log(`[status-server] child exited code=${code} signal=${signal}`)
|
|
627
|
+
statusServerChild = null
|
|
628
|
+
try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
|
|
629
|
+
scheduleStatusServerRestart()
|
|
630
|
+
})
|
|
631
|
+
forwardRecapStatusToStatusServer()
|
|
632
|
+
} catch (e) {
|
|
633
|
+
log(`[status-server] failed to fork: ${(e && (e.stack || e.message)) || e}`)
|
|
634
|
+
scheduleStatusServerRestart()
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
spawnStatusServer()
|
|
638
|
+
|
|
639
|
+
// ── Crash handlers ──────────────────────────────────────────────────
|
|
640
|
+
// Leave a trace on silent hangs. Previously only child workers
|
|
641
|
+
// (channels/memory) installed these; the main MCP entry had none, so
|
|
642
|
+
// unhandled errors died without writing a stack.
|
|
643
|
+
//
|
|
644
|
+
// Soft net policy (0.1.73): we deliberately do NOT call process.exit()
|
|
645
|
+
// from uncaughtException / unhandledRejection. A misbehaving tool path
|
|
646
|
+
// (e.g. explore concatenating results past V8 max-string-length on a
|
|
647
|
+
// very broad cwd) used to take the whole MCP server down; now it logs
|
|
648
|
+
// a stack and the process keeps serving. Real fatal conditions still
|
|
649
|
+
// bubble out via SIGTERM/SIGINT or the explicit shutdown() path.
|
|
650
|
+
const CRASH_FILE = join(PLUGIN_DATA, 'crash.log')
|
|
651
|
+
const logCrash = (kind, err) => {
|
|
652
|
+
const stack = err?.stack || String(err)
|
|
653
|
+
try { appendFileSync(CRASH_FILE, `[${new Date().toISOString()}] ${kind}\n${stack}\n\n`) } catch {}
|
|
654
|
+
try { log(`${kind}: ${err?.message || err}`) } catch {}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Fatal classification for uncaughtException. Soft-net (log-only) is the
|
|
658
|
+
// 0.1.73 default for recoverable conditions like "Invalid string length"
|
|
659
|
+
// from a runaway explore concat. But genuinely fatal conditions (port
|
|
660
|
+
// already bound, OOM, node assert violations, internal-assertion
|
|
661
|
+
// failures) leave the process in an unrecoverable state — staying alive
|
|
662
|
+
// just delays the inevitable while serving broken responses. For those,
|
|
663
|
+
// log the stack and exit(1) so the supervisor restarts cleanly.
|
|
664
|
+
const FATAL_CODES = new Set([
|
|
665
|
+
'EADDRINUSE',
|
|
666
|
+
'EADDRNOTAVAIL',
|
|
667
|
+
'ENOMEM',
|
|
668
|
+
// EPIPE on stdout/stdin means the MCP transport is gone — no recovery possible.
|
|
669
|
+
'EPIPE',
|
|
670
|
+
'ERR_INTERNAL_ASSERTION',
|
|
671
|
+
])
|
|
672
|
+
const FATAL_NAME_PATTERNS = [
|
|
673
|
+
/AssertionError/i, // node assert violations
|
|
674
|
+
]
|
|
675
|
+
function isFatalUncaught(err) {
|
|
676
|
+
if (!err) return false
|
|
677
|
+
if (err.code && FATAL_CODES.has(err.code)) return true
|
|
678
|
+
const name = err.name || (err.constructor && err.constructor.name) || ''
|
|
679
|
+
if (FATAL_NAME_PATTERNS.some(rx => rx.test(name))) return true
|
|
680
|
+
return false
|
|
681
|
+
}
|
|
682
|
+
process.on('uncaughtException', (err) => {
|
|
683
|
+
logCrash('uncaughtException', err)
|
|
684
|
+
if (isFatalUncaught(err)) {
|
|
685
|
+
try { log(`uncaughtException classified fatal (code=${err?.code} name=${err?.name}); exiting`) } catch {}
|
|
686
|
+
process.exit(1)
|
|
687
|
+
}
|
|
688
|
+
})
|
|
689
|
+
process.on('unhandledRejection', (reason) => {
|
|
690
|
+
logCrash('unhandledRejection', reason)
|
|
691
|
+
if (isFatalUncaught(reason)) {
|
|
692
|
+
try { log(`unhandledRejection classified fatal (code=${reason?.code} name=${reason?.name}); exiting`) } catch {}
|
|
693
|
+
process.exit(1)
|
|
694
|
+
}
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// Explicit stdio transport-gone handlers: if stdout errors with EPIPE or stdin
|
|
698
|
+
// closes, the MCP pipe is dead — exit(1) so the host respawns on next call.
|
|
699
|
+
process.stdout.on('error', e => { if (e?.code === 'EPIPE') { logCrash('stdout-epipe', e); process.exit(1) } })
|
|
700
|
+
// A normal MCP host closes stdin to end the session — route that through the
|
|
701
|
+
// graceful shutdown path so workers (memory/channels) and PG get a clean stop
|
|
702
|
+
// and the supervisor sees exit 0 instead of a spurious crash. The fatal
|
|
703
|
+
// stdout EPIPE guard above still covers the abnormal "pipe broken mid-write"
|
|
704
|
+
// condition where shutdown() can't drain reliably.
|
|
705
|
+
process.stdin.on('close', () => {
|
|
706
|
+
log('stdin closed — MCP transport gone, initiating graceful shutdown')
|
|
707
|
+
if (typeof shutdown === 'function') {
|
|
708
|
+
Promise.resolve()
|
|
709
|
+
.then(() => shutdown('stdin closed'))
|
|
710
|
+
.catch(e => {
|
|
711
|
+
try { logCrash('stdin-close-shutdown', e) } catch {}
|
|
712
|
+
process.exit(1)
|
|
713
|
+
})
|
|
714
|
+
} else {
|
|
715
|
+
// shutdown() not yet defined — extremely early stdin close. Treat as abnormal.
|
|
716
|
+
logCrash('stdin-close-early', new Error('stdin closed before shutdown wired'))
|
|
717
|
+
process.exit(1)
|
|
718
|
+
}
|
|
719
|
+
})
|
|
720
|
+
process.on('exit', (code) => {
|
|
721
|
+
if (code !== 0) {
|
|
722
|
+
try { logCrash('exit', new Error(`process exit code=${code}`)) } catch {}
|
|
723
|
+
}
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
// ── Bridge orphan cleanup ───────────────────────────────────────────
|
|
727
|
+
// Non-blocking: cleanup of stale state from a previous server PID.
|
|
728
|
+
// Awaiting these used to gate the boot path (memory worker spawn, agent
|
|
729
|
+
// eager init) behind disk + module-load work that has no semantic
|
|
730
|
+
// dependency on the rest of boot. Fire-and-forget so the critical path
|
|
731
|
+
// proceeds; failures stay logged.
|
|
732
|
+
import(pathToFileURL(join(PLUGIN_ROOT, 'src/shared/llm/pid-cleanup.mjs')).href)
|
|
733
|
+
.then(({ cleanupOrphanedPids }) => {
|
|
734
|
+
const killed = cleanupOrphanedPids()
|
|
735
|
+
if (killed > 0) log(`[bridge-cleanup] cleaned ${killed} orphaned processes`)
|
|
736
|
+
})
|
|
737
|
+
.catch(e => log(`[bridge-cleanup] failed: ${e && (e.stack || e.message) || e}`))
|
|
738
|
+
|
|
739
|
+
// ── Session cleanup: bridge sessions from previous MCP process ─────
|
|
740
|
+
// Non-blocking, same rationale as bridge-cleanup above.
|
|
741
|
+
import(pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/session/manager.mjs')).href)
|
|
742
|
+
.then(({ listSessions, closeSession, startIdleCleanup }) => {
|
|
743
|
+
const sessions = listSessions()
|
|
744
|
+
// Multi-instance guard: only reap bridge sessions whose owning MCP
|
|
745
|
+
// process is actually dead. A live peer (another Claude Code window)
|
|
746
|
+
// may still be running work on its session — closing it here is what
|
|
747
|
+
// produced the "bridge worker aborted mid-run" reports when a new
|
|
748
|
+
// window booted.
|
|
749
|
+
const isPidAlive = (pid) => {
|
|
750
|
+
if (!pid) return false
|
|
751
|
+
try { process.kill(pid, 0); return true } catch (e) { return !!(e && e.code === 'EPERM') }
|
|
752
|
+
}
|
|
753
|
+
let closed = 0
|
|
754
|
+
let preserved = 0
|
|
755
|
+
for (const s of sessions) {
|
|
756
|
+
if (s.owner !== 'bridge') continue
|
|
757
|
+
if (s.mcpPid === process.pid) continue
|
|
758
|
+
if (isPidAlive(s.mcpPid)) { preserved++; continue }
|
|
759
|
+
closeSession(s.id); closed++
|
|
760
|
+
}
|
|
761
|
+
log(`[session-cleanup] closed ${closed} dead-peer bridge sessions (pid≠${process.pid}), preserved ${preserved} live-peer, ${sessions.length - closed} remaining`)
|
|
762
|
+
startIdleCleanup()
|
|
763
|
+
log(`[session-cleanup] idle sweep timer started (interval=5m)`)
|
|
764
|
+
})
|
|
765
|
+
.catch(e => log(`[session-cleanup] failed: ${e && (e.stack || e.message) || e}`))
|
|
766
|
+
|
|
767
|
+
// ── MCP server ──────────────────────────────────────────────────────
|
|
768
|
+
function buildSearchProviderLine() {
|
|
769
|
+
try {
|
|
770
|
+
const cfg = loadSearchConfig()
|
|
771
|
+
const provider = cfg?.provider || 'anthropic-oauth'
|
|
772
|
+
const caps = PROVIDER_CAPS[provider]
|
|
773
|
+
if (!caps) {
|
|
774
|
+
return `Search backend: \`${provider}\` (unknown capabilities — verify with \`/mixdog:config\`).`
|
|
775
|
+
}
|
|
776
|
+
const types = (caps.searchTypes || []).join('/') || 'web'
|
|
777
|
+
const lines = [
|
|
778
|
+
`Search backend: \`${provider}\` (searchTypes=${types}).`,
|
|
779
|
+
'Two-step model: `search` returns SERP snippets + URLs; follow up with `web_fetch` for raw page bodies. `web_fetch` uses local readability+puppeteer extractors (provider-independent).',
|
|
780
|
+
]
|
|
781
|
+
if (caps.localeMode === 'none') {
|
|
782
|
+
lines.push('→ Note: `locale` parameter is ignored by this backend.')
|
|
783
|
+
}
|
|
784
|
+
return lines.join('\n')
|
|
785
|
+
} catch (e) {
|
|
786
|
+
return `Search backend: capability probe failed (${e && (e.message || e)}).`
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const SERVER_INSTRUCTIONS = [
|
|
791
|
+
`mixdog MCP server v${PLUGIN_VERSION}.`,
|
|
792
|
+
'',
|
|
793
|
+
'Agents: delegate via `bridge` with a `role` argument (roles defined in `user-workflow.json`; active set injected as the `# Roles` rule). `Agent` / `TeamCreate` are NOT used — `bridge` is the single entry point. `TaskCreate` / `TaskUpdate` / `TaskList` are allowed for Lead progress tracking only.',
|
|
794
|
+
'',
|
|
795
|
+
'Retrieval (HIGHEST PRIORITY): choose the source family first — `explore` for unknown/open-ended codebase work, `search` for web/URL/current external docs, `recall` for past memory. Then descend for bounded code lookup: `code_graph` (esp. `mode:search` for symbol-name keywords; `find_symbol` when exact) → `glob`/`list` → `grep` (free-text / non-symbol) → `read` with `file_path` plus `offset`/`limit` or line window. Mutations descend separately: `apply_patch` for large/multi-hunk/patch-shaped changes, batched `edit` for multiple exact substitutions, scalar `edit` for one small exact substitution. `bash` is shell-only (git, build, test, run); using it for file/code lookup is a violation.',
|
|
796
|
+
'',
|
|
797
|
+
buildSearchProviderLine(),
|
|
798
|
+
'',
|
|
799
|
+
'Channels: schedule / webhook / queue events arrive in the Lead session via the built-in channel mechanism, each with its own event-class marker.',
|
|
800
|
+
].join('\n')
|
|
801
|
+
|
|
802
|
+
const server = new Server(
|
|
803
|
+
{ name: 'mixdog', version: PLUGIN_VERSION },
|
|
804
|
+
{
|
|
805
|
+
capabilities: {
|
|
806
|
+
tools: {},
|
|
807
|
+
experimental: { 'claude/channel': {}, 'claude/channel/permission': {} },
|
|
808
|
+
},
|
|
809
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
810
|
+
},
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
// ── Channel permission request forwarding ──────────────────────────
|
|
814
|
+
// Claude Code's interactiveHandler races the terminal dialog against every
|
|
815
|
+
// MCP channel server that declares `experimental['claude/channel/permission']`.
|
|
816
|
+
// When CC fires this notification, forward it into the channels worker so it
|
|
817
|
+
// can post the Discord prompt. The worker reports the outcome back through
|
|
818
|
+
// the generic {type:'notify'} IPC path above, which becomes a
|
|
819
|
+
// `notifications/claude/channel/permission` notification on the MCP server.
|
|
820
|
+
const ChannelPermissionRequestNotificationSchema = z.object({
|
|
821
|
+
method: z.literal('notifications/claude/channel/permission_request'),
|
|
822
|
+
params: z.object({
|
|
823
|
+
request_id: z.string(),
|
|
824
|
+
tool_name: z.string(),
|
|
825
|
+
description: z.string().optional(),
|
|
826
|
+
input_preview: z.string().optional(),
|
|
827
|
+
}).passthrough(),
|
|
828
|
+
})
|
|
829
|
+
|
|
830
|
+
// Daemon-mode notification router. In stdio mode the single module-global
|
|
831
|
+
// `server` carries every worker→host notification. In daemon mode there are N
|
|
832
|
+
// per-connection servers, so worker notifications must be ROUTED: permission
|
|
833
|
+
// responses go to the connection that made the request (by request_id), channel
|
|
834
|
+
// events (Discord inbound / schedule / webhook) go to the designated Lead
|
|
835
|
+
// connection. Stays null in stdio mode.
|
|
836
|
+
let daemonNotifyRouter = null
|
|
837
|
+
function createDaemonNotifyRouter() {
|
|
838
|
+
const byReq = new Map() // request_id → perConn server (permission replies)
|
|
839
|
+
const bySession = new Map() // sessionId → perConn server (worker result/status routing)
|
|
840
|
+
const sessions = new Map() // perConn server → Session (owner resolution by leadPid)
|
|
841
|
+
// Resolve a connection by its terminal's client_host_pid (insertion-order scan).
|
|
842
|
+
function resolveConnByHostPid(hostPid) {
|
|
843
|
+
const pid = Number(hostPid) || 0
|
|
844
|
+
if (pid <= 0) return null
|
|
845
|
+
for (const [conn, session] of sessions) {
|
|
846
|
+
if (Number(session?.clientHostPid) === pid) return conn
|
|
847
|
+
}
|
|
848
|
+
return null
|
|
849
|
+
}
|
|
850
|
+
// Owner terminal = the connection whose supervisor pid matches the active
|
|
851
|
+
// instance's ownerLeadPid — the SAME SSOT hook-pipe ownership trusts
|
|
852
|
+
// (activeOwnerLeadPid). Exactly one deterministic owner: no first-connection
|
|
853
|
+
// election, no broadcast guessing.
|
|
854
|
+
function ownerConn() {
|
|
855
|
+
const ownerPid = activeOwnerLeadPid()
|
|
856
|
+
if (!ownerPid) return null
|
|
857
|
+
for (const [conn, session] of sessions) {
|
|
858
|
+
if (parsePositivePid(session?.leadPid) === ownerPid) return conn
|
|
859
|
+
}
|
|
860
|
+
return null
|
|
861
|
+
}
|
|
862
|
+
async function deliver(conn, notification, label) {
|
|
863
|
+
try {
|
|
864
|
+
await conn.notification(channelNotifyParamsForCc(notification))
|
|
865
|
+
const meta = notification?.params?.meta || null
|
|
866
|
+
if (meta?.type === 'dispatch_result' || meta?.caller_session_id != null || meta?.client_host_pid != null || String(label || '').includes('worker notify')) {
|
|
867
|
+
const sid = meta?.caller_session_id != null ? ` sid=${String(meta.caller_session_id)}` : ''
|
|
868
|
+
const host = Number(meta?.client_host_pid) || 0
|
|
869
|
+
log(`[daemon] ${label} delivered${sid}${host > 0 ? ` host=${host}` : ''}`)
|
|
870
|
+
}
|
|
871
|
+
return true
|
|
872
|
+
} catch (err) {
|
|
873
|
+
log(`[daemon] ${label} failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
874
|
+
return false
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return {
|
|
878
|
+
add(conn, session) { sessions.set(conn, session) },
|
|
879
|
+
remove(conn) {
|
|
880
|
+
sessions.delete(conn)
|
|
881
|
+
for (const [k, v] of byReq) if (v === conn) byReq.delete(k)
|
|
882
|
+
for (const [k, v] of bySession) if (v === conn) bySession.delete(k)
|
|
883
|
+
},
|
|
884
|
+
registerPermissionRequest(reqId, conn) { if (reqId) byReq.set(String(reqId), conn) },
|
|
885
|
+
// Bind a session id → its connection so worker results/status (bridge +
|
|
886
|
+
// explore) route back to the dispatching terminal. Re-binding the same
|
|
887
|
+
// connection under a new id (bootstrap UUID → real MIXDOG_SESSION_ID on the
|
|
888
|
+
// control frame) drops the stale key, so a result can never route to a dead
|
|
889
|
+
// bootstrap id.
|
|
890
|
+
registerSession(sessionId, conn) {
|
|
891
|
+
if (!sessionId) return
|
|
892
|
+
const key = String(sessionId)
|
|
893
|
+
for (const [k, v] of bySession) if (v === conn && k !== key) bySession.delete(k)
|
|
894
|
+
bySession.set(key, conn)
|
|
895
|
+
},
|
|
896
|
+
async route(method, params) {
|
|
897
|
+
// 1. Permission replies are request-scoped: the channels worker emits
|
|
898
|
+
// `notifications/claude/channel/permission` with a top-level request_id.
|
|
899
|
+
// Deliver ONLY to the originating connection (handing one terminal's
|
|
900
|
+
// answer to another would be a security/UX break).
|
|
901
|
+
if (method === 'notifications/claude/channel/permission') {
|
|
902
|
+
const reqId = params?.request_id != null ? String(params.request_id) : null
|
|
903
|
+
const target = reqId != null ? byReq.get(reqId) : null
|
|
904
|
+
if (reqId != null) byReq.delete(reqId)
|
|
905
|
+
if (target) {
|
|
906
|
+
return await deliver(target, { method, params }, `permission response for request ${reqId}`)
|
|
907
|
+
}
|
|
908
|
+
log(`[daemon] permission response for unknown/closed request ${reqId} — dropped`)
|
|
909
|
+
return false
|
|
910
|
+
}
|
|
911
|
+
// 2. Worker notifications & status (bridge/explore results, lifecycle
|
|
912
|
+
// echoes) are SESSION-scoped: meta.caller_session_id pins them to the
|
|
913
|
+
// dispatching terminal. No live session → drop; recoverPending replays
|
|
914
|
+
// real results on that session's reconnect (durable path — not a
|
|
915
|
+
// broadcast guess).
|
|
916
|
+
const meta = params?.meta || null
|
|
917
|
+
// Lifecycle status pings (silent_to_agent) never enter ANY terminal's
|
|
918
|
+
// context window — drop before routing, regardless of caller_session_id.
|
|
919
|
+
// Detached workers route here directly (bypassing agentNotify's silent
|
|
920
|
+
// branch); caller-session-less emits (bridge HTTP -> owner) previously fell
|
|
921
|
+
// through to owner delivery and leaked the flag to CC. Discord forwarding
|
|
922
|
+
// for non-detached emits is handled in agentNotify. The worker-side
|
|
923
|
+
// notifyFn / sendNotifyToParent gates coerce this flag to boolean before
|
|
924
|
+
// IPC, so === true is sufficient (no string 'true' reaches here).
|
|
925
|
+
if (meta?.silent_to_agent === true) return true;
|
|
926
|
+
const sid = meta?.caller_session_id != null ? String(meta.caller_session_id) : null
|
|
927
|
+
if (sid != null) {
|
|
928
|
+
const hostPid = Number(meta?.client_host_pid) || 0
|
|
929
|
+
const target = bySession.get(sid)
|
|
930
|
+
if (target) {
|
|
931
|
+
const targetSession = sessions.get(target) || null
|
|
932
|
+
const targetHostPid = Number(targetSession?.clientHostPid) || 0
|
|
933
|
+
if (!hostPid || (targetHostPid > 0 && targetHostPid === hostPid)) {
|
|
934
|
+
return await deliver(target, { method, params }, `worker notify for session ${sid}`)
|
|
935
|
+
}
|
|
936
|
+
log(`[daemon] worker notify for session ${sid} host mismatch target=${targetHostPid || 'unknown'} meta=${hostPid} — routing by host`)
|
|
937
|
+
}
|
|
938
|
+
// The dispatching session id is stale. MIXDOG_SESSION_ID is unset in the
|
|
939
|
+
// terminal, so the control frame never swaps in a stable id and EVERY
|
|
940
|
+
// reconnect mints a fresh bootstrap UUID (serveDaemon randomUUID); the
|
|
941
|
+
// prior conn's close drops its bySession key (remove()). A detached
|
|
942
|
+
// worker that outlived a reconnect would otherwise be lost even though
|
|
943
|
+
// its terminal is still attached. The terminal IS reachable under its
|
|
944
|
+
// stable per-terminal key: client_host_pid (= CC host ppid, invariant
|
|
945
|
+
// across reconnects, stamped on both the session and the notify meta).
|
|
946
|
+
// Route by that BEFORE owner — this delivers a non-owner terminal's own
|
|
947
|
+
// worker result back to it, not misrouted to the owner.
|
|
948
|
+
if (hostPid > 0) {
|
|
949
|
+
const hconn = resolveConnByHostPid(hostPid)
|
|
950
|
+
if (hconn) return await deliver(hconn, { method, params }, `worker notify for host ${hostPid}`)
|
|
951
|
+
log(`[daemon] worker notify for session ${sid} retained — host ${hostPid} not connected`)
|
|
952
|
+
return false
|
|
953
|
+
}
|
|
954
|
+
// Host pid is unknown (legacy client without host-pid): deliver to the
|
|
955
|
+
// active-instance owner terminal — the SAME ownerLeadPid SSOT
|
|
956
|
+
// (active-instance.json) the channel branch below trusts, not a broadcast
|
|
957
|
+
// guess. Host-scoped modern results never take this fallback because that
|
|
958
|
+
// would ack-and-delete a pending result in the wrong terminal.
|
|
959
|
+
const ownerForStaleSid = ownerConn()
|
|
960
|
+
if (ownerForStaleSid) {
|
|
961
|
+
return await deliver(ownerForStaleSid, { method, params }, `legacy worker notify for session ${sid}`)
|
|
962
|
+
}
|
|
963
|
+
log(`[daemon] worker notify for session ${sid} dropped — session + host + owner not connected`)
|
|
964
|
+
return false
|
|
965
|
+
}
|
|
966
|
+
const hostPid = Number(meta?.client_host_pid) || 0
|
|
967
|
+
if (meta?.type === 'dispatch_result' && hostPid > 0) {
|
|
968
|
+
const hconn = resolveConnByHostPid(hostPid)
|
|
969
|
+
if (hconn) return await deliver(hconn, { method, params }, `dispatch result for host ${hostPid}`)
|
|
970
|
+
log(`[daemon] dispatch result retained — host ${hostPid} not connected`)
|
|
971
|
+
return false
|
|
972
|
+
}
|
|
973
|
+
// 3. Channel events (Discord inbound / schedule / webhook / queue) are
|
|
974
|
+
// OWNER-scoped: only the active-instance owner terminal handles them.
|
|
975
|
+
// Owner not connected → drop; the queue / Discord surface it on reconnect.
|
|
976
|
+
const owner = ownerConn()
|
|
977
|
+
if (owner) {
|
|
978
|
+
return await deliver(owner, { method, params }, `channel event ${method}`)
|
|
979
|
+
}
|
|
980
|
+
log(`[daemon] channel event ${method} dropped — owner terminal not connected`)
|
|
981
|
+
return false
|
|
982
|
+
},
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Forward a channel permission request to the channels worker, replying with an
|
|
987
|
+
// explicit deny (via replyNotify) if the worker is unavailable so CC never hangs.
|
|
988
|
+
function forwardChannelPermissionRequest(params, replyNotify) {
|
|
989
|
+
const entry = workers.get('channels')
|
|
990
|
+
const reqId = params?.request_id
|
|
991
|
+
const deny = (reason) => replyNotify({
|
|
992
|
+
method: 'notifications/claude/channel',
|
|
993
|
+
params: { content: JSON.stringify({ type: 'permission_response', request_id: reqId, granted: false, reason }), meta: { user: 'mixdog-agent', user_id: 'system', ts: new Date().toISOString(), type: 'permission_response' } },
|
|
994
|
+
})
|
|
995
|
+
if (!entry?.proc?.connected || !entry.ready) {
|
|
996
|
+
log(`permission_request denied: channels worker not available (request_id=${reqId})`)
|
|
997
|
+
deny('channels worker not available')
|
|
998
|
+
return false
|
|
999
|
+
}
|
|
1000
|
+
try {
|
|
1001
|
+
entry.proc.send({ type: 'permission_request_inbound', params })
|
|
1002
|
+
return true
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
log(`permission_request IPC send failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1005
|
+
deny('IPC send failed')
|
|
1006
|
+
return false
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
server.setNotificationHandler(ChannelPermissionRequestNotificationSchema, async (notification) => {
|
|
1011
|
+
forwardChannelPermissionRequest(notification.params, (n) => {
|
|
1012
|
+
if (process.stdout.writable && !process.stdout.writableEnded) server.notification(n).catch(err => log(`[permission] notification dispatch failed: ${err?.message ?? err}`))
|
|
1013
|
+
})
|
|
1014
|
+
})
|
|
1015
|
+
|
|
1016
|
+
// ── Worker process management ──────────────────────────────────────
|
|
1017
|
+
const workers = new Map() // name → { proc, ready, pending }
|
|
1018
|
+
const WORKER_RESTART_WARN_AFTER = 3 // log [WARN] once attempt count crosses this; no hard cap
|
|
1019
|
+
const WORKER_MAX_BACKOFF_MS = 60_000
|
|
1020
|
+
const workerRestarts = new Map() // name → count (telemetry + backoff exponent + warn threshold)
|
|
1021
|
+
const workerIntentionalStop = new Set() // names where parent initiated shutdown; suppress respawn
|
|
1022
|
+
const workerPermanentlyDegraded = new Set() // worker self-declared unrecoverable (init reported degraded:true); suppress respawn
|
|
1023
|
+
|
|
1024
|
+
// Cached bridge-llm factory import — loaded on first agent_ipc_request and
|
|
1025
|
+
// reused thereafter. The agent module must be loaded before the first call
|
|
1026
|
+
// (loadModule('agent') runs at boot, well before any memory cycle fires).
|
|
1027
|
+
let _bridgeLlmFactory = null
|
|
1028
|
+
async function _getBridgeLlmFactory() {
|
|
1029
|
+
if (_bridgeLlmFactory) return _bridgeLlmFactory
|
|
1030
|
+
const mod = await import(
|
|
1031
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src', 'agent', 'orchestrator', 'smart-bridge', 'bridge-llm.mjs')).href
|
|
1032
|
+
)
|
|
1033
|
+
_bridgeLlmFactory = mod.makeBridgeLlm
|
|
1034
|
+
return _bridgeLlmFactory
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Per-callId AbortController so a worker-side timeout can plumb
|
|
1038
|
+
// agent_ipc_cancel through to the in-flight bridge LLM provider call,
|
|
1039
|
+
// stopping further token billing instead of letting the call run to
|
|
1040
|
+
// completion after the worker stopped waiting for the result.
|
|
1041
|
+
const AGENT_IPC_MAX_CONCURRENT = 2
|
|
1042
|
+
const _agentIpcInflight = new Map()
|
|
1043
|
+
/** @type {Array<{ msg: object, worker: string, proc: import('child_process').ChildProcess }>} */
|
|
1044
|
+
const _agentIpcQueue = []
|
|
1045
|
+
let _agentIpcRunning = 0
|
|
1046
|
+
|
|
1047
|
+
function _sendAgentIpcResponse(proc, callId, body) {
|
|
1048
|
+
try { proc.send({ type: 'agent_ipc_response', callId, ...body }) } catch {}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function _startAgentIpcJob(job) {
|
|
1052
|
+
const { msg, worker, proc } = job
|
|
1053
|
+
const _ctrl = new AbortController()
|
|
1054
|
+
_agentIpcInflight.set(msg.callId, { ctrl: _ctrl, worker })
|
|
1055
|
+
void handleAgentIpcRequest(msg, _ctrl.signal).then(res => {
|
|
1056
|
+
_sendAgentIpcResponse(proc, msg.callId, res)
|
|
1057
|
+
}).catch(err => {
|
|
1058
|
+
try { process.stderr.write(`[agent_ipc] handler rejected callId=${msg.callId}: ${err?.stack || err?.message || err}\n`) } catch {}
|
|
1059
|
+
_sendAgentIpcResponse(proc, msg.callId, { ok: false, error: err?.message || String(err) })
|
|
1060
|
+
}).finally(() => {
|
|
1061
|
+
_agentIpcInflight.delete(msg.callId)
|
|
1062
|
+
_agentIpcRunning = Math.max(0, _agentIpcRunning - 1)
|
|
1063
|
+
_drainAgentIpcQueue()
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function _drainAgentIpcQueue() {
|
|
1068
|
+
while (_agentIpcRunning < AGENT_IPC_MAX_CONCURRENT && _agentIpcQueue.length > 0) {
|
|
1069
|
+
const job = _agentIpcQueue.shift()
|
|
1070
|
+
_agentIpcRunning++
|
|
1071
|
+
_startAgentIpcJob(job)
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function _enqueueAgentIpcRequest(msg, worker, proc) {
|
|
1076
|
+
_agentIpcQueue.push({ msg, worker, proc })
|
|
1077
|
+
_drainAgentIpcQueue()
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function _cancelQueuedAgentIpc(callId, proc) {
|
|
1081
|
+
const idx = _agentIpcQueue.findIndex(j => j.msg.callId === callId)
|
|
1082
|
+
if (idx === -1) return false
|
|
1083
|
+
const [job] = _agentIpcQueue.splice(idx, 1)
|
|
1084
|
+
_sendAgentIpcResponse(job.proc || proc, callId, { ok: false, error: 'agent_ipc cancelled before start' })
|
|
1085
|
+
return true
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function _purgeAgentIpcForWorker(workerName) {
|
|
1089
|
+
for (let i = _agentIpcQueue.length - 1; i >= 0; i--) {
|
|
1090
|
+
if (_agentIpcQueue[i].worker === workerName) {
|
|
1091
|
+
const job = _agentIpcQueue.splice(i, 1)[0]
|
|
1092
|
+
_sendAgentIpcResponse(job.proc, job.msg.callId, { ok: false, error: `worker ${workerName} exited` })
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function handleAgentIpcRequest(msg, signal) {
|
|
1098
|
+
const params = msg?.params || {}
|
|
1099
|
+
try {
|
|
1100
|
+
if (msg.tool !== 'bridge_llm') {
|
|
1101
|
+
return { ok: false, error: `unsupported agent_ipc tool "${msg.tool}"` }
|
|
1102
|
+
}
|
|
1103
|
+
if (!params.prompt) {
|
|
1104
|
+
return { ok: false, error: 'bridge_llm: prompt required' }
|
|
1105
|
+
}
|
|
1106
|
+
const makeBridgeLlm = await _getBridgeLlmFactory()
|
|
1107
|
+
const llm = makeBridgeLlm({
|
|
1108
|
+
role: params.role || undefined,
|
|
1109
|
+
taskType: params.taskType || undefined,
|
|
1110
|
+
mode: params.mode || undefined,
|
|
1111
|
+
cwd: params.cwd || undefined,
|
|
1112
|
+
parentSignal: signal || undefined,
|
|
1113
|
+
})
|
|
1114
|
+
const raw = await llm({
|
|
1115
|
+
prompt: params.prompt,
|
|
1116
|
+
mode: params.mode || undefined,
|
|
1117
|
+
preset: params.preset || undefined,
|
|
1118
|
+
timeout: params.timeout || undefined,
|
|
1119
|
+
})
|
|
1120
|
+
return { ok: true, result: raw }
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
return { ok: false, error: e?.message || String(e) }
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function spawnWorker(name) {
|
|
1127
|
+
process.stderr.write(`[boot-time] tag=worker-spawn name=${name} tMs=${Date.now()}\n`)
|
|
1128
|
+
const modulePath = join(PLUGIN_ROOT, 'src', name, 'index.mjs')
|
|
1129
|
+
// Per-worker stderr files so cycle1/cycle2/embed/recap diagnostics are
|
|
1130
|
+
// captured even when the worker hangs before answering an IPC call. The
|
|
1131
|
+
// legacy shared log stays for compatibility; the scoped sibling makes
|
|
1132
|
+
// multi-terminal analysis unambiguous.
|
|
1133
|
+
const stderrPath = join(PLUGIN_DATA, `${name}-worker.log`)
|
|
1134
|
+
const stderrScopedPath = join(PLUGIN_DATA, `${name}-worker.${LOG_OWNER_LEAD_PID}.${process.pid}.log`)
|
|
1135
|
+
// One-shot rotation before opening: if worker log >10 MB, rename to .1 (overwrite).
|
|
1136
|
+
try { if (statSync(stderrPath).size > 10 * 1024 * 1024) renameSync(stderrPath, stderrPath + '.1') } catch {}
|
|
1137
|
+
try { if (statSync(stderrScopedPath).size > 10 * 1024 * 1024) renameSync(stderrScopedPath, stderrScopedPath + '.1') } catch {}
|
|
1138
|
+
let stderrStream = null
|
|
1139
|
+
let stderrScopedStream = null
|
|
1140
|
+
let stderrRemainder = ''
|
|
1141
|
+
const proc = fork(modulePath, [], {
|
|
1142
|
+
// stdio idx 1 = 'ignore' so a worker stdout write (or a stdout write
|
|
1143
|
+
// from any worker dependency such as bun runtime warnings, transformers,
|
|
1144
|
+
// onnxruntime) cannot leak into the parent's MCP JSON-RPC stream and
|
|
1145
|
+
// corrupt the frame the client sees. Worker logs are piped and written
|
|
1146
|
+
// with lead/server/worker metadata; IPC carries everything functional.
|
|
1147
|
+
// The supervisor (run-mcp.mjs) also quarantines any non-JSON line that
|
|
1148
|
+
// does reach its stdout pipe — defense in depth against future regressions.
|
|
1149
|
+
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
|
1150
|
+
env: {
|
|
1151
|
+
...process.env,
|
|
1152
|
+
CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
|
|
1153
|
+
CLAUDE_PLUGIN_DATA: PLUGIN_DATA,
|
|
1154
|
+
MIXDOG_WORKER_MODE: '1',
|
|
1155
|
+
MIXDOG_SESSION_ID: SESSION_ID,
|
|
1156
|
+
MIXDOG_OWNER_SESSION_ID: SESSION_ID,
|
|
1157
|
+
MIXDOG_SERVER_PID: String(process.pid),
|
|
1158
|
+
MIXDOG_OWNER_LEAD_PID: process.env.MIXDOG_SUPERVISOR_PID || '',
|
|
1159
|
+
},
|
|
1160
|
+
windowsHide: true,
|
|
1161
|
+
})
|
|
1162
|
+
// Build a list of env values to scrub from worker log output. Workers run
|
|
1163
|
+
// with the full parent env (provider keys, OAuth tokens) so those literal
|
|
1164
|
+
// values can otherwise surface verbatim in stderr (stack traces, debug
|
|
1165
|
+
// dumps). The env itself is left untouched — workers still need the keys
|
|
1166
|
+
// to call providers; only the LOG bytes are redacted.
|
|
1167
|
+
const SECRET_ENV_RE = /^(ANTHROPIC_|CLAUDE_|AWS_|OPENAI_|GH_|GITHUB_|NPM_|.*_API_KEY|.*_TOKEN|.*_SECRET)/
|
|
1168
|
+
const secretEnvValues = []
|
|
1169
|
+
try {
|
|
1170
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
1171
|
+
if (!v || typeof v !== 'string') continue
|
|
1172
|
+
if (v.length < 4) continue
|
|
1173
|
+
if (SECRET_ENV_RE.test(k)) secretEnvValues.push(v)
|
|
1174
|
+
}
|
|
1175
|
+
// Longest first so a longer secret containing a shorter one is masked whole.
|
|
1176
|
+
secretEnvValues.sort((a, b) => b.length - a.length)
|
|
1177
|
+
} catch {}
|
|
1178
|
+
const redactSecrets = (text) => {
|
|
1179
|
+
if (!text) return text
|
|
1180
|
+
let s = String(text)
|
|
1181
|
+
// Wrap all redaction in try/catch: a regex/replace failure must NEVER break
|
|
1182
|
+
// worker logging. On error, return whatever was redacted so far (best-effort).
|
|
1183
|
+
try {
|
|
1184
|
+
// Authorization: Bearer <token>
|
|
1185
|
+
s = s.replace(/(Bearer\s+)[A-Za-z0-9._\-]+/gi, '$1[REDACTED]')
|
|
1186
|
+
// sk-/key-like long opaque tokens (provider key prefixes + generic 32+ char tokens)
|
|
1187
|
+
s = s.replace(/\b(sk|sk-ant|pk|rk|gh[pousr]|xox[abprs]|ghp|ghs|ghu|ghr|github_pat)[-_][A-Za-z0-9_\-]{16,}/g, '[REDACTED]')
|
|
1188
|
+
s = s.replace(/\b[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}\b/g, '[REDACTED]')
|
|
1189
|
+
// --password=VALUE / --password VALUE / -p VALUE
|
|
1190
|
+
s = s.replace(/(--password(?:=|\s+)|--token(?:=|\s+)|--secret(?:=|\s+)|(?:^|\s)-p\s+)\S+/gi, '$1[REDACTED]')
|
|
1191
|
+
// URL userinfo: scheme://user:pass@host
|
|
1192
|
+
s = s.replace(/([a-zA-Z][a-zA-Z0-9+\-.]*:\/\/)([^\s/:@]+):([^\s/@]+)@/g, '$1[REDACTED]:[REDACTED]@')
|
|
1193
|
+
// Literal env secret values
|
|
1194
|
+
for (const v of secretEnvValues) {
|
|
1195
|
+
s = s.split(v).join('[REDACTED]')
|
|
1196
|
+
}
|
|
1197
|
+
} catch { /* best-effort: never throw out of redactSecrets */ }
|
|
1198
|
+
return s
|
|
1199
|
+
}
|
|
1200
|
+
const writeWorkerLogLine = (rawLine) => {
|
|
1201
|
+
// R14: sanitize AFTER redactSecrets so the redaction patterns still see
|
|
1202
|
+
// raw bytes (URL userinfo, Authorization headers), then strip ANSI / escape
|
|
1203
|
+
// lone CR + C0/C1 so a stderr line like "foo\r[fake-log-prefix] payload"
|
|
1204
|
+
// can't forge a new log entry or hide payloads behind a CR overwrite.
|
|
1205
|
+
const line = sanitizeLogField(redactSecrets(rawLine))
|
|
1206
|
+
const out = `[${new Date().toISOString()}] [${LOG_CONTEXT} worker=${name} workerPid=${proc.pid ?? '-'}] ${line}\n`
|
|
1207
|
+
try {
|
|
1208
|
+
if (!stderrStream) {
|
|
1209
|
+
stderrStream = createWriteStream(stderrPath, { flags: 'a' })
|
|
1210
|
+
stderrStream.on('error', () => {})
|
|
1211
|
+
}
|
|
1212
|
+
stderrStream.write(out)
|
|
1213
|
+
} catch {
|
|
1214
|
+
try { appendFileSync(stderrPath, out) } catch {}
|
|
1215
|
+
}
|
|
1216
|
+
try {
|
|
1217
|
+
if (!stderrScopedStream) {
|
|
1218
|
+
stderrScopedStream = createWriteStream(stderrScopedPath, { flags: 'a' })
|
|
1219
|
+
stderrScopedStream.on('error', () => {})
|
|
1220
|
+
}
|
|
1221
|
+
stderrScopedStream.write(out)
|
|
1222
|
+
} catch {
|
|
1223
|
+
try { appendFileSync(stderrScopedPath, out) } catch {}
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const writeWorkerLogChunk = (chunk) => {
|
|
1227
|
+
const text = stderrRemainder + String(chunk)
|
|
1228
|
+
const lines = text.split(/\r?\n/)
|
|
1229
|
+
stderrRemainder = lines.pop() ?? ''
|
|
1230
|
+
for (const line of lines) writeWorkerLogLine(line)
|
|
1231
|
+
}
|
|
1232
|
+
const closeWorkerLog = () => {
|
|
1233
|
+
if (stderrRemainder) {
|
|
1234
|
+
writeWorkerLogLine(stderrRemainder)
|
|
1235
|
+
stderrRemainder = ''
|
|
1236
|
+
}
|
|
1237
|
+
try { stderrStream?.end() } catch {}
|
|
1238
|
+
try { stderrScopedStream?.end() } catch {}
|
|
1239
|
+
}
|
|
1240
|
+
proc.stderr?.setEncoding?.('utf8')
|
|
1241
|
+
proc.stderr?.on('data', writeWorkerLogChunk)
|
|
1242
|
+
proc.stderr?.on('error', () => {})
|
|
1243
|
+
proc.once('exit', closeWorkerLog)
|
|
1244
|
+
|
|
1245
|
+
const entry = { proc, ready: false, pending: [] }
|
|
1246
|
+
// readyPromise lets callWorker await the worker's first 'ready' IPC instead
|
|
1247
|
+
// of rejecting immediately on entry.ready===false. Pre-ready callers (e.g.
|
|
1248
|
+
// SessionStart /cycle1) used to bounce off a 503 and rely on the hook's
|
|
1249
|
+
// 200ms retry loop; now they hold a single in-flight call until the worker
|
|
1250
|
+
// signals ready or the proc exits before that.
|
|
1251
|
+
entry.readyPromise = new Promise((resolve, reject) => {
|
|
1252
|
+
entry._resolveReady = resolve
|
|
1253
|
+
entry._rejectReady = reject
|
|
1254
|
+
})
|
|
1255
|
+
// Attach a no-op catch so a reject before any awaiter is hooked up does not
|
|
1256
|
+
// trigger unhandledRejection. The actual awaiter (callWorker) still observes
|
|
1257
|
+
// the rejection because await on a rejected promise re-throws.
|
|
1258
|
+
entry.readyPromise.catch(() => {})
|
|
1259
|
+
workers.set(name, entry)
|
|
1260
|
+
|
|
1261
|
+
proc.on('message', msg => {
|
|
1262
|
+
// R13: validate worker IPC frame shape before dispatch. A malformed frame
|
|
1263
|
+
// (null, array, primitive, or missing string type) used to throw at the
|
|
1264
|
+
// first field access or misroute into a privileged branch. Reject early.
|
|
1265
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || typeof msg.type !== 'string') {
|
|
1266
|
+
try { process.stderr.write(`[worker-ipc] dropped malformed frame from worker=${name}\n`) } catch {}
|
|
1267
|
+
return
|
|
1268
|
+
}
|
|
1269
|
+
try {
|
|
1270
|
+
if (msg.type === 'ready') {
|
|
1271
|
+
process.stderr.write(`[boot-time] tag=worker-ready name=${name} tMs=${Date.now()}\n`)
|
|
1272
|
+
if (msg.degraded) {
|
|
1273
|
+
log(`worker ${name} signalled degraded on boot: ${msg.error || 'unknown'}`)
|
|
1274
|
+
// Treat init failures as permanent (no retries): init errors indicate
|
|
1275
|
+
// unrecoverable state (e.g. pgdata corruption, missing schema) that
|
|
1276
|
+
// will not heal across restarts. Mark restart count at cap immediately
|
|
1277
|
+
// so the 'exit' handler skips respawn. This avoids 3 pointless retries
|
|
1278
|
+
// that each take several seconds and leave pgdata in a worse state.
|
|
1279
|
+
// Transient network / port-bind errors are expected to NOT send
|
|
1280
|
+
// degraded:true — they crash the worker without a 'ready' signal, so
|
|
1281
|
+
// the normal restart counter handles them.
|
|
1282
|
+
workerPermanentlyDegraded.add(name) // permanent — exit handler skips respawn
|
|
1283
|
+
try { entry._rejectReady(new Error(`worker ${name} degraded: ${msg.error || 'init failed'}`)) } catch {}
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
// Cache the channels worker's detected channel flag. Daemon ancestry is
|
|
1287
|
+
// constant for the daemon's lifetime, so respawned workers can inherit
|
|
1288
|
+
// this via the env spread and skip the (slow) ancestor-process walk.
|
|
1289
|
+
if (typeof msg.channelFlag === 'boolean') {
|
|
1290
|
+
process.env.MIXDOG_CHANNEL_FLAG = msg.channelFlag ? '1' : '0'
|
|
1291
|
+
}
|
|
1292
|
+
entry.ready = true
|
|
1293
|
+
workerIntentionalStop.delete(name)
|
|
1294
|
+
workerRestarts.delete(name) // stable boot resets the backoff/warn counter
|
|
1295
|
+
try { entry._resolveReady() } catch {}
|
|
1296
|
+
log(`worker ${name} ready (pid=${proc.pid})`)
|
|
1297
|
+
if (name === 'memory' && Number.isFinite(msg.port) && msg.port > 0) {
|
|
1298
|
+
const file = ACTIVE_INSTANCE_FILE
|
|
1299
|
+
const memoryServerPid = parsePositivePid(process.pid)
|
|
1300
|
+
// EPERM/EBUSY/EACCES on rename means an AV scanner briefly holds
|
|
1301
|
+
// the new .tmp file open while inspecting it. The lock typically
|
|
1302
|
+
// clears within 100-300ms. Without retry the merge is lost and
|
|
1303
|
+
// statusline / memory_port discovery falls back to stale state for
|
|
1304
|
+
// the rest of the process lifetime. Async retry chain keeps the
|
|
1305
|
+
// message handler unblocked while the next attempts run.
|
|
1306
|
+
const _retryMerge = (attempt) => {
|
|
1307
|
+
try {
|
|
1308
|
+
withFileLockSync(`${file}.lock`, () => {
|
|
1309
|
+
let cur = {}
|
|
1310
|
+
try { cur = JSON.parse(readFileSync(file, 'utf8')) } catch {}
|
|
1311
|
+
const curMemPort = Number(cur?.memory_port)
|
|
1312
|
+
const curMemPid = parsePositivePid(cur?.memory_server_pid)
|
|
1313
|
+
const portConflict =
|
|
1314
|
+
Number.isFinite(curMemPort) && curMemPort > 0 && curMemPort !== msg.port
|
|
1315
|
+
const otherOwnerAlive =
|
|
1316
|
+
curMemPid != null &&
|
|
1317
|
+
curMemPid !== memoryServerPid &&
|
|
1318
|
+
isMemoryOwnerPidAlive(curMemPid)
|
|
1319
|
+
if (portConflict && otherOwnerAlive) {
|
|
1320
|
+
log(`[server-main] skip memory_port ready merge port=${msg.port} curMemPort=${curMemPort} curMemPid=${curMemPid} memoryServerPid=${memoryServerPid || 'none'}`)
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
const next = {
|
|
1324
|
+
...cur,
|
|
1325
|
+
memory_port: msg.port,
|
|
1326
|
+
memory_server_pid: memoryServerPid,
|
|
1327
|
+
updatedAt: Date.now(),
|
|
1328
|
+
}
|
|
1329
|
+
writeJsonAtomicSync(file, next, { compact: true, fsyncDir: true })
|
|
1330
|
+
})
|
|
1331
|
+
} catch (e) {
|
|
1332
|
+
const transient = e?.code === 'EPERM' || e?.code === 'EBUSY' || e?.code === 'EACCES'
|
|
1333
|
+
if (transient && attempt < 3) {
|
|
1334
|
+
setTimeout(() => _retryMerge(attempt + 1), 50 * (attempt + 1))
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
log(`[server-main] active-instance memory_port merge failed (attempt ${attempt + 1}): ${e?.message || e}`)
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
_retryMerge(0)
|
|
1341
|
+
}
|
|
1342
|
+
return
|
|
1343
|
+
}
|
|
1344
|
+
if (msg.type === 'result' && typeof msg.callId === 'string' &&
|
|
1345
|
+
(Object.prototype.hasOwnProperty.call(msg, 'result') || typeof msg.error === 'string')) {
|
|
1346
|
+
// Clear any pending force-kill timer for this callId: a late result
|
|
1347
|
+
// after cooperative cancel means the worker is healthy and the
|
|
1348
|
+
// 5s SIGTERM timer from callWorker's timeout path must not fire.
|
|
1349
|
+
const killTimer = entry.killTimers?.get(msg.callId)
|
|
1350
|
+
if (killTimer) {
|
|
1351
|
+
clearTimeout(killTimer)
|
|
1352
|
+
entry.killTimers.delete(msg.callId)
|
|
1353
|
+
}
|
|
1354
|
+
const pending = entry.pending.find(p => p.callId === msg.callId)
|
|
1355
|
+
if (pending) {
|
|
1356
|
+
entry.pending = entry.pending.filter(p => p.callId !== msg.callId)
|
|
1357
|
+
if (msg.error) pending.reject(new Error(msg.error))
|
|
1358
|
+
else pending.resolve(msg.result)
|
|
1359
|
+
}
|
|
1360
|
+
return
|
|
1361
|
+
}
|
|
1362
|
+
if (msg.type === 'recap_status' && msg.recap) {
|
|
1363
|
+
recapStatusState = sanitizeRecapStatusState(msg.recap)
|
|
1364
|
+
forwardRecapStatusToStatusServer()
|
|
1365
|
+
return
|
|
1366
|
+
}
|
|
1367
|
+
if (msg.type === 'notify' && typeof msg.method === 'string') {
|
|
1368
|
+
// Worker → parent notification forwarding. The worker has no MCP
|
|
1369
|
+
// transport of its own; this is the single path that delivers Discord
|
|
1370
|
+
// inbound, schedule injects, webhook events, and interaction events
|
|
1371
|
+
// to the host (Claude Code) over the parent's connected Server.
|
|
1372
|
+
if (daemonNotifyRouter) {
|
|
1373
|
+
// Daemon mode: route to the request's origin connection (permission
|
|
1374
|
+
// responses) or the Lead connection (channel events). No stdio.
|
|
1375
|
+
daemonNotifyRouter.route(msg.method, msg.params || {})
|
|
1376
|
+
.catch(err => {
|
|
1377
|
+
log(`worker ${name} notify route failed (${msg.method}): ${err instanceof Error ? err.message : String(err)}`)
|
|
1378
|
+
})
|
|
1379
|
+
} else if (process.stdout.writableEnded || !process.stdout.writable) {
|
|
1380
|
+
log(`worker ${name} notify forward skipped — stdout closed (${msg.method})`)
|
|
1381
|
+
} else {
|
|
1382
|
+
server.notification(channelNotifyParamsForCc({ method: msg.method, params: msg.params || {} }))
|
|
1383
|
+
.catch(err => {
|
|
1384
|
+
log(`worker ${name} notify forward failed (${msg.method}): ${err instanceof Error ? err.message : String(err)}`)
|
|
1385
|
+
})
|
|
1386
|
+
}
|
|
1387
|
+
return
|
|
1388
|
+
}
|
|
1389
|
+
if (msg.type === 'agent_ipc_request' && typeof msg.callId === 'string' && typeof msg.tool === 'string') {
|
|
1390
|
+
// Worker → parent bridge LLM request. Memory worker cannot own the
|
|
1391
|
+
// provider registry / session manager (those live in the parent
|
|
1392
|
+
// process via loadModule('agent')), so cycle1 / cycle2 route every
|
|
1393
|
+
// LLM call here. We run the bridge call in-process, then ship the
|
|
1394
|
+
// raw assistant content back to the caller.
|
|
1395
|
+
_enqueueAgentIpcRequest(msg, name, proc)
|
|
1396
|
+
return
|
|
1397
|
+
}
|
|
1398
|
+
if (msg.type === 'agent_ipc_cancel' && typeof msg.callId === 'string') {
|
|
1399
|
+
// Worker timeout fired — stop the in-flight bridge LLM call before
|
|
1400
|
+
// it bills further tokens. parentSignal cascade aborts the
|
|
1401
|
+
// sub-session's own controller (bridge-llm.mjs:217-224).
|
|
1402
|
+
const _entry = _agentIpcInflight.get(msg.callId)
|
|
1403
|
+
if (_entry) {
|
|
1404
|
+
try { _entry.ctrl.abort() } catch {}
|
|
1405
|
+
_agentIpcInflight.delete(msg.callId)
|
|
1406
|
+
return
|
|
1407
|
+
}
|
|
1408
|
+
_cancelQueuedAgentIpc(msg.callId, proc)
|
|
1409
|
+
return
|
|
1410
|
+
}
|
|
1411
|
+
if (msg.type === 'memory_call_request' && msg.callId) {
|
|
1412
|
+
// Worker → parent → memory worker bridge. Lets non-memory workers
|
|
1413
|
+
// (e.g. channels) trigger memory tool actions like cycle1 without
|
|
1414
|
+
// owning the memory worker handle directly.
|
|
1415
|
+
// Worker handleToolCall only knows mcp tool names ('memory',
|
|
1416
|
+
// 'search_memories'); the action ('cycle1', 'flush', ...) lives in
|
|
1417
|
+
// args.action. Forwarding msg.action as the tool name made every
|
|
1418
|
+
// /cycle1 hit return "unknown tool: cycle1" instantly.
|
|
1419
|
+
// asyncAck: caller (e.g. channels worker chat ingest) opts in to an
|
|
1420
|
+
// immediate ack so it doesn't block on long-running memory work like
|
|
1421
|
+
// cycle2 / flush. Default path keeps the await semantics so cold-entry
|
|
1422
|
+
// cycle1 / recap flows still get the real result before continuing.
|
|
1423
|
+
if (msg.asyncAck) {
|
|
1424
|
+
try { proc.send({ type: 'memory_call_response', callId: msg.callId, ok: true, result: { acked: true } }) } catch {}
|
|
1425
|
+
callWorker('memory', 'memory', { action: msg.action, ...(msg.args || {}) })
|
|
1426
|
+
.catch(err => process.stderr.write(`[memory_call] async ${msg.action} rejected: ${err?.message || err}\n`))
|
|
1427
|
+
return
|
|
1428
|
+
}
|
|
1429
|
+
callWorker('memory', 'memory', { action: msg.action, ...(msg.args || {}) })
|
|
1430
|
+
.then(result => {
|
|
1431
|
+
try { proc.send({ type: 'memory_call_response', callId: msg.callId, ok: true, result }) } catch {}
|
|
1432
|
+
})
|
|
1433
|
+
.catch(err => {
|
|
1434
|
+
try { proc.send({ type: 'memory_call_response', callId: msg.callId, ok: false, error: err?.message || String(err) }) } catch {}
|
|
1435
|
+
})
|
|
1436
|
+
return
|
|
1437
|
+
}
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
try { process.stderr.write(`[worker-ipc] handler error worker=${name} type=${msg && msg.type}: ${err?.message || err}\n`) } catch {}
|
|
1440
|
+
}
|
|
1441
|
+
})
|
|
1442
|
+
|
|
1443
|
+
// Attach 'exit' before 'error' so a synchronous spawn-fail sees 'exit'
|
|
1444
|
+
// before 'error' — prevents dangling exit handler on early-fail path.
|
|
1445
|
+
proc.on('exit', (code) => {
|
|
1446
|
+
log(`worker ${name} exited (code=${code})`)
|
|
1447
|
+
workers.delete(name)
|
|
1448
|
+
// Abort any in-flight bridge LLM provider calls owned by this worker so
|
|
1449
|
+
// a dying worker stops billing tokens for results nobody is waiting on.
|
|
1450
|
+
let _abortedIpc = 0
|
|
1451
|
+
for (const [_cid, _e] of _agentIpcInflight) {
|
|
1452
|
+
if (_e.worker === name) {
|
|
1453
|
+
try { _e.ctrl.abort() } catch {}
|
|
1454
|
+
_agentIpcInflight.delete(_cid)
|
|
1455
|
+
_abortedIpc++
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
if (_abortedIpc > 0) log(`worker ${name} exit — aborted ${_abortedIpc} in-flight agent_ipc call(s)`)
|
|
1459
|
+
_purgeAgentIpcForWorker(name)
|
|
1460
|
+
// Intentional stop: parent sent shutdown IPC/SIGTERM — do not respawn.
|
|
1461
|
+
if (workerIntentionalStop.has(name)) {
|
|
1462
|
+
log(`worker ${name} stopped intentionally — skipping respawn`)
|
|
1463
|
+
return
|
|
1464
|
+
}
|
|
1465
|
+
if (!entry.ready) {
|
|
1466
|
+
try { entry._rejectReady(new Error(`worker ${name} exited before ready (code=${code})`)) } catch {}
|
|
1467
|
+
}
|
|
1468
|
+
for (const p of entry.pending) {
|
|
1469
|
+
p.reject(new Error(`worker ${name} exited unexpectedly`))
|
|
1470
|
+
}
|
|
1471
|
+
if (workerPermanentlyDegraded.has(name)) {
|
|
1472
|
+
log(`worker ${name} permanently degraded — skipping respawn`)
|
|
1473
|
+
return
|
|
1474
|
+
}
|
|
1475
|
+
const count = (workerRestarts.get(name) || 0) + 1
|
|
1476
|
+
workerRestarts.set(name, count)
|
|
1477
|
+
const backoffMs = Math.min(1000 * Math.pow(2, Math.min(count - 1, 6)), WORKER_MAX_BACKOFF_MS)
|
|
1478
|
+
if (count <= WORKER_RESTART_WARN_AFTER) {
|
|
1479
|
+
log(`restarting worker ${name} (attempt ${count}, backoff ${backoffMs}ms)`)
|
|
1480
|
+
} else {
|
|
1481
|
+
log(`[WARN] worker ${name} repeated restart (attempt ${count}, backoff ${backoffMs}ms) — investigate root cause`)
|
|
1482
|
+
}
|
|
1483
|
+
setTimeout(() => spawnWorker(name), backoffMs)
|
|
1484
|
+
})
|
|
1485
|
+
|
|
1486
|
+
proc.on('error', (err) => {
|
|
1487
|
+
log(`worker ${name} error: ${err.message}`)
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
// IPC disconnect handler: if the channel drops while calls are in flight,
|
|
1491
|
+
// reject pending immediately with a stable message so callers don't wait
|
|
1492
|
+
// for the full WORKER_CALL_TIMEOUT before surfacing an error.
|
|
1493
|
+
proc.once('disconnect', () => {
|
|
1494
|
+
const snap = [...entry.pending]
|
|
1495
|
+
entry.pending = []
|
|
1496
|
+
for (const p of snap) {
|
|
1497
|
+
p.reject(new Error(`worker ${name} disconnected`))
|
|
1498
|
+
}
|
|
1499
|
+
if (!entry.ready) {
|
|
1500
|
+
try { entry._rejectReady(new Error(`worker ${name} disconnected before ready`)) } catch {}
|
|
1501
|
+
}
|
|
1502
|
+
// Abort in-flight bridge LLM provider calls owned by this worker — the
|
|
1503
|
+
// IPC channel is gone so the response can never be delivered; letting
|
|
1504
|
+
// the provider call run on would only burn tokens.
|
|
1505
|
+
let _abortedIpc = 0
|
|
1506
|
+
for (const [_cid, _e] of _agentIpcInflight) {
|
|
1507
|
+
if (_e.worker === name) {
|
|
1508
|
+
try { _e.ctrl.abort() } catch {}
|
|
1509
|
+
_agentIpcInflight.delete(_cid)
|
|
1510
|
+
_abortedIpc++
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
_purgeAgentIpcForWorker(name)
|
|
1514
|
+
log(`worker ${name} IPC disconnected — rejected ${snap.length} pending call(s), aborted ${_abortedIpc} agent_ipc call(s)`)
|
|
1515
|
+
})
|
|
1516
|
+
|
|
1517
|
+
return entry
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
let _callIdSeq = 0
|
|
1521
|
+
const WORKER_CALL_TIMEOUT = 600000 // 10m per tool call
|
|
1522
|
+
// Window for awaiting a missing worker entry. Covers the 1s exit→spawn
|
|
1523
|
+
// timer plus typical memory boot (~2-3s). Long enough that the entry
|
|
1524
|
+
// reappears under normal restart flow, short enough that a permanently
|
|
1525
|
+
// dead worker still surfaces within bounds.
|
|
1526
|
+
const WORKER_NO_ENTRY_GRACE_MS = 8000
|
|
1527
|
+
|
|
1528
|
+
async function callWorker(name, toolName, args) {
|
|
1529
|
+
let entry = workers.get(name)
|
|
1530
|
+
// worker-unavailable: only restart-cap-exceeded and ipc-gone cases reject
|
|
1531
|
+
// synchronously. The pre-ready and mid-restart cases hold under bounded
|
|
1532
|
+
// waits so callers (e.g. SessionStart /cycle1) stop bouncing 503 across
|
|
1533
|
+
// the exit→spawn gap. exit-before-ready rejects readyPromise, which
|
|
1534
|
+
// surfaces here as a normal throw with the original 'exited before ready'
|
|
1535
|
+
// message preserved.
|
|
1536
|
+
if (!entry) {
|
|
1537
|
+
if (workerPermanentlyDegraded.has(name)) {
|
|
1538
|
+
throw new Error(`worker ${name} not available (permanently degraded)`)
|
|
1539
|
+
}
|
|
1540
|
+
const deadline = Date.now() + WORKER_NO_ENTRY_GRACE_MS
|
|
1541
|
+
while (Date.now() < deadline) {
|
|
1542
|
+
await new Promise(r => setTimeout(r, 100))
|
|
1543
|
+
entry = workers.get(name)
|
|
1544
|
+
if (entry) break
|
|
1545
|
+
if (workerPermanentlyDegraded.has(name)) {
|
|
1546
|
+
throw new Error(`worker ${name} not available (permanently degraded)`)
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (!entry) {
|
|
1550
|
+
throw new Error(`worker ${name} not available (no entry after ${WORKER_NO_ENTRY_GRACE_MS}ms)`)
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
if (!entry.proc.connected) {
|
|
1554
|
+
throw new Error(`worker ${name} not available (ipc disconnected)`)
|
|
1555
|
+
}
|
|
1556
|
+
if (!entry.ready) {
|
|
1557
|
+
// Bound the readyPromise wait: a worker process can be alive (no exit
|
|
1558
|
+
// event, no IPC disconnect) yet never send its 'ready' IPC if init
|
|
1559
|
+
// hangs (DB connect stall, vector index rebuild). Without a deadline
|
|
1560
|
+
// here, worker-to-worker calls would block indefinitely.
|
|
1561
|
+
const READY_WAIT_MS = 30000
|
|
1562
|
+
let readyTimer = null
|
|
1563
|
+
const readyTimeout = new Promise((_, reject) => {
|
|
1564
|
+
readyTimer = setTimeout(() => {
|
|
1565
|
+
reject(new Error(`worker ${name} not ready within ${READY_WAIT_MS}ms`))
|
|
1566
|
+
}, READY_WAIT_MS)
|
|
1567
|
+
readyTimer.unref?.()
|
|
1568
|
+
})
|
|
1569
|
+
try {
|
|
1570
|
+
await Promise.race([entry.readyPromise, readyTimeout])
|
|
1571
|
+
} finally {
|
|
1572
|
+
if (readyTimer) clearTimeout(readyTimer)
|
|
1573
|
+
}
|
|
1574
|
+
if (!entry.proc.connected) {
|
|
1575
|
+
throw new Error(`worker ${name} not available (ipc disconnected)`)
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
return new Promise((resolve, reject) => {
|
|
1579
|
+
const callId = String(++_callIdSeq)
|
|
1580
|
+
const timer = setTimeout(() => {
|
|
1581
|
+
entry.pending = entry.pending.filter(p => p.callId !== callId)
|
|
1582
|
+
// Signal the worker to cancel in-flight work for this callId.
|
|
1583
|
+
try {
|
|
1584
|
+
if (entry.proc?.connected) {
|
|
1585
|
+
entry.proc.send({ type: 'cancel', callId })
|
|
1586
|
+
// Force-kill if worker doesn't ack within 5s.
|
|
1587
|
+
// Track the kill timer on the entry keyed by callId so the IPC
|
|
1588
|
+
// result handler can clear it when the worker delivers a late
|
|
1589
|
+
// result (cooperative cancel succeeded after we gave up waiting).
|
|
1590
|
+
// Without this clear, a single timed-out call always killed the
|
|
1591
|
+
// whole worker even when it returned a clean result in time.
|
|
1592
|
+
if (!entry.killTimers) entry.killTimers = new Map()
|
|
1593
|
+
const killTimer = setTimeout(() => {
|
|
1594
|
+
entry.killTimers?.delete(callId)
|
|
1595
|
+
log(`worker ${name} did not ack cancel for ${callId} — force-killing`)
|
|
1596
|
+
try { entry.proc.kill('SIGTERM') } catch {}
|
|
1597
|
+
}, 5000)
|
|
1598
|
+
if (killTimer.unref) killTimer.unref()
|
|
1599
|
+
entry.killTimers.set(callId, killTimer)
|
|
1600
|
+
}
|
|
1601
|
+
} catch {}
|
|
1602
|
+
// Persist dispatch state so recoverPending surfaces it on next boot.
|
|
1603
|
+
const _dataDir = process.env.CLAUDE_PLUGIN_DATA
|
|
1604
|
+
if (_dataDir) {
|
|
1605
|
+
import('./src/agent/orchestrator/dispatch-persist.mjs').then(({ addPending }) => {
|
|
1606
|
+
addPending(_dataDir, `timeout_${callId}_${Date.now()}`, 'bridge', [`worker ${name} call ${toolName} timed out`])
|
|
1607
|
+
}).catch(() => {})
|
|
1608
|
+
}
|
|
1609
|
+
reject(new Error(`worker ${name} call ${toolName} timed out after ${WORKER_CALL_TIMEOUT}ms`))
|
|
1610
|
+
}, WORKER_CALL_TIMEOUT)
|
|
1611
|
+
entry.pending.push({ callId, resolve: v => { clearTimeout(timer); resolve(v) }, reject: e => { clearTimeout(timer); reject(e) } })
|
|
1612
|
+
try {
|
|
1613
|
+
// child.send() returning false means the IPC channel applied
|
|
1614
|
+
// backpressure — the message is queued internally by Node and will be
|
|
1615
|
+
// flushed when the channel drains; it is NOT a delivery failure.
|
|
1616
|
+
// Rejecting here while the worker may still receive (and execute)
|
|
1617
|
+
// the call leaves the parent waiting on a pending it just forgot
|
|
1618
|
+
// about, AND lets a side-effecting tool run with the parent
|
|
1619
|
+
// believing it failed. Keep the pending entry in place and let the
|
|
1620
|
+
// existing WORKER_CALL_TIMEOUT bound the wait if the channel never
|
|
1621
|
+
// drains; an actual transport failure surfaces through the catch
|
|
1622
|
+
// below or via the 'exit'/'disconnect' handlers that reject pending.
|
|
1623
|
+
entry.proc.send({ type: 'call', callId, name: toolName, args })
|
|
1624
|
+
} catch (sendErr) {
|
|
1625
|
+
clearTimeout(timer)
|
|
1626
|
+
entry.pending = entry.pending.filter(p => p.callId !== callId)
|
|
1627
|
+
reject(new Error(`worker ${name} send failed: ${sendErr.message}`))
|
|
1628
|
+
}
|
|
1629
|
+
})
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// ── Module loader (cached, init+start runs once per module) ─────────
|
|
1633
|
+
const modules = new Map()
|
|
1634
|
+
|
|
1635
|
+
function pushChannelNotification(content, extraMeta) {
|
|
1636
|
+
// Single exit path for BOTH channel notifications (schedule / webhook /
|
|
1637
|
+
// queue / bridge lifecycle) AND dispatch results (recall / search
|
|
1638
|
+
// / explore merged answers tagged `meta.type: 'dispatch_result'`). Despite
|
|
1639
|
+
// the name, this function is bidirectional — the `extraMeta.type` field
|
|
1640
|
+
// distinguishes the two flavours for downstream routing, not this function.
|
|
1641
|
+
//
|
|
1642
|
+
// `silent_to_agent: true` — bridge lifecycle status pings (worker started,
|
|
1643
|
+
// iter N, role-start echoes) that should surface on Discord but NOT land
|
|
1644
|
+
// in the Lead agent's context window. When set we skip the Lead-notify
|
|
1645
|
+
// hop entirely and ask the channels worker to post the content directly
|
|
1646
|
+
// to the currently-active bridge channel. The meta flag is otherwise
|
|
1647
|
+
// forwarded downstream so any future consumer that sees it can recognise
|
|
1648
|
+
// and drop it. Default (flag absent/false) → legacy behaviour preserved.
|
|
1649
|
+
const meta = { user: 'mixdog-agent', user_id: 'system', ts: new Date().toISOString(), ...(extraMeta || {}) }
|
|
1650
|
+
const silent = meta.silent_to_agent === true
|
|
1651
|
+
if (silent) {
|
|
1652
|
+
const entry = workers.get('channels')
|
|
1653
|
+
if (entry?.proc?.connected) {
|
|
1654
|
+
try {
|
|
1655
|
+
const sent = entry.proc.send({ type: 'forward_to_discord', content, channelId: meta.chat_id || null })
|
|
1656
|
+
if (sent === false) {
|
|
1657
|
+
log(`[agent-notify] silent forward IPC channel full or closed — dropping`)
|
|
1658
|
+
}
|
|
1659
|
+
} catch (err) {
|
|
1660
|
+
log(`[agent-notify] silent forward IPC failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return Promise.resolve()
|
|
1664
|
+
}
|
|
1665
|
+
// Daemon mode: the module-global `server` is NOT connected (each client has
|
|
1666
|
+
// its own per-connection server). Route through the daemon notify router,
|
|
1667
|
+
// which delivers a session-scoped result (meta.caller_session_id) to the
|
|
1668
|
+
// dispatching terminal and a channel event to the active-instance owner.
|
|
1669
|
+
// Without this, the server.notification() below targets an unconnected
|
|
1670
|
+
// server and the notification is silently lost.
|
|
1671
|
+
//
|
|
1672
|
+
// If a SESSION-scoped dispatch result could not be delivered (originating
|
|
1673
|
+
// terminal not connected), reject so the persist layer's notify→removePending
|
|
1674
|
+
// chain LEAVES the entry on disk; per-session recoverPending re-delivers it
|
|
1675
|
+
// when that terminal reconnects (control-frame trigger in serveDaemon).
|
|
1676
|
+
// Channel events (no caller_session_id) just resolve — a missing owner drop
|
|
1677
|
+
// is surfaced again via the queue / Discord, not the pending file.
|
|
1678
|
+
if (daemonNotifyRouter) {
|
|
1679
|
+
return daemonNotifyRouter.route('notifications/claude/channel', { content, meta })
|
|
1680
|
+
.then((delivered) => {
|
|
1681
|
+
if (!delivered && (meta.caller_session_id != null || meta.type === 'dispatch_result')) {
|
|
1682
|
+
throw new Error('dispatch result for session ' + meta.caller_session_id + ' undelivered — retained for replay')
|
|
1683
|
+
}
|
|
1684
|
+
})
|
|
1685
|
+
}
|
|
1686
|
+
// Pre-flight: if stdout is already closed, skip the write to avoid EPIPE.
|
|
1687
|
+
if (process.stdout.writableEnded || !process.stdout.writable) {
|
|
1688
|
+
log('[agent-notify] stdout closed; skipping notification')
|
|
1689
|
+
return Promise.resolve()
|
|
1690
|
+
}
|
|
1691
|
+
try {
|
|
1692
|
+
return server.notification(channelNotifyParamsForCc({
|
|
1693
|
+
method: 'notifications/claude/channel',
|
|
1694
|
+
params: { content, meta },
|
|
1695
|
+
})).catch(err => {
|
|
1696
|
+
log(`[agent-notify] channel failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
1697
|
+
})
|
|
1698
|
+
} catch (err) {
|
|
1699
|
+
log(`[agent-notify] sync throw (likely EPIPE): ${err instanceof Error ? err.message : String(err)}`)
|
|
1700
|
+
return Promise.resolve()
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function agentContext() {
|
|
1705
|
+
return {
|
|
1706
|
+
notifyFn: (text, extraMeta) => pushChannelNotification(text, extraMeta),
|
|
1707
|
+
elicitFn: opts => server.elicitInput(opts),
|
|
1708
|
+
// In-process tool bridge. External LLMs see the plugin's non-agent tools
|
|
1709
|
+
// (search, search_memories, channels actions, etc.) and their tool_calls
|
|
1710
|
+
// land back in dispatchTool, which routes to the same worker IPC /
|
|
1711
|
+
// in-process module the MCP call handler uses. Replaces the MCP HTTP
|
|
1712
|
+
// loopback path. agent-module tools are refused to prevent recursion.
|
|
1713
|
+
toolExecutor: async (name, args, callerCtx = {}) => {
|
|
1714
|
+
// agent-module tools normally refused via bridge to prevent recursion.
|
|
1715
|
+
// Exception: aiWrapped retrieval wrappers (`explore`) spawn one hidden
|
|
1716
|
+
// role and are guarded against re-entry in ai-wrapped-dispatch.mjs —
|
|
1717
|
+
// safe to dispatch from public bridge workers for open-locate briefs.
|
|
1718
|
+
if (TOOL_MODULE[name] === 'agent' && !TOOL_BY_NAME[name]?.aiWrapped) {
|
|
1719
|
+
throw new Error(`tool "${name}" is agent-internal and cannot be invoked via bridge`)
|
|
1720
|
+
}
|
|
1721
|
+
return dispatchTool(name, args, callerCtx)
|
|
1722
|
+
},
|
|
1723
|
+
internalTools: TOOL_DEFS.filter(t => {
|
|
1724
|
+
// Same exception as toolExecutor above: agent-module aiWrapped tools
|
|
1725
|
+
// (`explore`) are surfaced to public workers; plain agent-module tools
|
|
1726
|
+
// remain hidden to prevent recursion.
|
|
1727
|
+
if (t.module === 'agent') return t.aiWrapped === true
|
|
1728
|
+
return true
|
|
1729
|
+
}),
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
async function loadModule(name) {
|
|
1734
|
+
let entry = modules.get(name)
|
|
1735
|
+
if (entry) return entry
|
|
1736
|
+
const url = pathToFileURL(join(PLUGIN_ROOT, 'src', name, 'index.mjs')).href
|
|
1737
|
+
const mod = await import(url)
|
|
1738
|
+
if (mod.init) await mod.init(server)
|
|
1739
|
+
if (mod.start) await mod.start()
|
|
1740
|
+
entry = mod
|
|
1741
|
+
modules.set(name, entry)
|
|
1742
|
+
log(`module ${name} ready`)
|
|
1743
|
+
return entry
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Tilde expansion for caller-supplied `cwd`. Mirrors the `~` branch of
|
|
1747
|
+
// normalizeInputPath() in builtin.mjs but kept inline so the dispatcher
|
|
1748
|
+
// does not have to pre-load the whole builtin module at boot.
|
|
1749
|
+
function _expandCwdTilde(p) {
|
|
1750
|
+
if (typeof p !== 'string') return p
|
|
1751
|
+
if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) return homedir() + p.slice(1)
|
|
1752
|
+
return p
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// Shared dispatcher — used by the MCP call handler AND the agent's
|
|
1756
|
+
// toolExecutor passed through agentContext(). Single source of tool routing.
|
|
1757
|
+
// Public entry wraps body with start/end/error logs so BOTH call paths
|
|
1758
|
+
// (MCP CallToolRequest + bridge role toolExecutor) emit complete telemetry.
|
|
1759
|
+
function _shortHash(value) {
|
|
1760
|
+
return createHash('sha256').update(String(value || '')).digest('hex').slice(0, 8)
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
// Re-exec tracking for bash: same cmdHash arriving within TTL is recorded
|
|
1764
|
+
// as a marker line, so silent client-side response loss (server logged
|
|
1765
|
+
// start+ok but the MCP transport never delivered the result to the host)
|
|
1766
|
+
// surfaces as the same command being re-issued. Pure Map lookup; no
|
|
1767
|
+
// heuristic branch — the marker fires iff a prior entry exists within
|
|
1768
|
+
// TTL. Bound size; oldest-first eviction is an invariant of Map insertion
|
|
1769
|
+
// order.
|
|
1770
|
+
const _RECENT_BASH_TTL_MS = 10 * 60_000
|
|
1771
|
+
const _RECENT_BASH_MAX = 500
|
|
1772
|
+
const _recentBashCommands = new Map()
|
|
1773
|
+
|
|
1774
|
+
function _trackBashRecurrence(cmdHash, now) {
|
|
1775
|
+
if (!cmdHash) return null
|
|
1776
|
+
const prev = _recentBashCommands.get(cmdHash)
|
|
1777
|
+
let marker = null
|
|
1778
|
+
if (prev && now - prev.ts <= _RECENT_BASH_TTL_MS) {
|
|
1779
|
+
const gap = now - prev.ts
|
|
1780
|
+
prev.count += 1
|
|
1781
|
+
prev.ts = now
|
|
1782
|
+
_recentBashCommands.delete(cmdHash)
|
|
1783
|
+
_recentBashCommands.set(cmdHash, prev)
|
|
1784
|
+
marker = `[bash] re-exec same-hash cmdHash=${cmdHash} gap=${gap}ms count=${prev.count}`
|
|
1785
|
+
} else {
|
|
1786
|
+
if (prev) _recentBashCommands.delete(cmdHash)
|
|
1787
|
+
_recentBashCommands.set(cmdHash, { ts: now, count: 1 })
|
|
1788
|
+
}
|
|
1789
|
+
while (_recentBashCommands.size > _RECENT_BASH_MAX) {
|
|
1790
|
+
const firstKey = _recentBashCommands.keys().next().value
|
|
1791
|
+
if (firstKey === undefined) break
|
|
1792
|
+
_recentBashCommands.delete(firstKey)
|
|
1793
|
+
}
|
|
1794
|
+
return marker
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
function _flatPreview(value, cap = 80) {
|
|
1798
|
+
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, cap).replace(/[^\x20-\x7e]/g, '?')
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function _dispatchInputShape(name, args) {
|
|
1802
|
+
try {
|
|
1803
|
+
if (name === 'bash') {
|
|
1804
|
+
const command = String(args?.command || '')
|
|
1805
|
+
return command ? ` cmdHash=${_shortHash(command)} cmdPreview="${_flatPreview(command)}"` : ''
|
|
1806
|
+
}
|
|
1807
|
+
if (name === 'search') {
|
|
1808
|
+
const hasUrl = args && Object.prototype.hasOwnProperty.call(args, 'url') && args.url !== undefined
|
|
1809
|
+
const input = hasUrl ? args.url : (args?.query ?? args?.keywords ?? '')
|
|
1810
|
+
const count = Array.isArray(input) ? input.length : input ? 1 : 0
|
|
1811
|
+
const mode = hasUrl ? 'url' : 'query'
|
|
1812
|
+
const site = args?.site ? ` site="${_flatPreview(args.site, 60)}"` : ''
|
|
1813
|
+
const type = args?.type ? ` type=${_flatPreview(args.type, 20)}` : ''
|
|
1814
|
+
return ` mode=${mode} itemCount=${count} inputHash=${_shortHash(JSON.stringify(input))}${site}${type}`
|
|
1815
|
+
}
|
|
1816
|
+
} catch {}
|
|
1817
|
+
return ''
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
function _bashDispatchCeilingMs(args) {
|
|
1821
|
+
const MAX_BASH_TIMEOUT_MS = 1_800_000
|
|
1822
|
+
const DISPATCH_GRACE_MS = 30_000
|
|
1823
|
+
if (!(typeof args?.timeout === 'number' && args.timeout > 0)) {
|
|
1824
|
+
return null
|
|
1825
|
+
}
|
|
1826
|
+
const raw = args.timeout
|
|
1827
|
+
const timeoutMs = raw <= 600 ? raw * 1000 : raw
|
|
1828
|
+
const effectiveTimeoutMs = Math.min(Math.max(timeoutMs, 1_000), MAX_BASH_TIMEOUT_MS)
|
|
1829
|
+
return effectiveTimeoutMs + DISPATCH_GRACE_MS
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Build the cancellation/ceiling wiring for a dispatch: ceiling timer +
|
|
1833
|
+
// combined abort signal that folds caller-provided abortSignal and the MCP
|
|
1834
|
+
// requestSignal together with the ceiling's own AbortController. Returned
|
|
1835
|
+
// `clearCeiling` is idempotent so both the success and error paths in
|
|
1836
|
+
// dispatchTool can call it. Preserves the recent fixes (killTimer/ceiling
|
|
1837
|
+
// clearing and combined abort signal) by routing every cleanup through
|
|
1838
|
+
// the same closure.
|
|
1839
|
+
function _buildDispatchCancellation(name, args, callerCtx) {
|
|
1840
|
+
const CEILING_MS = name === 'bash' ? _bashDispatchCeilingMs(args) : 630_000
|
|
1841
|
+
const _abortCtl = new AbortController()
|
|
1842
|
+
let _ceilingTimer
|
|
1843
|
+
let _ceilingPromise = null
|
|
1844
|
+
if (typeof CEILING_MS === 'number' && CEILING_MS > 0) {
|
|
1845
|
+
_ceilingPromise = new Promise((_, reject) => {
|
|
1846
|
+
_ceilingTimer = setTimeout(() => {
|
|
1847
|
+
try { _abortCtl.abort(new Error(`dispatch ceiling exceeded (${CEILING_MS}ms) for tool=${name}`)) } catch {}
|
|
1848
|
+
reject(new Error(`dispatch ceiling exceeded (${CEILING_MS}ms) for tool=${name}`))
|
|
1849
|
+
}, CEILING_MS)
|
|
1850
|
+
_ceilingTimer.unref?.()
|
|
1851
|
+
})
|
|
1852
|
+
}
|
|
1853
|
+
// Combine ceiling abort with any caller-provided abortSignal so neither
|
|
1854
|
+
// masks the other. Both signals propagate to the tool impl; whichever
|
|
1855
|
+
// aborts first wins. Node 20.3+ provides AbortSignal.any.
|
|
1856
|
+
// Also fold in callerCtx.requestSignal (the MCP client-side cancellation
|
|
1857
|
+
// forwarded from the CallTool handler) so direct MCP cancellation
|
|
1858
|
+
// reaches builtins / code-graph / patch paths that only inspect
|
|
1859
|
+
// callerCtx.abortSignal. Without this, the requestSignal was only
|
|
1860
|
+
// visible to the agent module via the explicit ctx.requestSignal handoff.
|
|
1861
|
+
const _abortSignals = []
|
|
1862
|
+
if (_ceilingPromise) _abortSignals.push(_abortCtl.signal)
|
|
1863
|
+
if (callerCtx.abortSignal) _abortSignals.push(callerCtx.abortSignal)
|
|
1864
|
+
if (callerCtx.requestSignal && callerCtx.requestSignal !== callerCtx.abortSignal) {
|
|
1865
|
+
_abortSignals.push(callerCtx.requestSignal)
|
|
1866
|
+
}
|
|
1867
|
+
const _combinedSignal = _abortSignals.length > 1
|
|
1868
|
+
? AbortSignal.any(_abortSignals)
|
|
1869
|
+
: (_abortSignals[0] || null)
|
|
1870
|
+
const _ctxWithSignal = _combinedSignal
|
|
1871
|
+
? { ...callerCtx, abortSignal: _combinedSignal }
|
|
1872
|
+
: callerCtx
|
|
1873
|
+
const clearCeiling = () => { if (_ceilingTimer) clearTimeout(_ceilingTimer) }
|
|
1874
|
+
return { ctxWithSignal: _ctxWithSignal, ceilingPromise: _ceilingPromise, clearCeiling }
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
async function dispatchTool(name, args, callerCtx = {}) {
|
|
1878
|
+
const _t0 = Date.now()
|
|
1879
|
+
const _id = `${process.pid}-${_callIdSeq++}`
|
|
1880
|
+
// Diagnostic logging: every dispatch emits start + ok/error unconditionally
|
|
1881
|
+
// so any hang is visible as start-without-ok in mcp-debug.log. Writes go
|
|
1882
|
+
// direct to _logAppend, bypassing the pair-suppression wrapper.
|
|
1883
|
+
_logAppend(_logLine(`[dispatch] start id=${_id} tool=${name}${_dispatchInputShape(name, args)}`))
|
|
1884
|
+
if (name === 'bash') {
|
|
1885
|
+
const cmd = String(args?.command || '')
|
|
1886
|
+
if (cmd) {
|
|
1887
|
+
const marker = _trackBashRecurrence(_shortHash(cmd), _t0)
|
|
1888
|
+
if (marker) _logAppend(_logLine(marker))
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
// Transport safety ceiling, not a tool-efficiency classifier. `bash` owns
|
|
1892
|
+
// its requested timeout; the dispatcher only adds grace so bash treeKill /
|
|
1893
|
+
// persistent-shell cleanup can settle before the outer race rejects. Other
|
|
1894
|
+
// tools keep a fixed ceiling to avoid orphaned dispatch slots.
|
|
1895
|
+
const { ctxWithSignal: _ctxWithSignal, ceilingPromise: _ceilingPromise, clearCeiling: _clearCeiling } =
|
|
1896
|
+
_buildDispatchCancellation(name, args, callerCtx)
|
|
1897
|
+
// Central live-progress emit: when the caller threaded a progress reporter
|
|
1898
|
+
// (MCP progressToken present), fire ONE per-tool start message here and mark
|
|
1899
|
+
// the downstream ctx so executeBuiltinTool's fallback emit does not double up.
|
|
1900
|
+
// No-op when ctxWithSignal.progress is null (no token) — reporter is null →
|
|
1901
|
+
// not a function → path stays byte-identical to the no-progress behaviour.
|
|
1902
|
+
let _ctxForImpl = _ctxWithSignal
|
|
1903
|
+
if (typeof _ctxWithSignal.progress === 'function') {
|
|
1904
|
+
try { _ctxWithSignal.progress(formatToolStartProgress(name, args)) } catch { /* progress is best-effort */ }
|
|
1905
|
+
_ctxForImpl = { ..._ctxWithSignal, progressStarted: true }
|
|
1906
|
+
}
|
|
1907
|
+
try {
|
|
1908
|
+
const _dispatchPromise = _dispatchToolImpl(name, args, _ctxForImpl)
|
|
1909
|
+
const _result = _ceilingPromise
|
|
1910
|
+
? await Promise.race([_dispatchPromise, _ceilingPromise])
|
|
1911
|
+
: await _dispatchPromise
|
|
1912
|
+
_clearCeiling()
|
|
1913
|
+
const elapsed = Date.now() - _t0
|
|
1914
|
+
_logAppend(_logLine(`[dispatch] ok id=${_id} tool=${name} elapsed=${elapsed}ms`))
|
|
1915
|
+
// App-level errors travel as strings (`Error [code N]: ...`) without
|
|
1916
|
+
// raising — record them in tool-events.log so cross-session audits
|
|
1917
|
+
// can find read/edit invariant failures by grep.
|
|
1918
|
+
_recordToolApplicationError(name, _result)
|
|
1919
|
+
return _result
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
_clearCeiling()
|
|
1922
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1923
|
+
_logAppend(_logLine(`[dispatch] error id=${_id} tool=${name} elapsed=${Date.now() - _t0}ms msg=${msg.slice(0, 200)}`))
|
|
1924
|
+
throw err
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// Schema/lookup normalisation: tilde-expand a caller-supplied cwd once at
|
|
1929
|
+
// dispatch entry and resolve the tool definition. Throws the same
|
|
1930
|
+
// disabled-module vs unknown-tool error messages the inline branch did so
|
|
1931
|
+
// callers see no behaviour change.
|
|
1932
|
+
function _resolveDispatchToolDef(name, args) {
|
|
1933
|
+
// Normalise caller-supplied `cwd` once at the entry so every downstream
|
|
1934
|
+
// module (builtin / lsp / code_graph / patch / bash_session /
|
|
1935
|
+
// host_input / agent) receives the expanded path. Previously only the
|
|
1936
|
+
// agent ingresses (the unified `bridge` tool) ran tilde
|
|
1937
|
+
// expansion, so explore / list / grep / glob with a `~` cwd silently
|
|
1938
|
+
// fell back to process.cwd().
|
|
1939
|
+
if (args && typeof args.cwd === 'string') args.cwd = _expandCwdTilde(args.cwd)
|
|
1940
|
+
const def = TOOL_BY_NAME[name]
|
|
1941
|
+
if (!def) {
|
|
1942
|
+
// Distinguish "disabled module" from "unknown tool" so callers (and
|
|
1943
|
+
// the Lead) get an actionable message instead of a generic miss.
|
|
1944
|
+
const rawDef = RAW_TOOL_DEFS.find(t => t.name === name)
|
|
1945
|
+
if (rawDef && rawDef.module && MODULE_NAMES.includes(rawDef.module) && !isModuleEnabled(rawDef.module)) {
|
|
1946
|
+
throw new Error(`module '${rawDef.module}' is disabled — enable it in the setup UI (General → Modules) and restart the plugin`)
|
|
1947
|
+
}
|
|
1948
|
+
throw new Error(`Unknown tool: ${name}`)
|
|
1949
|
+
}
|
|
1950
|
+
return def
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// recall / search bypass the ai-wrapped dispatcher entirely. Both are
|
|
1954
|
+
// pure mechanical fan-out (array → worker handleSearch array branch /
|
|
1955
|
+
// search-backend Promise.allSettled), so the LLM-routing scaffolding
|
|
1956
|
+
// (makeBridgeLlm import, ROLE_BY_TOOL lookup, recursion guard) that
|
|
1957
|
+
// ai-wrapped still ships for explore adds no value here. Inlining the
|
|
1958
|
+
// worker / backend call also lets recall's array branch keep the
|
|
1959
|
+
// embedTexts pre-warm path (worker, not Lead, runs the batch ONNX call)
|
|
1960
|
+
// intact: previously the Lead-side dispatcher fanned out into N
|
|
1961
|
+
// single-query callMemoryWorker requests, which forced the worker into
|
|
1962
|
+
// its single-flight inference queue and re-introduced the per-query
|
|
1963
|
+
// stagger the embed-batch patch was meant to fix.
|
|
1964
|
+
//
|
|
1965
|
+
// search now also subsumes web_fetch: pass `url` (string or array) to
|
|
1966
|
+
// route to the fetch backend, `query` (string or array) to route to the
|
|
1967
|
+
// search backend. Exactly one of the two must be supplied; mixing them
|
|
1968
|
+
// is a contract violation since the two backends produce different
|
|
1969
|
+
// result shapes.
|
|
1970
|
+
function _capSyncRetrievalBody(text) {
|
|
1971
|
+
const bodyStr = typeof text === 'string' ? text : String(text ?? '')
|
|
1972
|
+
const bodyBytes = Buffer.byteLength(bodyStr, 'utf8')
|
|
1973
|
+
let bodyLines = bodyStr.length === 0 ? 0 : 1
|
|
1974
|
+
for (let i = 0; i < bodyStr.length; i += 1) {
|
|
1975
|
+
if (bodyStr.charCodeAt(i) === 10) bodyLines += 1
|
|
1976
|
+
}
|
|
1977
|
+
return smartReadTruncate(bodyStr, bodyLines, bodyBytes).text
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// ② completion progress (claude "Found N" parity) for recall. Counts the
|
|
1981
|
+
// `#<id>` entry markers in the rendered body; best-effort, no-op when
|
|
1982
|
+
// callerCtx.progress is absent (no progressToken). Never throws.
|
|
1983
|
+
function _emitRecallProgress(callerCtx, body) {
|
|
1984
|
+
if (typeof callerCtx?.progress !== 'function') return
|
|
1985
|
+
try {
|
|
1986
|
+
const _n = (String(body).match(/#\d+\b/g) || []).length
|
|
1987
|
+
callerCtx.progress(`recalled ${_n} memories`)
|
|
1988
|
+
} catch { /* best-effort */ }
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
async function _dispatchRecallOrSearch(name, args, callerCtx = {}) {
|
|
1992
|
+
// MCP schema-less query/url field: some clients JSON-stringify arrays
|
|
1993
|
+
// when the inputSchema does not declare an explicit `type`. Parse
|
|
1994
|
+
// `'["a","b"]'` back into an array so fan-out works regardless of
|
|
1995
|
+
// how the caller serialized the input.
|
|
1996
|
+
const _maybeParseArray = (v) => {
|
|
1997
|
+
if (typeof v !== 'string') return v
|
|
1998
|
+
const trimmed = v.trim()
|
|
1999
|
+
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return v
|
|
2000
|
+
try {
|
|
2001
|
+
const parsed = JSON.parse(trimmed)
|
|
2002
|
+
return Array.isArray(parsed) ? parsed : v
|
|
2003
|
+
} catch { return v }
|
|
2004
|
+
}
|
|
2005
|
+
const _toList = (v) => {
|
|
2006
|
+
if (v == null) return []
|
|
2007
|
+
if (Array.isArray(v)) return v.map(x => String(x ?? ''))
|
|
2008
|
+
return [String(v ?? '')]
|
|
2009
|
+
}
|
|
2010
|
+
if (name === 'recall') {
|
|
2011
|
+
const queries = _toList(_maybeParseArray(args?.query))
|
|
2012
|
+
// id mode (follow-up lookup): a previous recall returned `#N`
|
|
2013
|
+
// markers; passing them back here fetches the entry + its chunk
|
|
2014
|
+
// members directly, no ranked lookup/search. Accepts a single number or
|
|
2015
|
+
// array; strings are coerced. Mutually exclusive with `query`.
|
|
2016
|
+
const rawId = _maybeParseArray(args?.id)
|
|
2017
|
+
const idList = rawId == null
|
|
2018
|
+
? []
|
|
2019
|
+
: (Array.isArray(rawId) ? rawId : [rawId])
|
|
2020
|
+
.map(v => Number(v))
|
|
2021
|
+
.filter(v => Number.isFinite(v) && v > 0)
|
|
2022
|
+
if (queries.length === 0 && idList.length === 0) {
|
|
2023
|
+
return { content: [{ type: 'text', text: '[recall] either `query` or `id` is required' }], isError: true }
|
|
2024
|
+
}
|
|
2025
|
+
if (queries.length > 0 && idList.length > 0) {
|
|
2026
|
+
return { content: [{ type: 'text', text: '[recall] specify either `query` or `id`, not both' }], isError: true }
|
|
2027
|
+
}
|
|
2028
|
+
const passThrough = {}
|
|
2029
|
+
if (args.period != null) passThrough.period = args.period
|
|
2030
|
+
if (args.limit != null) passThrough.limit = args.limit
|
|
2031
|
+
if (args.offset != null) passThrough.offset = args.offset
|
|
2032
|
+
if (args.sort != null) passThrough.sort = args.sort
|
|
2033
|
+
if (args.category != null) passThrough.category = args.category
|
|
2034
|
+
if (args.includeMembers != null) passThrough.includeMembers = args.includeMembers
|
|
2035
|
+
if (args.includeRaw != null) passThrough.includeRaw = args.includeRaw
|
|
2036
|
+
if (args.includeArchived != null) passThrough.includeArchived = args.includeArchived
|
|
2037
|
+
if (typeof args.projectScope === 'string' && args.projectScope) {
|
|
2038
|
+
passThrough.projectScope = args.projectScope
|
|
2039
|
+
} else {
|
|
2040
|
+
passThrough.cwd = (typeof args.cwd === 'string' && args.cwd) ? args.cwd : (callerCtx.callerCwd || pwd())
|
|
2041
|
+
}
|
|
2042
|
+
if (idList.length > 0) {
|
|
2043
|
+
const result = await callWorker('memory', 'memory', { action: 'search', ids: idList, ...passThrough })
|
|
2044
|
+
const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
|
|
2045
|
+
_emitRecallProgress(callerCtx, body)
|
|
2046
|
+
return { content: [{ type: 'text', text: body }] }
|
|
2047
|
+
}
|
|
2048
|
+
// Single-query stays a string so the worker's single branch runs;
|
|
2049
|
+
// multi-query goes in as an array so the worker's array branch runs
|
|
2050
|
+
// (pre-warm embedTexts + Promise.all sub-search inside one process).
|
|
2051
|
+
const queryArg = queries.length > 1 ? queries : queries[0]
|
|
2052
|
+
const result = await callWorker('memory', 'memory', { action: 'search', query: queryArg, ...passThrough })
|
|
2053
|
+
const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
|
|
2054
|
+
_emitRecallProgress(callerCtx, body)
|
|
2055
|
+
return { content: [{ type: 'text', text: body }] }
|
|
2056
|
+
}
|
|
2057
|
+
// search — query (text) vs url (fetch) mutually exclusive.
|
|
2058
|
+
const queries = _toList(_maybeParseArray(args?.query !== undefined ? args.query : args?.keywords))
|
|
2059
|
+
const urls = _toList(_maybeParseArray(args?.url))
|
|
2060
|
+
if (queries.length === 0 && urls.length === 0) {
|
|
2061
|
+
return { content: [{ type: 'text', text: '[search] either `query` or `url` is required' }], isError: true }
|
|
2062
|
+
}
|
|
2063
|
+
if (queries.length > 0 && urls.length > 0) {
|
|
2064
|
+
return { content: [{ type: 'text', text: '[search] specify either `query` or `url`, not both' }], isError: true }
|
|
2065
|
+
}
|
|
2066
|
+
const { loadConfig: loadSearchConfig } = await import(
|
|
2067
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/search/lib/config.mjs')).href,
|
|
2068
|
+
)
|
|
2069
|
+
const searchConfig = loadSearchConfig()
|
|
2070
|
+
if (urls.length > 0) {
|
|
2071
|
+
const searchMod = await loadModule('search')
|
|
2072
|
+
const urlArg = urls.length > 1 ? urls : urls[0]
|
|
2073
|
+
const result = await searchMod.handleToolCall('web_fetch', {
|
|
2074
|
+
url: urlArg,
|
|
2075
|
+
startIndex: args?.startIndex,
|
|
2076
|
+
maxLength: args?.maxLength,
|
|
2077
|
+
})
|
|
2078
|
+
const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
|
|
2079
|
+
// ② completion progress (claude "Found N" parity). callerCtx.progress is
|
|
2080
|
+
// the central reporter; best-effort, no-op when absent (no progressToken).
|
|
2081
|
+
if (typeof callerCtx.progress === 'function') {
|
|
2082
|
+
try { callerCtx.progress(urls.length > 1 ? `fetched ${urls.length} URLs` : `fetched ${urls[0]}`) } catch { /* best-effort */ }
|
|
2083
|
+
}
|
|
2084
|
+
return { content: [{ type: 'text', text: body }], ...(result?.isError ? { isError: true } : {}) }
|
|
2085
|
+
}
|
|
2086
|
+
const searchMod = await loadModule('search')
|
|
2087
|
+
const queryArg = queries.length > 1 ? queries : queries[0]
|
|
2088
|
+
const result = await searchMod.handleToolCall('search', {
|
|
2089
|
+
keywords: queryArg,
|
|
2090
|
+
// Thread the per-call provider override through to the search module so
|
|
2091
|
+
// an explicit `provider:"xai-api"` etc. on the public `search` tool is
|
|
2092
|
+
// honoured instead of silently falling back to the configured default
|
|
2093
|
+
// (searchArgsSchema in src/search/index.mjs already accepts this field).
|
|
2094
|
+
provider: args?.provider,
|
|
2095
|
+
site: args?.site,
|
|
2096
|
+
type: args?.type,
|
|
2097
|
+
locale: args?.locale,
|
|
2098
|
+
maxResults: args?.maxResults || searchConfig?.rawSearch?.maxResults || 10,
|
|
2099
|
+
contextSize: args?.contextSize,
|
|
2100
|
+
})
|
|
2101
|
+
const body = _capSyncRetrievalBody(result?.text ?? result?.content?.[0]?.text ?? '(no response)')
|
|
2102
|
+
// ② completion progress (claude "Found N" parity). Count numbered result
|
|
2103
|
+
// lines in the formatted body; best-effort, no-op when progress absent.
|
|
2104
|
+
if (typeof callerCtx.progress === 'function') {
|
|
2105
|
+
try {
|
|
2106
|
+
const _n = (body.match(/^\s*\d+\.\s/gm) || []).length
|
|
2107
|
+
callerCtx.progress(`found ${_n} results`)
|
|
2108
|
+
} catch { /* best-effort */ }
|
|
2109
|
+
}
|
|
2110
|
+
return { content: [{ type: 'text', text: body }], ...(result?.isError ? { isError: true } : {}) }
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// ai-wrapped dispatch: explore + any other tool flagged aiWrapped in
|
|
2114
|
+
// tools.json. Imports the dispatcher lazily so non-aiWrapped calls don't
|
|
2115
|
+
// pay the cost.
|
|
2116
|
+
async function _dispatchAiWrappedRoute(name, args, callerCtx) {
|
|
2117
|
+
const { dispatchAiWrapped } = await import(
|
|
2118
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/ai-wrapped-dispatch.mjs')).href,
|
|
2119
|
+
)
|
|
2120
|
+
return dispatchAiWrapped(name, args ?? {}, {
|
|
2121
|
+
PLUGIN_ROOT,
|
|
2122
|
+
callMemoryWorker: (n, a) => callWorker('memory', n, a),
|
|
2123
|
+
// Caller session id propagates from loop.mjs → executeInternalTool →
|
|
2124
|
+
// toolExecutor → dispatchTool → dispatchAiWrapped. Used there to reject
|
|
2125
|
+
// recursion when a hidden-role session (explorer / cycle1 / cycle2)
|
|
2126
|
+
// tries to re-enter an aiWrapped dispatcher.
|
|
2127
|
+
callerSessionId: callerCtx.callerSessionId,
|
|
2128
|
+
callerCwd: callerCtx.callerCwd,
|
|
2129
|
+
// A2: forward the MCP request signal so the sync fan-out can detect a
|
|
2130
|
+
// harness sever of the transport. callerCtx.requestSignal is extra.signal
|
|
2131
|
+
// from the CallTool handler (~2038) preserved through _ctxWithSignal's
|
|
2132
|
+
// spread — it is the signal that fires AT the 120s harness ceiling (the
|
|
2133
|
+
// transport tear-down). We forward requestSignal specifically, NOT the
|
|
2134
|
+
// combined abortSignal, because the combined signal also folds in the
|
|
2135
|
+
// plugin's own 630s ceiling (a different failure mode); requestSignal
|
|
2136
|
+
// alone is the precise "transport severed" event the sync path must react
|
|
2137
|
+
// to by pushing its finalized result through the channel instead of
|
|
2138
|
+
// returning into a dead in-turn transport.
|
|
2139
|
+
requestSignal: callerCtx.requestSignal,
|
|
2140
|
+
// Push merged answer into the Lead session when a dispatch
|
|
2141
|
+
// (wait:false) completes, so Lead integrates the result on its next
|
|
2142
|
+
// turn via a channel notification (no polling tool exposed).
|
|
2143
|
+
notifyFn: pushChannelNotification,
|
|
2144
|
+
// Originating MCP session id (daemon routing). Distinct from callerSessionId
|
|
2145
|
+
// (which gates the orchestrator-session guard); this only tags the
|
|
2146
|
+
// dispatch_result so the daemon router delivers it to the dispatching
|
|
2147
|
+
// terminal instead of broadcasting.
|
|
2148
|
+
routingSessionId: callerCtx.callerSession?.sessionId,
|
|
2149
|
+
clientHostPid: callerCtx.callerSession?.clientHostPid,
|
|
2150
|
+
})
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
// Module-routed dispatch: builtin / code_graph / patch / host_input plus
|
|
2154
|
+
// the worker-IPC (memory, channels) and module.handleToolCall fallback
|
|
2155
|
+
// paths. Same early-return order as the inline branches it replaces.
|
|
2156
|
+
async function _dispatchByModule(name, args, callerCtx, def) {
|
|
2157
|
+
if (def.module === 'builtin') {
|
|
2158
|
+
// Plugin builtin file tools exposed to external MCP clients (e.g. the
|
|
2159
|
+
// Lead / Claude Code harness). Write semantics live inside executeBuiltinTool.
|
|
2160
|
+
const { executeBuiltinTool } = await import(
|
|
2161
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/builtin.mjs')).href,
|
|
2162
|
+
)
|
|
2163
|
+
const effectiveCwd = (typeof args?.cwd === 'string' && args.cwd) ? args.cwd : (callerCtx.callerCwd || pwd())
|
|
2164
|
+
// Read-state / persistent-shell scope id precedence (NOT the recursion-guard
|
|
2165
|
+
// callerSessionId — that must stay null for Lead-direct so the orchestrator
|
|
2166
|
+
// guard does not fail closed): orchestrator session (bridge worker) > the
|
|
2167
|
+
// dispatching TERMINAL's session (per-connection daemon Session) > the
|
|
2168
|
+
// process-global SESSION_ID (stdio bootSession). The terminal tier is what
|
|
2169
|
+
// isolates each daemon terminal's `read` snapshots and `__default__<sid>`
|
|
2170
|
+
// persistent bash; without it all terminals shared one global scope and
|
|
2171
|
+
// tripped cross-session "file unchanged" stubs and a shared shell.
|
|
2172
|
+
const scopeSessionId = callerCtx.callerSessionId ?? callerCtx.callerSession?.sessionId ?? SESSION_ID
|
|
2173
|
+
// Live-progress reporter (MCP notifications/progress). Threaded only when
|
|
2174
|
+
// the client supplied a progressToken; null otherwise so the path stays
|
|
2175
|
+
// byte-identical to the no-progress behaviour.
|
|
2176
|
+
// Background-job completion push: the `bash` run_in_background path arms an
|
|
2177
|
+
// in-process watcher that calls notifyFn once the job finishes, mirroring
|
|
2178
|
+
// the explore-tool dispatch_result mechanism. Thread the same notify ctx
|
|
2179
|
+
// (notifyFn + daemon-routing identity) the aiWrapped dispatcher receives so
|
|
2180
|
+
// the completion result routes back to the dispatching terminal.
|
|
2181
|
+
const text = await executeBuiltinTool(name, args ?? {}, effectiveCwd, {
|
|
2182
|
+
sessionId: scopeSessionId,
|
|
2183
|
+
abortSignal: callerCtx.abortSignal ?? null,
|
|
2184
|
+
onProgress: callerCtx.progress ?? null,
|
|
2185
|
+
progressStarted: callerCtx.progressStarted === true,
|
|
2186
|
+
notifyFn: pushChannelNotification,
|
|
2187
|
+
routingSessionId: callerCtx.callerSession?.sessionId,
|
|
2188
|
+
clientHostPid: callerCtx.callerSession?.clientHostPid,
|
|
2189
|
+
})
|
|
2190
|
+
// Image-aware `read` returns a pre-built MCP result ({content:[{type:'image',...}]}).
|
|
2191
|
+
// Pass any such object through untouched; only string results get text-wrapped
|
|
2192
|
+
// (String()-flattening an object yields "[object Object]" and drops the image).
|
|
2193
|
+
if (text && typeof text === 'object' && Array.isArray(text.content)) return text
|
|
2194
|
+
return { content: [{ type: 'text', text: String(text) }] }
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
if (def.module === 'code_graph') {
|
|
2198
|
+
const { executeCodeGraphTool } = await import(
|
|
2199
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/code-graph.mjs')).href,
|
|
2200
|
+
)
|
|
2201
|
+
let resolvedName = name
|
|
2202
|
+
const resolvedArgs = args ?? {}
|
|
2203
|
+
if (name === 'find_symbol' && resolvedArgs.mode && resolvedArgs.mode !== 'symbol') {
|
|
2204
|
+
const m = resolvedArgs.mode
|
|
2205
|
+
if (m === 'callers') resolvedName = 'find_callers'
|
|
2206
|
+
else if (m === 'references') resolvedName = 'find_references'
|
|
2207
|
+
else if (m === 'imports') resolvedName = 'find_imports'
|
|
2208
|
+
else if (m === 'dependents') resolvedName = 'find_dependents'
|
|
2209
|
+
else resolvedName = 'code_graph'
|
|
2210
|
+
}
|
|
2211
|
+
const text = await executeCodeGraphTool(resolvedName, resolvedArgs, callerCtx.callerCwd || pwd(), callerCtx.abortSignal ?? null)
|
|
2212
|
+
// ② completion progress (claude "Found N" parity). Best-effort, no-op
|
|
2213
|
+
// when callerCtx.progress is absent (no progressToken). Never throws —
|
|
2214
|
+
// the tool result is returned regardless.
|
|
2215
|
+
if (typeof callerCtx.progress === 'function') {
|
|
2216
|
+
try {
|
|
2217
|
+
const _mode = String(resolvedArgs?.mode || '').trim()
|
|
2218
|
+
|| (resolvedName === 'find_callers' ? 'callers'
|
|
2219
|
+
: resolvedName === 'find_references' ? 'references' : '')
|
|
2220
|
+
const _fileArg = (typeof resolvedArgs?.file === 'string' && resolvedArgs.file.trim()) ? resolvedArgs.file.trim() : ''
|
|
2221
|
+
const _countLocLines = (t) => (String(t).match(/^[^\n]*?:\d+:\d+/gm) || []).length
|
|
2222
|
+
let _msg = null
|
|
2223
|
+
if (_mode === 'callers') _msg = `found ${_countLocLines(text)} callers`
|
|
2224
|
+
else if (_mode === 'references') _msg = `found ${_countLocLines(text)} references`
|
|
2225
|
+
else if (_mode === 'search') {
|
|
2226
|
+
const _m = /\bmatches=(\d+)/.exec(String(text))
|
|
2227
|
+
_msg = `found ${_m ? _m[1] : 0} symbols`
|
|
2228
|
+
} else if (_fileArg) _msg = `mapped ${_fileArg}`
|
|
2229
|
+
if (_msg) callerCtx.progress(_msg)
|
|
2230
|
+
} catch { /* best-effort */ }
|
|
2231
|
+
}
|
|
2232
|
+
return { content: [{ type: 'text', text: String(text) }] }
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
if (def.module === 'patch') {
|
|
2236
|
+
// Unified-diff apply tool. One-turn multi-file
|
|
2237
|
+
// edits without Read-before-Edit (the patch's context lines are the
|
|
2238
|
+
// read-proof). Mtime-guarded
|
|
2239
|
+
// against concurrent writes. See src/agent/orchestrator/tools/patch.mjs.
|
|
2240
|
+
const { executePatchTool } = await import(
|
|
2241
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/patch.mjs')).href,
|
|
2242
|
+
)
|
|
2243
|
+
// Same scope-id precedence as the builtin path above: orchestrator session
|
|
2244
|
+
// > dispatching terminal session > process-global SESSION_ID. Keeps each
|
|
2245
|
+
// terminal's mtime read-state isolated without touching the recursion-guard
|
|
2246
|
+
// callerSessionId.
|
|
2247
|
+
const sessionId = callerCtx.callerSessionId ?? callerCtx.callerSession?.sessionId ?? SESSION_ID
|
|
2248
|
+
const text = await executePatchTool(name, args ?? {}, callerCtx.callerCwd || pwd(), {
|
|
2249
|
+
sessionId,
|
|
2250
|
+
readStateScope: sessionId,
|
|
2251
|
+
abortSignal: callerCtx.abortSignal ?? null,
|
|
2252
|
+
onProgress: callerCtx.progress ?? null,
|
|
2253
|
+
})
|
|
2254
|
+
return { content: [{ type: 'text', text: String(text) }] }
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
if (def.module === 'host_input') {
|
|
2258
|
+
// Host-terminal input injection. Walks the parent chain from this Node
|
|
2259
|
+
// process, finds the first ancestor matching a supported terminal host
|
|
2260
|
+
// (currently powershell.exe / pwsh.exe), and replays the supplied text
|
|
2261
|
+
// into its console via AttachConsole + WriteConsoleInputW. Reuses the
|
|
2262
|
+
// proven dev/scripts/inject-input.ps1 helper.
|
|
2263
|
+
// See src/agent/orchestrator/tools/host-input.mjs.
|
|
2264
|
+
const { executeHostInputTool } = await import(
|
|
2265
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/host-input.mjs')).href,
|
|
2266
|
+
)
|
|
2267
|
+
const text = await executeHostInputTool(name, args ?? {}, callerCtx.callerCwd || pwd())
|
|
2268
|
+
return { content: [{ type: 'text', text: String(text) }] }
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
if (def.module === 'cwd') {
|
|
2272
|
+
// Session-cwd tool. Backed by process.env.MIXDOG_SESSION_CWD, which
|
|
2273
|
+
// captureOriginalUserCwd() consults first — so a successful `set`
|
|
2274
|
+
// immediately changes pwd() for every downstream tool dispatch.
|
|
2275
|
+
// See src/agent/orchestrator/tools/cwd-tool.mjs.
|
|
2276
|
+
const { executeCwdTool } = await import(
|
|
2277
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/cwd-tool.mjs')).href,
|
|
2278
|
+
)
|
|
2279
|
+
const text = await executeCwdTool(name, args ?? {}, callerCtx.callerCwd || pwd(), { session: callerCtx.callerSession })
|
|
2280
|
+
return { content: [{ type: 'text', text: String(text) }] }
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
const moduleName = def.module
|
|
2284
|
+
|
|
2285
|
+
if (moduleName === 'memory' || moduleName === 'channels') {
|
|
2286
|
+
return callWorker(moduleName, name, args ?? {})
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
const mod = await loadModule(moduleName)
|
|
2290
|
+
if (moduleName === 'agent') {
|
|
2291
|
+
// Merge shared agent context with the per-request abort signal so the
|
|
2292
|
+
// bridge handler can tear down its async IIFE on client-side cancel.
|
|
2293
|
+
// Forward callerCwd from the MCP dispatch frame so the bridge handler
|
|
2294
|
+
// (src/agent/index.mjs:875) can resolve the worker cwd from the Lead's
|
|
2295
|
+
// current working directory instead of falling back to a stale frozen
|
|
2296
|
+
// user-cwd.txt or process.cwd().
|
|
2297
|
+
const ctx = agentContext()
|
|
2298
|
+
if (callerCtx?.requestSignal) ctx.requestSignal = callerCtx.requestSignal
|
|
2299
|
+
if (callerCtx?.callerCwd) ctx.callerCwd = callerCtx.callerCwd
|
|
2300
|
+
// Tag the dispatching MCP session so a detached bridge worker's result
|
|
2301
|
+
// routes back to THIS terminal (daemon router), not the Lead connection.
|
|
2302
|
+
if (callerCtx?.callerSession?.sessionId) ctx.routingSessionId = callerCtx.callerSession.sessionId
|
|
2303
|
+
if (typeof callerCtx?.callerSession?.clientHostPid === 'number' && callerCtx.callerSession.clientHostPid > 0) {
|
|
2304
|
+
ctx.clientHostPid = callerCtx.callerSession.clientHostPid
|
|
2305
|
+
}
|
|
2306
|
+
return mod.handleToolCall(name, args ?? {}, ctx)
|
|
2307
|
+
}
|
|
2308
|
+
return mod.handleToolCall(name, args ?? {})
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
async function _dispatchToolImpl(name, args, callerCtx = {}) {
|
|
2312
|
+
const def = _resolveDispatchToolDef(name, args)
|
|
2313
|
+
if (name === 'recall' || name === 'search') {
|
|
2314
|
+
return _dispatchRecallOrSearch(name, args, callerCtx)
|
|
2315
|
+
}
|
|
2316
|
+
if (def.aiWrapped) {
|
|
2317
|
+
return _dispatchAiWrappedRoute(name, args, callerCtx)
|
|
2318
|
+
}
|
|
2319
|
+
return _dispatchByModule(name, args, callerCtx, def)
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
// ── Handlers ────────────────────────────────────────────────────────
|
|
2323
|
+
const ALWAYS_LOAD_TOOLS = new Set([
|
|
2324
|
+
'read', 'bash', 'grep', 'bridge', 'list',
|
|
2325
|
+
'glob', 'recall', 'code_graph', 'explore', 'write', 'search',
|
|
2326
|
+
// R1-reviewer follow-up: Decision Table first-tools that were deferred —
|
|
2327
|
+
// apply_patch is the multi-file atomic edit primitive, job_wait the wait
|
|
2328
|
+
// hook for run_in_background. Both deserve always-loaded status by usage.
|
|
2329
|
+
'apply_patch', 'job_wait',
|
|
2330
|
+
// Session-cwd controls — always loaded so a Lead can rebase the working
|
|
2331
|
+
// directory without paying a manifest-fetch round trip first.
|
|
2332
|
+
'cwd',
|
|
2333
|
+
])
|
|
2334
|
+
|
|
2335
|
+
const COMPACT_INPUT_SCHEMA_DESCRIPTION_TOOLS = new Set([
|
|
2336
|
+
'read', 'grep', 'list', 'recall', 'code_graph', 'apply_patch',
|
|
2337
|
+
])
|
|
2338
|
+
|
|
2339
|
+
function stripSchemaDescriptions(value) {
|
|
2340
|
+
if (!value || typeof value !== 'object') return value
|
|
2341
|
+
if (Array.isArray(value)) return value.map(stripSchemaDescriptions)
|
|
2342
|
+
const out = {}
|
|
2343
|
+
for (const [key, child] of Object.entries(value)) {
|
|
2344
|
+
if (key === 'description') continue
|
|
2345
|
+
out[key] = stripSchemaDescriptions(child)
|
|
2346
|
+
}
|
|
2347
|
+
return out
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
function toListToolDef(tool) {
|
|
2351
|
+
const out = ALWAYS_LOAD_TOOLS.has(tool.name)
|
|
2352
|
+
? { ...tool, _meta: { ...(tool._meta || {}), 'anthropic/alwaysLoad': true } }
|
|
2353
|
+
: tool
|
|
2354
|
+
if (!COMPACT_INPUT_SCHEMA_DESCRIPTION_TOOLS.has(tool.name)) return out
|
|
2355
|
+
return {
|
|
2356
|
+
...out,
|
|
2357
|
+
inputSchema: stripSchemaDescriptions(out.inputSchema),
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// Precompute ListTools response once at boot (TOOL_DEFS and ALWAYS_LOAD_TOOLS
|
|
2362
|
+
// are static after module init; rebuilding the spread on every request is waste).
|
|
2363
|
+
const LIST_TOOLS_RESPONSE = {
|
|
2364
|
+
tools: TOOL_DEFS.map(toListToolDef),
|
|
2365
|
+
}
|
|
2366
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => LIST_TOOLS_RESPONSE)
|
|
2367
|
+
|
|
2368
|
+
// Lazy-loaded result-compression module so the boot path doesn't pay the
|
|
2369
|
+
// cost of importing bridge-trace + dependencies until the first tool call.
|
|
2370
|
+
let _compressionModule = null
|
|
2371
|
+
async function _getCompressionModule() {
|
|
2372
|
+
if (!_compressionModule) {
|
|
2373
|
+
_compressionModule = await import(
|
|
2374
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/tools/result-compression.mjs')).href,
|
|
2375
|
+
)
|
|
2376
|
+
}
|
|
2377
|
+
return _compressionModule
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
let _leadDirectOutputSeq = 0
|
|
2381
|
+
// Skip compress/trim post-processing for small MCP tool bodies (fast path).
|
|
2382
|
+
const LEAD_DIRECT_POST_COMPRESS_MIN_BYTES = 4 * 1024
|
|
2383
|
+
function _saveLeadDirectFullOutput(toolName, text) {
|
|
2384
|
+
const safeTool = String(toolName || 'tool').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 40) || 'tool'
|
|
2385
|
+
const dir = join(PLUGIN_DATA, 'tool-results', 'lead-direct')
|
|
2386
|
+
mkdirSync(dir, { recursive: true })
|
|
2387
|
+
const file = join(dir, `${Date.now()}-${process.pid}-${++_leadDirectOutputSeq}-${safeTool}.txt`)
|
|
2388
|
+
writeFileAsync(file, text, 'utf8').catch((err) => log(`[compress] full-output save failed tool=${toolName} msg=${err?.message || err}`))
|
|
2389
|
+
return file
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
// Memory-family tools carry session_id as an inline arg so the memory worker's
|
|
2393
|
+
// HTTP handlers can persist / filter by it without a separate IPC channel.
|
|
2394
|
+
// Non-memory tools see args unchanged — extra fields would noisily mismatch
|
|
2395
|
+
// strict schemas (e.g. bash, glob).
|
|
2396
|
+
const MEMORY_FAMILY_TOOLS = new Set(['memory', 'search_memories', 'recall'])
|
|
2397
|
+
|
|
2398
|
+
// Boot session = today's single stdio client. cwdFn=pwd keeps it tracking the
|
|
2399
|
+
// live MIXDOG_SESSION_CWD env, so this path is behaviour-identical to the
|
|
2400
|
+
// pre-Session code. The daemon entry creates one Session per connection.
|
|
2401
|
+
const bootSession = new Session(null, {
|
|
2402
|
+
sessionId: SESSION_ID,
|
|
2403
|
+
cwdFn: pwd,
|
|
2404
|
+
setCwdFn: (v) => { process.env.MIXDOG_SESSION_CWD = v },
|
|
2405
|
+
})
|
|
2406
|
+
|
|
2407
|
+
// Shared tool-call body for both the stdio entry (bootSession) and the daemon
|
|
2408
|
+
// per-connection entry. Identity (sessionId) and cwd come from `session` and
|
|
2409
|
+
// thread through dispatchTool's callerCtx, which every route already honours.
|
|
2410
|
+
// Build a monotonic MCP progress reporter from a CallTool `extra`. Returns
|
|
2411
|
+
// null when the client did not subscribe (no progressToken) so downstream
|
|
2412
|
+
// tools take their existing no-progress path unchanged. The reporter is a
|
|
2413
|
+
// fire-and-forget async function; notifications carry no `id` and the SDK
|
|
2414
|
+
// stamps relatedRequestId so the daemon/run-mcp layers route the frame back
|
|
2415
|
+
// to the originating connection.
|
|
2416
|
+
function _buildProgressReporter(progressCtx) {
|
|
2417
|
+
const token = progressCtx?._meta?.progressToken
|
|
2418
|
+
const sendNotification = progressCtx?.sendNotification
|
|
2419
|
+
if (token === undefined || token === null || typeof sendNotification !== 'function') return null
|
|
2420
|
+
let _seq = 0
|
|
2421
|
+
return (message) => {
|
|
2422
|
+
const progress = ++_seq
|
|
2423
|
+
try {
|
|
2424
|
+
const r = sendNotification({
|
|
2425
|
+
method: 'notifications/progress',
|
|
2426
|
+
params: { progressToken: token, progress, message: String(message ?? '') },
|
|
2427
|
+
})
|
|
2428
|
+
if (r && typeof r.catch === 'function') r.catch(() => {})
|
|
2429
|
+
} catch { /* progress is best-effort — never disturb the tool result */ }
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
async function runToolCall(session, name, rawArgs, requestSignal, progressCtx = null) {
|
|
2434
|
+
const _entryT = Date.now()
|
|
2435
|
+
const args = MEMORY_FAMILY_TOOLS.has(name) && rawArgs && typeof rawArgs === 'object'
|
|
2436
|
+
? { ...rawArgs, sessionId: rawArgs.sessionId ?? session.sessionId }
|
|
2437
|
+
: rawArgs
|
|
2438
|
+
// Log absolute entry timestamp so we can compare against the caller's
|
|
2439
|
+
// emit timestamp (passed via args._mcpEmitTs if instrumented) to size the
|
|
2440
|
+
// Claude-Code → plugin transport window — currently the largest opaque
|
|
2441
|
+
// segment in the per-call cost stack.
|
|
2442
|
+
log(`[mcp-entry] tool=${name} t=${_entryT}`)
|
|
2443
|
+
// `extra.signal` is an AbortSignal that fires when the MCP client cancels
|
|
2444
|
+
// this request (e.g. user rejects / interrupts a tool call in Claude Code).
|
|
2445
|
+
// Thread it down so long-running tools — specifically the async IIFE the
|
|
2446
|
+
// `bridge` tool spawns to run askSession — can close their session and
|
|
2447
|
+
// stop hitting the provider after the user bails out.
|
|
2448
|
+
//
|
|
2449
|
+
// `callerCwd` defaults to the mixdog server's own working directory so
|
|
2450
|
+
// tools that take a cwd fallback (notably the unified `bridge` tool) can
|
|
2451
|
+
// resolve to a valid plugin path when the caller did not explicitly pass
|
|
2452
|
+
// one. Callers can still override with an explicit `cwd` argument.
|
|
2453
|
+
// Telemetry: dispatchTool's outer wrapper handles start/end/error logs
|
|
2454
|
+
// for both this MCP call path and the in-process toolExecutor path.
|
|
2455
|
+
// MCP live-progress reporter. The SDK auto-allocates extra._meta.progressToken
|
|
2456
|
+
// when the client passes an onprogress callback to callTool and exposes
|
|
2457
|
+
// extra.sendNotification bound to this request (relatedRequestId set). We
|
|
2458
|
+
// build a monotonic-progress reporter ONLY when a token is present, so a
|
|
2459
|
+
// client that did not subscribe sees byte-identical behaviour (no emits).
|
|
2460
|
+
const _progressReporter = _buildProgressReporter(progressCtx)
|
|
2461
|
+
const _postT0 = Date.now()
|
|
2462
|
+
const result = await dispatchTool(name, args, {
|
|
2463
|
+
requestSignal,
|
|
2464
|
+
callerSession: session,
|
|
2465
|
+
callerCwd: session.resolveCwd(),
|
|
2466
|
+
progress: _progressReporter,
|
|
2467
|
+
})
|
|
2468
|
+
const _postT1 = Date.now()
|
|
2469
|
+
// Apply the same chained safe-compression + trim passes the bridge role loop
|
|
2470
|
+
// runs (loop.mjs:1105). Without this, Lead-direct tool calls bypassed every
|
|
2471
|
+
// RTK-class lossless reduction (CR overwrite, NUL/BOM strip, trailing
|
|
2472
|
+
// newline normalize, ANSI/whitespace/dup/separator). compressToolResult
|
|
2473
|
+
// is gated on annotations.compressible per tool definition, so non-
|
|
2474
|
+
// compressible tools (read/etc) pass through untouched. Final expand
|
|
2475
|
+
// guard inside compressToolResult returns the original on no-shrink.
|
|
2476
|
+
// tailTrimLargeOutput adds RTK-style long-line and head/tail caps so one
|
|
2477
|
+
// huge minified line cannot stall the MCP stdout path.
|
|
2478
|
+
// sessionId='lead-direct' (sentinel) lets traceBridgeCompress fire on
|
|
2479
|
+
// this path too — without it the compress trace skipped Lead-direct
|
|
2480
|
+
// entirely and PG aggregates only saw bridge role loop activity.
|
|
2481
|
+
try {
|
|
2482
|
+
const { compressToolResult, tailTrimLargeOutput } = await _getCompressionModule()
|
|
2483
|
+
const rawText = result?.content?.[0]?.text
|
|
2484
|
+
if (typeof rawText === 'string' && Buffer.byteLength(rawText, 'utf8') >= LEAD_DIRECT_POST_COMPRESS_MIN_BYTES) {
|
|
2485
|
+
const _before = rawText.length
|
|
2486
|
+
let nextText = compressToolResult(name, args, rawText, { sessionId: 'lead-direct', toolKind: null })
|
|
2487
|
+
// `read` output is consumed sequentially — head-only trim with a
|
|
2488
|
+
// continue hint instead of head+tail (which destroys the middle of a
|
|
2489
|
+
// deliberately windowed read).
|
|
2490
|
+
const _seq = name === 'read'
|
|
2491
|
+
const trimmedCandidate = tailTrimLargeOutput(nextText, { trimLongLines: true, sequential: _seq })
|
|
2492
|
+
if (trimmedCandidate !== nextText) {
|
|
2493
|
+
const fullOutputPath = _saveLeadDirectFullOutput(name, rawText)
|
|
2494
|
+
if (fullOutputPath) {
|
|
2495
|
+
nextText = tailTrimLargeOutput(nextText, { fullOutputPath, sequential: _seq })
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
if (nextText !== rawText) {
|
|
2499
|
+
result.content[0].text = nextText
|
|
2500
|
+
const _saved = _before - nextText.length
|
|
2501
|
+
const _pct = _before > 0 ? Math.round((_saved / _before) * 100) : 0
|
|
2502
|
+
log(`[compress] tool=${name} ${_before}→${nextText.length} bytes saved=${_pct}%`)
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
} catch { /* compression best-effort — never block a tool result */ }
|
|
2506
|
+
const _postT3 = Date.now()
|
|
2507
|
+
log(`[mcp-post] tool=${name} dispatch=${_postT1 - _postT0}ms post=${_postT3 - _postT1}ms total=${_postT3 - _postT0}ms`)
|
|
2508
|
+
return result
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
|
|
2512
|
+
return runToolCall(bootSession, req.params.name, req.params.arguments, extra?.signal, extra)
|
|
2513
|
+
})
|
|
2514
|
+
|
|
2515
|
+
// ── Memory worker — start before MCP handshake so it boots in parallel ─────
|
|
2516
|
+
const memoryOn = isModuleEnabled('memory')
|
|
2517
|
+
const channelsOn = isModuleEnabled('channels')
|
|
2518
|
+
if (memoryOn) spawnWorker('memory')
|
|
2519
|
+
else log(`module 'memory' disabled — skipping worker spawn`)
|
|
2520
|
+
|
|
2521
|
+
// ── Daemon mode: serve N Claude Code clients over one shared pipe ───────────
|
|
2522
|
+
// Each connection gets its own MCP SDK Server bound to a FramedServerTransport,
|
|
2523
|
+
// so the SDK does protocol-correct initialize/tools negotiation per client
|
|
2524
|
+
// while a per-connection Session carries identity (sessionId/cwd) into
|
|
2525
|
+
// runToolCall. The memory/channels workers booted in this process are SINGLE
|
|
2526
|
+
// shared services — the point of the daemon. Replaces the per-terminal stdio
|
|
2527
|
+
// server + fork-proxy/election model (deleted at cutover).
|
|
2528
|
+
async function serveDaemon(dataDir) {
|
|
2529
|
+
daemonNotifyRouter = daemonNotifyRouter || createDaemonNotifyRouter()
|
|
2530
|
+
const reg = new SessionRegistry()
|
|
2531
|
+
const srv = await daemonListen({
|
|
2532
|
+
dataDir,
|
|
2533
|
+
onConnection(conn) {
|
|
2534
|
+
const session = reg.open(conn, { sessionId: randomUUID() })
|
|
2535
|
+
const perConn = new Server(
|
|
2536
|
+
{ name: 'mixdog', version: PLUGIN_VERSION },
|
|
2537
|
+
{
|
|
2538
|
+
capabilities: { tools: {}, experimental: { 'claude/channel': {}, 'claude/channel/permission': {} } },
|
|
2539
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
2540
|
+
},
|
|
2541
|
+
)
|
|
2542
|
+
perConn.setRequestHandler(ListToolsRequestSchema, async () => LIST_TOOLS_RESPONSE)
|
|
2543
|
+
perConn.setRequestHandler(CallToolRequestSchema, async (req, extra) =>
|
|
2544
|
+
runToolCall(session, req.params.name, req.params.arguments, extra?.signal, extra))
|
|
2545
|
+
// Channel permission requests: forward to the shared channels worker and
|
|
2546
|
+
// record request_id → this connection so the worker's response routes back
|
|
2547
|
+
// here (the daemon notify router). Each connection registers its own.
|
|
2548
|
+
perConn.setNotificationHandler(ChannelPermissionRequestNotificationSchema, async (notification) => {
|
|
2549
|
+
const reqId = notification?.params?.request_id
|
|
2550
|
+
const forwarded = forwardChannelPermissionRequest(notification.params, (n) => perConn.notification(n).catch(() => {}))
|
|
2551
|
+
// Track for response routing ONLY if it reached the worker; a local
|
|
2552
|
+
// deny already replied directly, so registering would leak in byReq.
|
|
2553
|
+
if (forwarded && reqId) daemonNotifyRouter.registerPermissionRequest(reqId, perConn)
|
|
2554
|
+
})
|
|
2555
|
+
// Track the connection + its Session so the router can resolve the
|
|
2556
|
+
// active-instance owner (channel events) and the dispatching session
|
|
2557
|
+
// (worker results). Register the bootstrap session id now so a dispatch
|
|
2558
|
+
// that completes before the control frame still routes correctly; the
|
|
2559
|
+
// control-frame handler below swaps in the real MIXDOG_SESSION_ID.
|
|
2560
|
+
daemonNotifyRouter.add(perConn, session)
|
|
2561
|
+
daemonNotifyRouter.registerSession(session.sessionId, perConn)
|
|
2562
|
+
// Register cleanup immediately after the registry/router entries exist,
|
|
2563
|
+
// before any further (throwable) transport setup — otherwise an
|
|
2564
|
+
// exception mid-setup leaks this session/router registration.
|
|
2565
|
+
conn.onClose(() => { daemonNotifyRouter.remove(perConn); reg.close(conn) })
|
|
2566
|
+
// Session identity arrives out-of-band as a `__mixdog` control frame,
|
|
2567
|
+
// filtered by FramedServerTransport before the SDK (no dependency on the
|
|
2568
|
+
// MCP initialize ordering).
|
|
2569
|
+
const transport = new FramedServerTransport(conn, {
|
|
2570
|
+
onControl(msg) {
|
|
2571
|
+
if (msg.__mixdog !== 'session') return
|
|
2572
|
+
let priorSid = null
|
|
2573
|
+
if (msg.sessionId) {
|
|
2574
|
+
priorSid = session.sessionId
|
|
2575
|
+
session.sessionId = String(msg.sessionId)
|
|
2576
|
+
// Register sessionId → this connection so async dispatch results
|
|
2577
|
+
// (bridge/explore) route back to this terminal instead of broadcasting.
|
|
2578
|
+
daemonNotifyRouter.registerSession(session.sessionId, perConn)
|
|
2579
|
+
}
|
|
2580
|
+
const _advertisedCwd = typeof msg.cwd === 'string' && msg.cwd ? msg.cwd : null
|
|
2581
|
+
const _restoredCwd = !_advertisedCwd && Number.isFinite(msg.leadPid) ? readLastSessionCwd(msg.leadPid) : null
|
|
2582
|
+
if (_advertisedCwd) session.cwd = _advertisedCwd
|
|
2583
|
+
else if (_restoredCwd) session.cwd = _restoredCwd
|
|
2584
|
+
if (typeof msg.transcriptPath === 'string') session.transcriptPath = msg.transcriptPath
|
|
2585
|
+
if (typeof msg.clientHostPid === 'number' && msg.clientHostPid > 0) session.clientHostPid = msg.clientHostPid
|
|
2586
|
+
if (Number.isFinite(msg.leadPid)) session.leadPid = msg.leadPid
|
|
2587
|
+
if (msg.instanceId) session.instanceId = String(msg.instanceId)
|
|
2588
|
+
// Reconnect recovery must use the same stable terminal key as
|
|
2589
|
+
// notification routing. MIXDOG_SESSION_ID is often absent, so
|
|
2590
|
+
// sessionId-only replay misses pending results from a prior bootstrap
|
|
2591
|
+
// UUID; clientHostPid lets the reattached terminal reclaim them.
|
|
2592
|
+
import('./src/agent/orchestrator/dispatch-persist.mjs')
|
|
2593
|
+
.then(({ recoverPending }) => recoverPending(PLUGIN_DATA, pushChannelNotification, {
|
|
2594
|
+
sessionId: session.sessionId,
|
|
2595
|
+
...(priorSid && priorSid !== session.sessionId ? { priorSessionId: priorSid } : {}),
|
|
2596
|
+
...(Number(session.clientHostPid) > 0 ? { clientHostPid: session.clientHostPid } : {}),
|
|
2597
|
+
}))
|
|
2598
|
+
.catch(() => {})
|
|
2599
|
+
},
|
|
2600
|
+
})
|
|
2601
|
+
perConn.connect(transport)
|
|
2602
|
+
.catch((e) => log(`[daemon] connection setup failed: ${e?.message || e}`))
|
|
2603
|
+
},
|
|
2604
|
+
})
|
|
2605
|
+
if (!srv) {
|
|
2606
|
+
// Another daemon already owns the pipe (host owner-lock handoff race).
|
|
2607
|
+
log('[daemon] pipe already owned — exiting')
|
|
2608
|
+
process.exit(0)
|
|
2609
|
+
}
|
|
2610
|
+
globalThis.__mixdogServerReady = true
|
|
2611
|
+
log(`[daemon] serving pid=${process.pid} dataDir=${dataDir} tools=${TOOL_DEFS.length}`)
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
// ── Transport — connect so the MCP host can send its first tool call ────────
|
|
2615
|
+
if (process.argv.includes('--daemon') || process.env.MIXDOG_DAEMON_MODE === '1') {
|
|
2616
|
+
await serveDaemon(process.env.MIXDOG_DAEMON_DATA_DIR || PLUGIN_DATA)
|
|
2617
|
+
} else {
|
|
2618
|
+
await server.connect(new StdioServerTransport())
|
|
2619
|
+
}
|
|
2620
|
+
// Signal to server.mjs's prelude uncaught guards that the MCP transport is
|
|
2621
|
+
// live: from this point a soft-net log-only policy is safe because the
|
|
2622
|
+
// post-load classifier (FATAL_CODES + FATAL_NAME_PATTERNS) owns the exit
|
|
2623
|
+
// decision. Anything thrown before this flag flips still routes through the
|
|
2624
|
+
// prelude's pre-ready branch and exit(1) so the supervisor restarts.
|
|
2625
|
+
globalThis.__mixdogServerReady = true
|
|
2626
|
+
log(`connected pid=${process.pid} v${PLUGIN_VERSION} tools=${TOOL_DEFS.length}`)
|
|
2627
|
+
|
|
2628
|
+
// ── Background hydration (fire-and-forget after connect) ────────────────────
|
|
2629
|
+
// Agent registry — background; handleToolCall falls back to
|
|
2630
|
+
// setInternalToolsProvider lazily if a call arrives before this resolves.
|
|
2631
|
+
;(async () => {
|
|
2632
|
+
if (!isModuleEnabled('agent')) {
|
|
2633
|
+
log(`module 'agent' disabled — skipping eager init, bridge tools will not register`)
|
|
2634
|
+
return
|
|
2635
|
+
}
|
|
2636
|
+
try {
|
|
2637
|
+
await loadModule('agent').then(async () => {
|
|
2638
|
+
// Populate the in-process tool registry at boot so ALL session entry
|
|
2639
|
+
// points (direct createSession / resumeSession, not just handleToolCall)
|
|
2640
|
+
// see the bridge from the first call. handleToolCall still calls
|
|
2641
|
+
// setInternalToolsProvider as an idempotent fallback, but we no longer
|
|
2642
|
+
// rely on a tool call arriving first.
|
|
2643
|
+
try {
|
|
2644
|
+
const internalToolsMod = await import(
|
|
2645
|
+
pathToFileURL(join(PLUGIN_ROOT, 'src', 'agent', 'orchestrator', 'internal-tools.mjs')).href
|
|
2646
|
+
)
|
|
2647
|
+
const { setInternalToolsProvider, markBootReady } = internalToolsMod
|
|
2648
|
+
const ctx = agentContext()
|
|
2649
|
+
setInternalToolsProvider({ executor: ctx.toolExecutor, tools: ctx.internalTools })
|
|
2650
|
+
|
|
2651
|
+
markBootReady()
|
|
2652
|
+
log(`internal-tools registry populated tools=${ctx.internalTools.length}`)
|
|
2653
|
+
// Windows-only: request Defender exclusion for hot IO paths, but keep
|
|
2654
|
+
// the synchronous PowerShell preference probe out of the SessionStart
|
|
2655
|
+
// boot path. The check is advisory and rate-limited; delaying it avoids
|
|
2656
|
+
// holding the MCP parent event loop while cycle1/core/recap are trying
|
|
2657
|
+
// to answer the startup hooks.
|
|
2658
|
+
const defenderTimer = setTimeout(() => {
|
|
2659
|
+
try { maybeRequestDefenderExclusion(PLUGIN_DATA, log) }
|
|
2660
|
+
catch (e) { log(`[defender] check failed: ${e.message}`) }
|
|
2661
|
+
}, 30_000)
|
|
2662
|
+
defenderTimer.unref?.()
|
|
2663
|
+
} catch (e) {
|
|
2664
|
+
log(`internal-tools registry populate failed: ${e.message}`)
|
|
2665
|
+
}
|
|
2666
|
+
})
|
|
2667
|
+
} catch (e) { log(`eager agent init failed: ${e.message}`) }
|
|
2668
|
+
})()
|
|
2669
|
+
|
|
2670
|
+
// ── Spawn workers: channels (gated on memory) ───────────────────────
|
|
2671
|
+
// Hoisted to register at the head of the setImmediate FIFO so the
|
|
2672
|
+
// channels fork lands ahead of the rules-watcher and status-fork
|
|
2673
|
+
// setImmediates registered later in this file. `reconcileClaudeMd`
|
|
2674
|
+
// is a function declaration, so it's hoisted and safe to reference
|
|
2675
|
+
// here even though its source location is below.
|
|
2676
|
+
setImmediate(() => {
|
|
2677
|
+
if (!channelsOn) {
|
|
2678
|
+
log(`module 'channels' disabled — skipping worker spawn`)
|
|
2679
|
+
// CLAUDE.md reconcile is driven by channels/injection config; when
|
|
2680
|
+
// channels is off we still reconcile once so managed blocks stay in
|
|
2681
|
+
// sync with the current mode.
|
|
2682
|
+
reconcileClaudeMd()
|
|
2683
|
+
return
|
|
2684
|
+
}
|
|
2685
|
+
// Channels worker issues callWorker('memory', ...) for inbound routing
|
|
2686
|
+
// and recall. With memory disabled there is no memory worker entry, so
|
|
2687
|
+
// those calls would hang for WORKER_NO_ENTRY_GRACE_MS then reject. Gate
|
|
2688
|
+
// channels start on memory availability to match the lifecycle comment
|
|
2689
|
+
// ("channels (gated on memory)") above.
|
|
2690
|
+
if (!memoryOn) {
|
|
2691
|
+
log(`module 'channels' enabled but 'memory' disabled — skipping channels worker spawn (channels depends on memory)`)
|
|
2692
|
+
reconcileClaudeMd()
|
|
2693
|
+
return
|
|
2694
|
+
}
|
|
2695
|
+
|
|
2696
|
+
reconcileClaudeMd()
|
|
2697
|
+
log('[server] channels spawn reason=parallel-with-memory')
|
|
2698
|
+
spawnWorker('channels')
|
|
2699
|
+
})
|
|
2700
|
+
|
|
2701
|
+
// ── Deferred: search eager-load + dispatch recovery ────────────────
|
|
2702
|
+
// Both are fire-and-forget after channels-spawn is enqueued so they
|
|
2703
|
+
// don't sit on the boot critical path. Search eager-load only avoids
|
|
2704
|
+
// the first-call JIT cost; dispatch recovery emits Aborted
|
|
2705
|
+
// notifications for orphaned handles from a prior process death.
|
|
2706
|
+
if (isModuleEnabled('search')) {
|
|
2707
|
+
const searchWarmupTimer = setTimeout(() => {
|
|
2708
|
+
loadModule('search').catch(e => log(`eager search init failed: ${e.message}`))
|
|
2709
|
+
}, 15_000)
|
|
2710
|
+
searchWarmupTimer.unref?.()
|
|
2711
|
+
} else {
|
|
2712
|
+
log(`module 'search' disabled — skipping eager init`)
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
setImmediate(() => {
|
|
2716
|
+
const IS_DAEMON = process.argv.includes('--daemon') || process.env.MIXDOG_DAEMON_MODE === '1'
|
|
2717
|
+
if (IS_DAEMON) return
|
|
2718
|
+
import('./src/agent/orchestrator/dispatch-persist.mjs')
|
|
2719
|
+
.then(({ recoverPending }) => {
|
|
2720
|
+
const recovered = recoverPending(PLUGIN_DATA, pushChannelNotification)
|
|
2721
|
+
if (recovered > 0) log(`dispatch-recovery: emitted ${recovered} Aborted notifications`)
|
|
2722
|
+
})
|
|
2723
|
+
.catch((err) => {
|
|
2724
|
+
log(`dispatch-recovery failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
2725
|
+
})
|
|
2726
|
+
})
|
|
2727
|
+
|
|
2728
|
+
// ── CLAUDE.md managed block reconciliation ─────────────────────────
|
|
2729
|
+
// Writes static rules into the managed block. Session recap is NOT
|
|
2730
|
+
// written here — the SessionStart hook injects it live from the memory worker.
|
|
2731
|
+
// Fail-soft: any error is logged and swallowed.
|
|
2732
|
+
//
|
|
2733
|
+
// mode === 'claude_md' → upsert the managed block (strong enforcement)
|
|
2734
|
+
// mode === 'hook' (default or missing) → remove any stale managed block
|
|
2735
|
+
// Shared loader for the two CLAUDE.md sites (boot-time reconcile +
|
|
2736
|
+
// live watcher): reads the channels section once, derives the injection
|
|
2737
|
+
// target, and require()s the rules-builder / writer pair. Returning the
|
|
2738
|
+
// same shape from a single helper avoids the duplicated readSection +
|
|
2739
|
+
// createRequire + require() pair at the original two call sites.
|
|
2740
|
+
function _readClaudeMdInjection() {
|
|
2741
|
+
const mainConfig = readSection('channels')
|
|
2742
|
+
const injection = (mainConfig && mainConfig.promptInjection) || {}
|
|
2743
|
+
return { injection }
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
function _loadClaudeMdWriters() {
|
|
2747
|
+
const req = createRequire(import.meta.url)
|
|
2748
|
+
const { buildInjectionContent } = req(join(PLUGIN_ROOT, 'lib', 'rules-builder.cjs'))
|
|
2749
|
+
const { upsertManagedBlock, removeManagedBlock, expandHome } = req(join(PLUGIN_ROOT, 'lib', 'claude-md-writer.cjs'))
|
|
2750
|
+
return { buildInjectionContent, upsertManagedBlock, removeManagedBlock, expandHome }
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function reconcileClaudeMd() {
|
|
2754
|
+
try {
|
|
2755
|
+
const { injection } = _readClaudeMdInjection()
|
|
2756
|
+
if (injection.mode !== 'claude_md') {
|
|
2757
|
+
// hook/non-claude_md mode: load writers lazily only to remove any
|
|
2758
|
+
// stale managed block, matching original early-return semantics
|
|
2759
|
+
// (no require + no targetPath derivation until after the mode check).
|
|
2760
|
+
const { removeManagedBlock, expandHome } = _loadClaudeMdWriters()
|
|
2761
|
+
const targetPath = injection.targetPath || '~/.claude/CLAUDE.md'
|
|
2762
|
+
const removed = removeManagedBlock(targetPath)
|
|
2763
|
+
if (removed) log(`hook mode: removed stale managed block from ${expandHome(targetPath)}`)
|
|
2764
|
+
return
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
const targetPath = injection.targetPath || '~/.claude/CLAUDE.md'
|
|
2768
|
+
const { buildInjectionContent, upsertManagedBlock, expandHome } = _loadClaudeMdWriters()
|
|
2769
|
+
const content = buildInjectionContent({ PLUGIN_ROOT, DATA_DIR: PLUGIN_DATA })
|
|
2770
|
+
upsertManagedBlock(targetPath, content)
|
|
2771
|
+
log(`claude_md: wrote managed block to ${expandHome(targetPath)} (${content.length} chars)`)
|
|
2772
|
+
} catch (e) {
|
|
2773
|
+
log(`claude_md reconcile failed: ${e && (e.stack || e.message) || e}`)
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// ── CLAUDE.md managed block live watcher ───────────────────────────
|
|
2778
|
+
// After boot-time reconcile, watch the rules/config sources and rebuild
|
|
2779
|
+
// the managed block in-place whenever they change. Keeps the disk copy
|
|
2780
|
+
// of CLAUDE.md in sync so the next session start always sees the latest
|
|
2781
|
+
// rules, even if the user edited mid-session.
|
|
2782
|
+
//
|
|
2783
|
+
// Only active when injection.mode === 'claude_md'. In hook mode this is
|
|
2784
|
+
// a no-op (hook mode regenerates on every prompt anyway).
|
|
2785
|
+
//
|
|
2786
|
+
// All errors are contained: per-watcher try/catch plus an outer try/catch
|
|
2787
|
+
// so watcher setup failure never crashes the MCP server.
|
|
2788
|
+
setImmediate(() => {
|
|
2789
|
+
try {
|
|
2790
|
+
const { injection } = _readClaudeMdInjection()
|
|
2791
|
+
if (injection.mode !== 'claude_md') return
|
|
2792
|
+
|
|
2793
|
+
// Initial target snapshot used only for the watcher's own exclude
|
|
2794
|
+
// check (don't recurse on our own writes). The rebuild callback
|
|
2795
|
+
// re-reads mode/targetPath on every fire so a runtime config change
|
|
2796
|
+
// (mode flip to hook, or targetPath move) is honored instead of
|
|
2797
|
+
// upserting into the stale snapshot.
|
|
2798
|
+
const initialTargetPath = injection.targetPath || '~/.claude/CLAUDE.md'
|
|
2799
|
+
const { buildInjectionContent, upsertManagedBlock, expandHome } = _loadClaudeMdWriters()
|
|
2800
|
+
const resolvedTarget = pathResolve(expandHome(initialTargetPath))
|
|
2801
|
+
|
|
2802
|
+
// Track every target path we have ever written a managed block to
|
|
2803
|
+
// during this process's lifetime so an A→B→C reconfiguration removes
|
|
2804
|
+
// the block from BOTH A and B before writing C. Tracking only the
|
|
2805
|
+
// initial target left a stale block on intermediate hops because the
|
|
2806
|
+
// watcher always compared `currentTargetPath` against `initialTargetPath`.
|
|
2807
|
+
// Keyed by expandHome()-resolved absolute path so two spellings of
|
|
2808
|
+
// the same file (`~/.claude/CLAUDE.md` vs the expanded form) collapse.
|
|
2809
|
+
const writtenTargets = new Map() // resolvedAbs -> originalPath (for log + remove)
|
|
2810
|
+
writtenTargets.set(pathResolve(expandHome(initialTargetPath)), initialTargetPath)
|
|
2811
|
+
|
|
2812
|
+
let debounceTimer = null
|
|
2813
|
+
const rebuild = triggerFilename => {
|
|
2814
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
2815
|
+
debounceTimer = setTimeout(() => {
|
|
2816
|
+
debounceTimer = null
|
|
2817
|
+
try {
|
|
2818
|
+
// Re-read injection on every fire so mode/target changes in
|
|
2819
|
+
// mixdog-config.json take effect immediately instead of writing
|
|
2820
|
+
// to the original target captured at setup time.
|
|
2821
|
+
const { injection: currentInjection } = _readClaudeMdInjection()
|
|
2822
|
+
const currentTargetPath = currentInjection.targetPath || '~/.claude/CLAUDE.md'
|
|
2823
|
+
const resolvedCurrent = pathResolve(expandHome(currentTargetPath))
|
|
2824
|
+
if (currentInjection.mode !== 'claude_md') {
|
|
2825
|
+
// Mode flipped (e.g. claude_md → hook): remove any managed
|
|
2826
|
+
// block from EVERY target we have ever written to during this
|
|
2827
|
+
// session so we don't leave stale injection on disk at any
|
|
2828
|
+
// prior hop in an A→B→C chain.
|
|
2829
|
+
const { removeManagedBlock } = _loadClaudeMdWriters()
|
|
2830
|
+
for (const [, prior] of writtenTargets) {
|
|
2831
|
+
try {
|
|
2832
|
+
const removed = removeManagedBlock(prior)
|
|
2833
|
+
if (removed) log(`[rules-watcher] mode changed away from claude_md — removed managed block from ${expandHome(prior)}`)
|
|
2834
|
+
} catch (e) {
|
|
2835
|
+
log(`[rules-watcher] failed to remove managed block from ${expandHome(prior)}: ${e && (e.stack || e.message) || e}`)
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
return
|
|
2839
|
+
}
|
|
2840
|
+
// Every rebuild removes stale managed blocks from all prior targets
|
|
2841
|
+
// except the current one. This covers A→B→A, where A was already
|
|
2842
|
+
// seen but B still needs cleanup before A is rebuilt.
|
|
2843
|
+
const { removeManagedBlock } = _loadClaudeMdWriters()
|
|
2844
|
+
for (const [resolvedPrior, prior] of writtenTargets) {
|
|
2845
|
+
if (resolvedPrior === resolvedCurrent) continue
|
|
2846
|
+
try {
|
|
2847
|
+
const removed = removeManagedBlock(prior)
|
|
2848
|
+
if (removed) log(`[rules-watcher] removed stale managed block from ${expandHome(prior)} before rebuilding ${expandHome(currentTargetPath)}`)
|
|
2849
|
+
} catch (e) {
|
|
2850
|
+
log(`[rules-watcher] failed to remove stale managed block from ${expandHome(prior)}: ${e && (e.stack || e.message) || e}`)
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
const content = buildInjectionContent({ PLUGIN_ROOT, DATA_DIR: PLUGIN_DATA })
|
|
2854
|
+
upsertManagedBlock(currentTargetPath, content)
|
|
2855
|
+
writtenTargets.set(resolvedCurrent, currentTargetPath)
|
|
2856
|
+
log(`[rules-watcher] rebuilt managed block (${content.length} chars) at ${expandHome(currentTargetPath)} after ${triggerFilename}`)
|
|
2857
|
+
} catch (e) {
|
|
2858
|
+
log(`[rules-watcher] rebuild failed: ${e && (e.stack || e.message) || e}`)
|
|
2859
|
+
}
|
|
2860
|
+
}, 300)
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// Filenames are normalised to forward-slashes before lookup so the
|
|
2864
|
+
// recursive fs.watch event (which reports e.g. `history\user.md` on
|
|
2865
|
+
// Windows) lines up with this allowlist. Keep these in sync with the
|
|
2866
|
+
// sources buildInjectionContent (lib/rules-builder.cjs) actually reads:
|
|
2867
|
+
// mixdog-config.json + user-workflow.{json,md} at the data root and
|
|
2868
|
+
// history/user.md + history/bot.md (user profile / bot persona).
|
|
2869
|
+
const DATA_ALLOWLIST = new Set([
|
|
2870
|
+
'mixdog-config.json', 'user-workflow.json', 'user-workflow.md',
|
|
2871
|
+
'history/user.md', 'history/bot.md',
|
|
2872
|
+
])
|
|
2873
|
+
|
|
2874
|
+
const makeHandler = root => {
|
|
2875
|
+
const isDataDir = pathResolve(root) === pathResolve(PLUGIN_DATA)
|
|
2876
|
+
return (_eventType, filename) => {
|
|
2877
|
+
if (!filename) return
|
|
2878
|
+
if (!/\.(md|json)$/i.test(filename)) return
|
|
2879
|
+
const norm = filename.replace(/\\/g, '/')
|
|
2880
|
+
if (isDataDir && !DATA_ALLOWLIST.has(norm)) return
|
|
2881
|
+
const abs = pathResolve(root, filename)
|
|
2882
|
+
if (abs === resolvedTarget) return
|
|
2883
|
+
rebuild(filename)
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
const roots = [
|
|
2888
|
+
join(PLUGIN_ROOT, 'rules'),
|
|
2889
|
+
PLUGIN_DATA,
|
|
2890
|
+
]
|
|
2891
|
+
for (const root of roots) {
|
|
2892
|
+
try {
|
|
2893
|
+
fsWatch(root, { recursive: true, persistent: true }, makeHandler(root))
|
|
2894
|
+
log(`[rules-watcher] watching ${root}`)
|
|
2895
|
+
} catch (e) {
|
|
2896
|
+
log(`[rules-watcher] failed to watch ${root}: ${e && (e.stack || e.message) || e}`)
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
} catch (e) {
|
|
2900
|
+
log(`[rules-watcher] setup failed: ${e && (e.stack || e.message) || e}`)
|
|
2901
|
+
}
|
|
2902
|
+
})
|
|
2903
|
+
|
|
2904
|
+
// Channels worker spawn + reconcileClaudeMd + deferred search/dispatch
|
|
2905
|
+
// recovery stay after MCP connect; status-server now starts during parent
|
|
2906
|
+
// boot, before memory/channels workers.
|
|
2907
|
+
|
|
2908
|
+
// ── Shutdown ────────────────────────────────────────────────────────
|
|
2909
|
+
const isWin = process.platform === 'win32'
|
|
2910
|
+
let shuttingDown = false
|
|
2911
|
+
const WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 8000 // child must be < parent (10s) to avoid race
|
|
2912
|
+
|
|
2913
|
+
async function gracefulKillWorker(name, entry) {
|
|
2914
|
+
const pid = entry.proc.pid
|
|
2915
|
+
workerIntentionalStop.add(name)
|
|
2916
|
+
// Step 1: request clean shutdown via IPC (preferred) or SIGTERM simulation.
|
|
2917
|
+
// On Windows, Node child_process.kill('SIGTERM') sends a real SIGTERM only
|
|
2918
|
+
// on newer Node; for reliability we prefer IPC message on win32.
|
|
2919
|
+
let shutdownRequested = false
|
|
2920
|
+
if (entry.proc.connected) {
|
|
2921
|
+
try {
|
|
2922
|
+
entry.proc.send({ type: 'shutdown' })
|
|
2923
|
+
shutdownRequested = true
|
|
2924
|
+
log(`shutdown: sent IPC {type:"shutdown"} to worker ${name} (pid=${pid})`)
|
|
2925
|
+
} catch {}
|
|
2926
|
+
}
|
|
2927
|
+
if (!shutdownRequested) {
|
|
2928
|
+
try {
|
|
2929
|
+
entry.proc.kill('SIGTERM')
|
|
2930
|
+
log(`shutdown: sent SIGTERM to worker ${name} (pid=${pid})`)
|
|
2931
|
+
} catch {}
|
|
2932
|
+
}
|
|
2933
|
+
// Step 2: wait for clean exit (process.exit fires 'exit' which deletes from workers).
|
|
2934
|
+
const exitP = new Promise(resolve => entry.proc.once('exit', resolve))
|
|
2935
|
+
const timedOut = await Promise.race([
|
|
2936
|
+
exitP.then(() => false),
|
|
2937
|
+
new Promise(resolve => setTimeout(() => resolve(true), WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_MS)),
|
|
2938
|
+
])
|
|
2939
|
+
if (!timedOut) {
|
|
2940
|
+
log(`shutdown: worker ${name} exited cleanly (pid=${pid}) — path=graceful`)
|
|
2941
|
+
return
|
|
2942
|
+
}
|
|
2943
|
+
// Step 3: timeout expired — force kill as last resort.
|
|
2944
|
+
log(`shutdown: worker ${name} did not exit within ${WORKER_GRACEFUL_SHUTDOWN_TIMEOUT_MS}ms — forcing kill (pid=${pid}) path=force`)
|
|
2945
|
+
try {
|
|
2946
|
+
if (isWin && pid) {
|
|
2947
|
+
const { execSync: _ek } = await import('node:child_process')
|
|
2948
|
+
_ek(`taskkill /F /PID ${pid}`, { stdio: 'ignore', windowsHide: true, timeout: 5000 })
|
|
2949
|
+
} else {
|
|
2950
|
+
entry.proc.kill('SIGKILL')
|
|
2951
|
+
}
|
|
2952
|
+
} catch {}
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
async function shutdown(reason) {
|
|
2956
|
+
if (shuttingDown) return
|
|
2957
|
+
shuttingDown = true
|
|
2958
|
+
log(`shutdown: ${reason}`)
|
|
2959
|
+
// Stop idle session sweep timer
|
|
2960
|
+
try {
|
|
2961
|
+
const { stopIdleCleanup } = await import(pathToFileURL(join(PLUGIN_ROOT, 'src/agent/orchestrator/session/manager.mjs')).href)
|
|
2962
|
+
stopIdleCleanup()
|
|
2963
|
+
} catch {}
|
|
2964
|
+
// Stop status HTTP server child — parent-disconnect triggers graceful
|
|
2965
|
+
// shutdown (advertisement file cleanup + server.close) in the child.
|
|
2966
|
+
// On Windows, taskkill /F is the reliable fallback.
|
|
2967
|
+
// Set the stop flag first so the exit handler's scheduleStatusServerRestart()
|
|
2968
|
+
// is a no-op; otherwise a long shutdown can respawn the status server.
|
|
2969
|
+
statusServerStopping = true
|
|
2970
|
+
if (statusServerRestartTimer) {
|
|
2971
|
+
clearTimeout(statusServerRestartTimer)
|
|
2972
|
+
statusServerRestartTimer = null
|
|
2973
|
+
}
|
|
2974
|
+
if (statusServerChild) {
|
|
2975
|
+
const pid = statusServerChild.pid
|
|
2976
|
+
try {
|
|
2977
|
+
if (isWin && pid) {
|
|
2978
|
+
const { execSync: _execSync } = await import('node:child_process')
|
|
2979
|
+
_execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore', windowsHide: true, timeout: 3000 })
|
|
2980
|
+
} else {
|
|
2981
|
+
// SIGTERM then bounded wait + SIGKILL escalation: the parent exits at
|
|
2982
|
+
// the end of shutdown(), so without waiting a slow-to-handle child is
|
|
2983
|
+
// orphaned. Race the child's 'exit' against a 3s deadline, then force
|
|
2984
|
+
// kill if it hasn't exited.
|
|
2985
|
+
const _child = statusServerChild
|
|
2986
|
+
const _exited = new Promise(resolve => _child.once('exit', () => resolve(false)))
|
|
2987
|
+
_child.kill('SIGTERM')
|
|
2988
|
+
const _timedOut = await Promise.race([
|
|
2989
|
+
_exited,
|
|
2990
|
+
new Promise(resolve => setTimeout(() => resolve(true), 3000)),
|
|
2991
|
+
])
|
|
2992
|
+
if (_timedOut) {
|
|
2993
|
+
try { _child.kill('SIGKILL') } catch {}
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
} catch {}
|
|
2997
|
+
// Belt-and-braces: unlink the advertisement file if child didn't.
|
|
2998
|
+
try { unlinkSync(STATUS_ADVERTISE_PATH) } catch {}
|
|
2999
|
+
}
|
|
3000
|
+
// Graceful worker shutdown: IPC/SIGTERM → wait → force kill only as last resort.
|
|
3001
|
+
// Avoids taskkill /F /T which may corrupt pgdata.
|
|
3002
|
+
for (const [name, entry] of workers) {
|
|
3003
|
+
await gracefulKillWorker(name, entry)
|
|
3004
|
+
}
|
|
3005
|
+
// Kill tracked bridge CLI processes
|
|
3006
|
+
try {
|
|
3007
|
+
const { cleanupOrphanedPids } = await import(pathToFileURL(join(PLUGIN_ROOT, 'src/shared/llm/pid-cleanup.mjs')).href)
|
|
3008
|
+
const killed = cleanupOrphanedPids()
|
|
3009
|
+
if (killed > 0) log(`shutdown: cleaned ${killed} bridge CLI processes`)
|
|
3010
|
+
} catch {}
|
|
3011
|
+
// Graceful PG shutdown — fires only on clean shutdown path.
|
|
3012
|
+
// On supervisor-kill path (SIGTERM w/ no time for graceful stop), PG is
|
|
3013
|
+
// left running; next ensurePgInstance call recovers via stale-detection.
|
|
3014
|
+
try {
|
|
3015
|
+
const { stopPgForShutdown } = await import(pathToFileURL(join(PLUGIN_ROOT, 'src/memory/lib/pg/supervisor.mjs')).href)
|
|
3016
|
+
await stopPgForShutdown()
|
|
3017
|
+
} catch {}
|
|
3018
|
+
for (const mod of modules.values()) {
|
|
3019
|
+
if (mod.stop) await mod.stop()
|
|
3020
|
+
}
|
|
3021
|
+
// Final log drain — graceful path is the only flush guarantee since the
|
|
3022
|
+
// SIGTERM short-circuit handler was removed.
|
|
3023
|
+
try { _logFlushSync() } catch {}
|
|
3024
|
+
process.exit(0)
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
server.onclose = () => shutdown('transport closed')
|
|
3028
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
3029
|
+
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
3030
|
+
|
|
3031
|
+
// R17 parent-watch: if the supervisor dies abruptly (no IPC, no SIGTERM),
|
|
3032
|
+
// server-main + forked workers would otherwise keep running across restarts.
|
|
3033
|
+
// Poll the supervisor pid with signal 0 every 5 s; on ESRCH (parent gone)
|
|
3034
|
+
// trigger the same graceful shutdown path the SIGTERM handler uses.
|
|
3035
|
+
{
|
|
3036
|
+
const _supervisorPid = Number(process.env.MIXDOG_SUPERVISOR_PID)
|
|
3037
|
+
if (Number.isFinite(_supervisorPid) && _supervisorPid > 0) {
|
|
3038
|
+
const _parentWatch = setInterval(() => {
|
|
3039
|
+
try {
|
|
3040
|
+
process.kill(_supervisorPid, 0)
|
|
3041
|
+
} catch {
|
|
3042
|
+
// parent gone — fall through to graceful shutdown
|
|
3043
|
+
try { clearInterval(_parentWatch) } catch {}
|
|
3044
|
+
shutdown('supervisor exit (parent-watch)')
|
|
3045
|
+
}
|
|
3046
|
+
}, 5000)
|
|
3047
|
+
if (typeof _parentWatch.unref === 'function') _parentWatch.unref()
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
// Wire prelude's supervisor-control flag (set before server-main loaded).
|
|
3052
|
+
globalThis.__mixdogShutdownFromSupervisor = () => shutdown('supervisor control')
|
|
3053
|
+
if (globalThis.__mixdogSupervisorShutdownRequested) {
|
|
3054
|
+
shutdown('supervisor control (early)')
|
|
3055
|
+
}
|