mixdog 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +31 -0
- package/.claude-plugin/plugin.json +20 -0
- package/.gitattributes +34 -0
- package/.mcp.json +14 -0
- package/ARCHITECTURE.md +77 -0
- package/CHANGELOG.md +7 -0
- package/CONTRIBUTING.md +45 -0
- package/DATA-FLOW.md +79 -0
- package/LICENSE +21 -0
- package/README.md +389 -0
- package/SECURITY.md +138 -0
- package/UNINSTALL.md +112 -0
- package/agents/maintenance.md +5 -0
- package/agents/memory-classification.md +30 -0
- package/agents/scheduler-task.md +18 -0
- package/agents/webhook-handler.md +27 -0
- package/agents/worker.md +24 -0
- package/bin/bridge +133 -0
- package/bin/statusline-launcher.mjs +78 -0
- package/bin/statusline-lib.mjs +550 -0
- package/bin/statusline.mjs +607 -0
- package/bun.lock +802 -0
- package/commands/config.md +16 -0
- package/commands/doctor.md +13 -0
- package/commands/setup.md +17 -0
- package/defaults/cycle3-review-prompt.md +90 -0
- package/defaults/hidden-roles.json +65 -0
- package/defaults/memory-chunk-prompt.md +63 -0
- package/defaults/memory-promote-prompt.md +135 -0
- package/defaults/mixdog-config.template.json +27 -0
- package/defaults/user-workflow.json +8 -0
- package/defaults/user-workflow.md +12 -0
- package/hooks/hooks.json +73 -0
- package/hooks/lib/active-instance.cjs +77 -0
- package/hooks/lib/permission-evaluator.cjs +411 -0
- package/hooks/lib/permission-route.cjs +63 -0
- package/hooks/lib/permission-rules.cjs +170 -0
- package/hooks/lib/settings-loader.cjs +116 -0
- package/hooks/post-tool-use.cjs +84 -0
- package/hooks/pre-mcp-sandbox.cjs +158 -0
- package/hooks/pre-tool-subagent.cjs +253 -0
- package/hooks/session-start.cjs +1372 -0
- package/hooks/turn-timer.cjs +82 -0
- package/lib/claude-md-writer.cjs +386 -0
- package/lib/config-cjs.cjs +61 -0
- package/lib/hook-pipe-path.cjs +10 -0
- package/lib/keychain-cjs.cjs +263 -0
- package/lib/plugin-paths.cjs +61 -0
- package/lib/rules-builder.cjs +241 -0
- package/lib/text-utils.cjs +61 -0
- package/native/README.md +117 -0
- package/native/prebuilt/linux-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/linux-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/macos-aarch64/mixdog-shim +0 -0
- package/native/prebuilt/macos-x86_64/mixdog-shim +0 -0
- package/native/prebuilt/windows-x86_64/mixdog-shim.exe +0 -0
- package/package.json +107 -0
- package/prompts/code-review.txt +16 -0
- package/prompts/security-audit.txt +17 -0
- package/rules/bridge/00-common.md +39 -0
- package/rules/bridge/20-skip-protocol.md +18 -0
- package/rules/bridge/30-explorer.md +33 -0
- package/rules/bridge/40-cycle1-agent.md +52 -0
- package/rules/bridge/41-cycle2-agent.md +62 -0
- package/rules/bridge/42-cycle3-agent.md +44 -0
- package/rules/lead/00-tool-lead.md +61 -0
- package/rules/lead/01-general.md +23 -0
- package/rules/lead/02-channels.md +49 -0
- package/rules/lead/03-team.md +27 -0
- package/rules/lead/04-workflow.md +20 -0
- package/rules/shared/00-language.md +14 -0
- package/rules/shared/01-tool.md +138 -0
- package/scripts/bootstrap.mjs +184 -0
- package/scripts/bridge-unify-smoke.mjs +308 -0
- package/scripts/build-runtime-linux.sh +348 -0
- package/scripts/build-runtime-macos.sh +217 -0
- package/scripts/build-runtime-windows.ps1 +242 -0
- package/scripts/builtin-utils-smoke.mjs +392 -0
- package/scripts/check-json.mjs +45 -0
- package/scripts/check-syntax-changed.mjs +102 -0
- package/scripts/check-syntax.mjs +58 -0
- package/scripts/code-graph-batch.test.mjs +33 -0
- package/scripts/config-preserve-smoke.mjs +180 -0
- package/scripts/doctor.mjs +484 -0
- package/scripts/edit-normalize-fuzz.mjs +130 -0
- package/scripts/edit-normalize-smoke.mjs +401 -0
- package/scripts/edit-operation-smoke.mjs +369 -0
- package/scripts/edit2-smoke.mjs +63 -0
- package/scripts/fuzzy-e2e.mjs +28 -0
- package/scripts/fuzzy-smoke.mjs +26 -0
- package/scripts/generate-runtime-manifest.mjs +166 -0
- package/scripts/guard-smoke.mjs +66 -0
- package/scripts/hidden-role-schema-smoke.mjs +162 -0
- package/scripts/hook-routing-smoke.mjs +29 -0
- package/scripts/inject-input.ps1 +204 -0
- package/scripts/io-complex-smoke.mjs +667 -0
- package/scripts/io-explore-bench.mjs +424 -0
- package/scripts/io-guardrails-smoke.mjs +205 -0
- package/scripts/io-mini-bench-baseline.json +11 -0
- package/scripts/io-mini-bench.mjs +216 -0
- package/scripts/io-route-harness.mjs +933 -0
- package/scripts/io-telemetry-report.mjs +691 -0
- package/scripts/mutation-bench.mjs +564 -0
- package/scripts/mutation-io-smoke.mjs +1081 -0
- package/scripts/native-patch-bridge-smoke.mjs +288 -0
- package/scripts/native-patch-smoke.mjs +304 -0
- package/scripts/patch-interior-context-smoke.mjs +49 -0
- package/scripts/patch-newline-utf8-smoke.mjs +157 -0
- package/scripts/perf-hook-smoke.mjs +71 -0
- package/scripts/permission-eval-smoke.mjs +426 -0
- package/scripts/prep-patch.mjs +53 -0
- package/scripts/prep-shim.mjs +96 -0
- package/scripts/provider-cache-smoke.mjs +687 -0
- package/scripts/report-runtime-health.mjs +132 -0
- package/scripts/run-mcp.mjs +1547 -0
- package/scripts/salvage-v4a-shatter.test.mjs +58 -0
- package/scripts/scoped-cache-io-smoke.mjs +103 -0
- package/scripts/shell-policy-round3-smoke.mjs +46 -0
- package/scripts/smoke-runtime-negative.ps1 +100 -0
- package/scripts/smoke-runtime-negative.sh +95 -0
- package/scripts/stall-policy-smoke.mjs +50 -0
- package/scripts/start-memory-worker.mjs +23 -0
- package/scripts/statusline-launcher-smoke.mjs +82 -0
- package/scripts/stress-atomic-write.mjs +1028 -0
- package/scripts/test-config-rmw-restore.mjs +122 -0
- package/scripts/test-fault-inject.mjs +164 -0
- package/scripts/test-large-file.mjs +174 -0
- package/scripts/tool-edge-smoke.mjs +209 -0
- package/scripts/uninstall.mjs +201 -0
- package/scripts/webhook-selfheal-smoke.mjs +29 -0
- package/scripts/write-overwrite-guard-smoke.mjs +56 -0
- package/server-main.mjs +3055 -0
- package/server.mjs +468 -0
- package/setup/config-merge.mjs +254 -0
- package/setup/install.mjs +120 -0
- package/setup/launch-core.mjs +507 -0
- package/setup/launch.mjs +101 -0
- package/setup/setup-server.mjs +3206 -0
- package/setup/setup.html +3693 -0
- package/skills/retro-skill-proposer/SKILL.md +92 -0
- package/skills/schedule-add/SKILL.md +77 -0
- package/skills/setup/SKILL.md +346 -0
- package/skills/webhook-add/SKILL.md +81 -0
- package/src/agent/bridge-stall-watchdog.mjs +337 -0
- package/src/agent/index.mjs +2138 -0
- package/src/agent/orchestrator/activity-bus.mjs +38 -0
- package/src/agent/orchestrator/ai-wrapped-dispatch.mjs +1010 -0
- package/src/agent/orchestrator/bridge-retry.mjs +220 -0
- package/src/agent/orchestrator/bridge-trace.mjs +583 -0
- package/src/agent/orchestrator/cache-mtime.mjs +58 -0
- package/src/agent/orchestrator/config.mjs +358 -0
- package/src/agent/orchestrator/context/collect.mjs +651 -0
- package/src/agent/orchestrator/dispatch-persist.mjs +549 -0
- package/src/agent/orchestrator/drain-registry.mjs +50 -0
- package/src/agent/orchestrator/explore-validator.mjs +8 -0
- package/src/agent/orchestrator/internal-roles.mjs +118 -0
- package/src/agent/orchestrator/internal-tools.mjs +88 -0
- package/src/agent/orchestrator/jobs.mjs +116 -0
- package/src/agent/orchestrator/mcp/client.mjs +364 -0
- package/src/agent/orchestrator/providers/anthropic-betas.mjs +21 -0
- package/src/agent/orchestrator/providers/anthropic-oauth.mjs +1745 -0
- package/src/agent/orchestrator/providers/anthropic.mjs +437 -0
- package/src/agent/orchestrator/providers/gemini.mjs +1175 -0
- package/src/agent/orchestrator/providers/grok-oauth.mjs +782 -0
- package/src/agent/orchestrator/providers/model-catalog.mjs +241 -0
- package/src/agent/orchestrator/providers/openai-compat.mjs +1467 -0
- package/src/agent/orchestrator/providers/openai-oauth-ws.mjs +1890 -0
- package/src/agent/orchestrator/providers/openai-oauth.mjs +1307 -0
- package/src/agent/orchestrator/providers/openai-ws.mjs +104 -0
- package/src/agent/orchestrator/providers/registry.mjs +192 -0
- package/src/agent/orchestrator/providers/retry-classifier.mjs +325 -0
- package/src/agent/orchestrator/session/abort-lookup.mjs +13 -0
- package/src/agent/orchestrator/session/cache/post-edit-marks.mjs +42 -0
- package/src/agent/orchestrator/session/cache/prefetch-cache.mjs +142 -0
- package/src/agent/orchestrator/session/cache/read-cache.mjs +319 -0
- package/src/agent/orchestrator/session/cache/scoped-cache-outcome.mjs +11 -0
- package/src/agent/orchestrator/session/cache/scoped-cache.mjs +361 -0
- package/src/agent/orchestrator/session/cache/util.mjs +49 -0
- package/src/agent/orchestrator/session/loop.mjs +1478 -0
- package/src/agent/orchestrator/session/manager.mjs +1975 -0
- package/src/agent/orchestrator/session/read-dedup.mjs +6 -0
- package/src/agent/orchestrator/session/result-classification.mjs +65 -0
- package/src/agent/orchestrator/session/save-session-worker.mjs +18 -0
- package/src/agent/orchestrator/session/store.mjs +624 -0
- package/src/agent/orchestrator/session/stream-watchdog.mjs +130 -0
- package/src/agent/orchestrator/session/tool-result-offload.mjs +166 -0
- package/src/agent/orchestrator/session/trim.mjs +491 -0
- package/src/agent/orchestrator/smart-bridge/CACHE-SHARD.md +115 -0
- package/src/agent/orchestrator/smart-bridge/bridge-llm.mjs +327 -0
- package/src/agent/orchestrator/smart-bridge/cache-obs.mjs +150 -0
- package/src/agent/orchestrator/smart-bridge/cache-strategy.mjs +228 -0
- package/src/agent/orchestrator/smart-bridge/index.mjs +215 -0
- package/src/agent/orchestrator/smart-bridge/profiles.mjs +37 -0
- package/src/agent/orchestrator/smart-bridge/registry.mjs +348 -0
- package/src/agent/orchestrator/smart-bridge/session-builder.mjs +116 -0
- package/src/agent/orchestrator/stall-policy.mjs +195 -0
- package/src/agent/orchestrator/tool-loop-guard.mjs +75 -0
- package/src/agent/orchestrator/tools/bash-policy-scan.mjs +77 -0
- package/src/agent/orchestrator/tools/bash-session.mjs +721 -0
- package/src/agent/orchestrator/tools/builtin/advisory-lock.mjs +171 -0
- package/src/agent/orchestrator/tools/builtin/arg-guard.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/atomic-write.mjs +236 -0
- package/src/agent/orchestrator/tools/builtin/bash-tool.mjs +480 -0
- package/src/agent/orchestrator/tools/builtin/binary-file.mjs +76 -0
- package/src/agent/orchestrator/tools/builtin/builtin-tools.mjs +256 -0
- package/src/agent/orchestrator/tools/builtin/cache-layers.mjs +386 -0
- package/src/agent/orchestrator/tools/builtin/cwd-utils.mjs +37 -0
- package/src/agent/orchestrator/tools/builtin/device-paths.mjs +154 -0
- package/src/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +292 -0
- package/src/agent/orchestrator/tools/builtin/diff-utils.mjs +109 -0
- package/src/agent/orchestrator/tools/builtin/edit-base-guard.mjs +58 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-plan.mjs +240 -0
- package/src/agent/orchestrator/tools/builtin/edit-byte-utils.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/edit-commit.mjs +74 -0
- package/src/agent/orchestrator/tools/builtin/edit-context-utils.mjs +242 -0
- package/src/agent/orchestrator/tools/builtin/edit-diagnostics.mjs +211 -0
- package/src/agent/orchestrator/tools/builtin/edit-engine.mjs +1364 -0
- package/src/agent/orchestrator/tools/builtin/edit-failure-context.mjs +126 -0
- package/src/agent/orchestrator/tools/builtin/edit-hint.mjs +141 -0
- package/src/agent/orchestrator/tools/builtin/edit-match-utils.mjs +194 -0
- package/src/agent/orchestrator/tools/builtin/edit-partial-write.mjs +60 -0
- package/src/agent/orchestrator/tools/builtin/edit-stale-refresh.mjs +168 -0
- package/src/agent/orchestrator/tools/builtin/edit-tool.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/edit-utf8-guard.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fs-reachability.mjs +48 -0
- package/src/agent/orchestrator/tools/builtin/fuzzy-match.mjs +99 -0
- package/src/agent/orchestrator/tools/builtin/glob-walk.mjs +170 -0
- package/src/agent/orchestrator/tools/builtin/grep-formatting.mjs +113 -0
- package/src/agent/orchestrator/tools/builtin/hash-utils.mjs +6 -0
- package/src/agent/orchestrator/tools/builtin/list-formatting.mjs +7 -0
- package/src/agent/orchestrator/tools/builtin/list-tool.mjs +593 -0
- package/src/agent/orchestrator/tools/builtin/native-edit-runner.mjs +89 -0
- package/src/agent/orchestrator/tools/builtin/notebook-edit-tool.mjs +300 -0
- package/src/agent/orchestrator/tools/builtin/open-config-tool.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/path-diagnostics.mjs +152 -0
- package/src/agent/orchestrator/tools/builtin/path-locks.mjs +35 -0
- package/src/agent/orchestrator/tools/builtin/path-utils.mjs +201 -0
- package/src/agent/orchestrator/tools/builtin/read-args.mjs +103 -0
- package/src/agent/orchestrator/tools/builtin/read-batch.mjs +172 -0
- package/src/agent/orchestrator/tools/builtin/read-constants.mjs +40 -0
- package/src/agent/orchestrator/tools/builtin/read-formatting.mjs +118 -0
- package/src/agent/orchestrator/tools/builtin/read-image-resize.mjs +189 -0
- package/src/agent/orchestrator/tools/builtin/read-image.mjs +88 -0
- package/src/agent/orchestrator/tools/builtin/read-lines.mjs +12 -0
- package/src/agent/orchestrator/tools/builtin/read-mode-tool.mjs +455 -0
- package/src/agent/orchestrator/tools/builtin/read-open.mjs +190 -0
- package/src/agent/orchestrator/tools/builtin/read-range-index.mjs +271 -0
- package/src/agent/orchestrator/tools/builtin/read-ranges.mjs +26 -0
- package/src/agent/orchestrator/tools/builtin/read-single-tool.mjs +728 -0
- package/src/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +173 -0
- package/src/agent/orchestrator/tools/builtin/read-special-files.mjs +268 -0
- package/src/agent/orchestrator/tools/builtin/read-streaming.mjs +602 -0
- package/src/agent/orchestrator/tools/builtin/read-tool.mjs +530 -0
- package/src/agent/orchestrator/tools/builtin/read-windows.mjs +107 -0
- package/src/agent/orchestrator/tools/builtin/rename-tool.mjs +196 -0
- package/src/agent/orchestrator/tools/builtin/rg-runner.mjs +422 -0
- package/src/agent/orchestrator/tools/builtin/search-builders.mjs +158 -0
- package/src/agent/orchestrator/tools/builtin/search-tool.mjs +869 -0
- package/src/agent/orchestrator/tools/builtin/shell-analysis.mjs +653 -0
- package/src/agent/orchestrator/tools/builtin/shell-jobs.mjs +936 -0
- package/src/agent/orchestrator/tools/builtin/shell-output.mjs +36 -0
- package/src/agent/orchestrator/tools/builtin/shell-runtime.mjs +214 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +143 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-store.mjs +206 -0
- package/src/agent/orchestrator/tools/builtin/snapshot-validation.mjs +98 -0
- package/src/agent/orchestrator/tools/builtin/text-stats.mjs +69 -0
- package/src/agent/orchestrator/tools/builtin/windows-roots.mjs +23 -0
- package/src/agent/orchestrator/tools/builtin/write-tool.mjs +401 -0
- package/src/agent/orchestrator/tools/builtin.mjs +500 -0
- package/src/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +39 -0
- package/src/agent/orchestrator/tools/code-graph-tool-defs.mjs +24 -0
- package/src/agent/orchestrator/tools/code-graph.mjs +4095 -0
- package/src/agent/orchestrator/tools/cwd-tool.mjs +298 -0
- package/src/agent/orchestrator/tools/destructive-warning.mjs +323 -0
- package/src/agent/orchestrator/tools/edit-normalize.mjs +603 -0
- package/src/agent/orchestrator/tools/env-scrub.mjs +100 -0
- package/src/agent/orchestrator/tools/graph-binary-fetcher.mjs +144 -0
- package/src/agent/orchestrator/tools/graph-manifest.json +26 -0
- package/src/agent/orchestrator/tools/host-input.mjs +204 -0
- package/src/agent/orchestrator/tools/mutation-content-cache.mjs +67 -0
- package/src/agent/orchestrator/tools/mutation-planner.mjs +75 -0
- package/src/agent/orchestrator/tools/next-call-utils.mjs +48 -0
- package/src/agent/orchestrator/tools/patch-binary-fetcher.mjs +133 -0
- package/src/agent/orchestrator/tools/patch-manifest.json +26 -0
- package/src/agent/orchestrator/tools/patch-tool-defs.mjs +20 -0
- package/src/agent/orchestrator/tools/patch.mjs +2754 -0
- package/src/agent/orchestrator/tools/progress-message.mjs +118 -0
- package/src/agent/orchestrator/tools/result-compression.mjs +279 -0
- package/src/agent/orchestrator/tools/shell-command.mjs +865 -0
- package/src/agent/orchestrator/tools/shell-exec-policy.mjs +89 -0
- package/src/agent/orchestrator/tools/shell-policy-danger-target.mjs +27 -0
- package/src/agent/orchestrator/tools/shell-policy-imports.mjs +7 -0
- package/src/agent/orchestrator/tools/shell-policy.mjs +345 -0
- package/src/agent/orchestrator/tools/shell-snapshot.mjs +313 -0
- package/src/agent/orchestrator/workflow-store.mjs +93 -0
- package/src/agent/tool-defs.mjs +103 -0
- package/src/channels/backends/discord.mjs +784 -0
- package/src/channels/data/voice-runtime-manifest.json +138 -0
- package/src/channels/index.mjs +3229 -0
- package/src/channels/lib/cli-worker-host.mjs +12 -0
- package/src/channels/lib/config-lock.mjs +13 -0
- package/src/channels/lib/config.mjs +292 -0
- package/src/channels/lib/drop-trace.mjs +71 -0
- package/src/channels/lib/event-pipeline.mjs +81 -0
- package/src/channels/lib/event-queue.mjs +345 -0
- package/src/channels/lib/executor.mjs +168 -0
- package/src/channels/lib/format.mjs +188 -0
- package/src/channels/lib/holidays.mjs +138 -0
- package/src/channels/lib/hook-pipe-server.mjs +802 -0
- package/src/channels/lib/interaction-workflows.mjs +184 -0
- package/src/channels/lib/memory-client.mjs +149 -0
- package/src/channels/lib/output-forwarder.mjs +765 -0
- package/src/channels/lib/runtime-paths.mjs +479 -0
- package/src/channels/lib/scheduler.mjs +723 -0
- package/src/channels/lib/session-control.mjs +36 -0
- package/src/channels/lib/session-discovery.mjs +103 -0
- package/src/channels/lib/settings.mjs +11 -0
- package/src/channels/lib/state-file.mjs +68 -0
- package/src/channels/lib/status-snapshot.mjs +219 -0
- package/src/channels/lib/tool-format.mjs +140 -0
- package/src/channels/lib/transcript-discovery.mjs +195 -0
- package/src/channels/lib/voice-runtime-fetcher.mjs +734 -0
- package/src/channels/lib/webhook.mjs +1179 -0
- package/src/channels/lib/whisper-server.mjs +477 -0
- package/src/channels/tool-defs.mjs +170 -0
- package/src/daemon/host.mjs +118 -0
- package/src/daemon/mcp-transport.mjs +47 -0
- package/src/daemon/session.mjs +100 -0
- package/src/daemon/thin-client.mjs +71 -0
- package/src/daemon/transport.mjs +163 -0
- package/src/memory/data/runtime-manifest.json +40 -0
- package/src/memory/index.mjs +3305 -0
- package/src/memory/lib/agent-ipc.mjs +93 -0
- package/src/memory/lib/bridge-trace-queries.mjs +120 -0
- package/src/memory/lib/core-memory-store.mjs +330 -0
- package/src/memory/lib/embedding-provider.mjs +269 -0
- package/src/memory/lib/embedding-worker.mjs +323 -0
- package/src/memory/lib/llm-worker-host.mjs +17 -0
- package/src/memory/lib/memory-cycle.mjs +11 -0
- package/src/memory/lib/memory-cycle1.mjs +641 -0
- package/src/memory/lib/memory-cycle2.mjs +1284 -0
- package/src/memory/lib/memory-cycle3.mjs +540 -0
- package/src/memory/lib/memory-embed.mjs +299 -0
- package/src/memory/lib/memory-extraction.mjs +5 -0
- package/src/memory/lib/memory-maintenance-store.mjs +32 -0
- package/src/memory/lib/memory-ops-policy.mjs +190 -0
- package/src/memory/lib/memory-recall-id-patch.mjs +15 -0
- package/src/memory/lib/memory-recall-read-query.mjs +7 -0
- package/src/memory/lib/memory-recall-scope-filter.mjs +63 -0
- package/src/memory/lib/memory-recall-store.mjs +621 -0
- package/src/memory/lib/memory-retrievers.mjs +112 -0
- package/src/memory/lib/memory-score.mjs +71 -0
- package/src/memory/lib/memory-text-utils.mjs +58 -0
- package/src/memory/lib/memory.mjs +412 -0
- package/src/memory/lib/model-profile.mjs +85 -0
- package/src/memory/lib/pg/adapter.mjs +308 -0
- package/src/memory/lib/pg/process.mjs +360 -0
- package/src/memory/lib/pg/supervisor.mjs +396 -0
- package/src/memory/lib/project-id-resolver.mjs +86 -0
- package/src/memory/lib/runtime-fetcher.mjs +442 -0
- package/src/memory/lib/trace-store.mjs +728 -0
- package/src/memory/tool-defs.mjs +79 -0
- package/src/search/index.mjs +1173 -0
- package/src/search/lib/backends/anthropic-oauth.mjs +98 -0
- package/src/search/lib/backends/exa.mjs +50 -0
- package/src/search/lib/backends/firecrawl.mjs +61 -0
- package/src/search/lib/backends/gemini-api.mjs +83 -0
- package/src/search/lib/backends/grok-oauth.mjs +86 -0
- package/src/search/lib/backends/index.mjs +150 -0
- package/src/search/lib/backends/openai-api.mjs +144 -0
- package/src/search/lib/backends/openai-oauth.mjs +98 -0
- package/src/search/lib/backends/openai-web-search.mjs +76 -0
- package/src/search/lib/backends/tavily.mjs +55 -0
- package/src/search/lib/backends/xai-api.mjs +113 -0
- package/src/search/lib/cache.mjs +131 -0
- package/src/search/lib/config.mjs +192 -0
- package/src/search/lib/formatter.mjs +115 -0
- package/src/search/lib/provider-usage.mjs +67 -0
- package/src/search/lib/providers.mjs +47 -0
- package/src/search/lib/search-intent.mjs +109 -0
- package/src/search/lib/setup-handler.mjs +261 -0
- package/src/search/lib/state.mjs +201 -0
- package/src/search/lib/web-tools.mjs +1207 -0
- package/src/search/tool-defs.mjs +83 -0
- package/src/setup/defender-exclusion.mjs +183 -0
- package/src/shared/abort-controller.mjs +15 -0
- package/src/shared/atomic-file.mjs +420 -0
- package/src/shared/config.mjs +350 -0
- package/src/shared/daemon-recycle.mjs +108 -0
- package/src/shared/disable-claude-builtins.mjs +88 -0
- package/src/shared/err-text.mjs +12 -0
- package/src/shared/llm/cost.mjs +66 -0
- package/src/shared/llm/http-agent.mjs +123 -0
- package/src/shared/llm/index.mjs +41 -0
- package/src/shared/llm/pid-cleanup.mjs +27 -0
- package/src/shared/llm/usage-log.mjs +47 -0
- package/src/shared/plugin-paths.mjs +58 -0
- package/src/shared/schedules-store.mjs +70 -0
- package/src/shared/seed.mjs +119 -0
- package/src/shared/user-cwd.mjs +213 -0
- package/src/shared/user-data-guard.mjs +238 -0
- package/src/status/aggregator.mjs +584 -0
- package/src/status/server.mjs +413 -0
- package/tools.json +1653 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai-wrapped-dispatch — dispatch hub for `recall` / `search` / `explore`.
|
|
3
|
+
*
|
|
4
|
+
* All three MCP tools flagged `aiWrapped: true` in tools.json route here
|
|
5
|
+
* instead of the direct module handler. Each query spawns its own Pool C
|
|
6
|
+
* agent session and runs concurrently via Promise.allSettled, so wall-clock
|
|
7
|
+
* latency is bound by the slowest query rather than the sum. A single query
|
|
8
|
+
* spawns a single agent, so the per-array cost scales linearly with query
|
|
9
|
+
* count. Shared Pool B/C cache shards mean only the first concurrent agent
|
|
10
|
+
* pays the cold-write; peers ride the warm prefix.
|
|
11
|
+
*
|
|
12
|
+
* Dispatch completion pushes into the caller's session via the existing
|
|
13
|
+
* `notifications/claude/channel` bridge. The notify meta carries
|
|
14
|
+
* `type: 'dispatch_result'` plus an `instruction` string so the Lead
|
|
15
|
+
* integrates the answer on its next turn automatically.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { homedir } from 'os'
|
|
19
|
+
import { resolve as resolvePath, isAbsolute, join, relative, dirname } from 'path'
|
|
20
|
+
import { createHash } from 'crypto'
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, statSync, readdirSync, unlinkSync, writeFileSync } from 'fs'
|
|
22
|
+
import { loadConfig, getPluginData } from './config.mjs'
|
|
23
|
+
import { fileURLToPath } from 'url'
|
|
24
|
+
import { getHiddenRole } from './internal-roles.mjs'
|
|
25
|
+
import { writeJsonAtomicSync } from '../../shared/atomic-file.mjs'
|
|
26
|
+
import { errText } from '../../shared/err-text.mjs'
|
|
27
|
+
import { resolvePresetName } from './smart-bridge/bridge-llm.mjs'
|
|
28
|
+
import { smartReadTruncate } from './tools/builtin.mjs'
|
|
29
|
+
import { executeBuiltinTool } from './tools/builtin.mjs'
|
|
30
|
+
import { addPending, removePending, setPendingResult } from './dispatch-persist.mjs'
|
|
31
|
+
import { notifyActivity } from './activity-bus.mjs'
|
|
32
|
+
import { stripSoftWarns } from './tool-loop-guard.mjs'
|
|
33
|
+
import { stripAnsi, normalizeWhitespace, dedupRepeatedLines } from './tools/result-compression.mjs'
|
|
34
|
+
import {
|
|
35
|
+
EXPLORE_OUTPUT_CHAR_CAP,
|
|
36
|
+
EXPLORE_PER_PIECE_CHAR_CAP,
|
|
37
|
+
EXPLORE_TRUNCATION_MARKER,
|
|
38
|
+
} from './explore-validator.mjs'
|
|
39
|
+
import { classifyResultKind } from './session/result-classification.mjs'
|
|
40
|
+
import { isUncPath } from './tools/builtin/device-paths.mjs'
|
|
41
|
+
|
|
42
|
+
// Plugin version — read once at module load from package.json so per-call cost
|
|
43
|
+
// is zero. Included in the query-cache key so cached results are invalidated
|
|
44
|
+
// when the plugin version changes (new prompt templates, tool definitions, etc.).
|
|
45
|
+
// Intentionally retained: query-result disk cache was removed but this constant
|
|
46
|
+
// stays so a future cache re-enable keeps stable invalidation semantics.
|
|
47
|
+
const _PLUGIN_VERSION = (() => {
|
|
48
|
+
try {
|
|
49
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '../../../package.json')
|
|
50
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
51
|
+
return String(pkg.version || 'unknown')
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`[ai-wrapped-dispatch] package.json read failed: ${err.message}`)
|
|
54
|
+
}
|
|
55
|
+
})()
|
|
56
|
+
|
|
57
|
+
// Fan-out deadline — documented runtime envelope.
|
|
58
|
+
// Default 240 s; override via env FANOUT_DEADLINE_S. 240 s balances
|
|
59
|
+
// the slowest bridge role latency against session responsiveness.
|
|
60
|
+
// Applied to both sync and background fan-out paths. After expiry, settled
|
|
61
|
+
// subs are merged as partial; pending subs are aborted.
|
|
62
|
+
const _FANOUT_DEADLINE_MS = (() => {
|
|
63
|
+
const v = parseInt(process.env.FANOUT_DEADLINE_S, 10)
|
|
64
|
+
return Number.isFinite(v) && v > 0 ? v * 1000 : 240_000
|
|
65
|
+
})()
|
|
66
|
+
|
|
67
|
+
// Hard errors that should trigger sibling abort + partial-error escalation.
|
|
68
|
+
// SessionClosedError is excluded — it means the parent itself aborted, not
|
|
69
|
+
// a sub failure.
|
|
70
|
+
function isHardSubError(reason) {
|
|
71
|
+
if (!reason) return false
|
|
72
|
+
if (reason?.name === 'SessionClosedError') return false
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _roleNameForTool(tool) {
|
|
77
|
+
const def = getHiddenRole('explorer')
|
|
78
|
+
if (def && def.invokedBy === tool) return 'explorer'
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ROLE_BY_TOOL = Object.freeze({
|
|
83
|
+
explore: { role: _roleNameForTool('explore'), build: buildExplorerPrompt, label: _roleNameForTool('explore') || 'explorer' },
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// Clamp a raw subagent body (or error string) to the per-piece cap
|
|
87
|
+
// BEFORE it gets wrapped with header / separator. Returns the (possibly
|
|
88
|
+
// truncated) string; truncation reuses the existing marker so callers
|
|
89
|
+
// see a consistent signal.
|
|
90
|
+
function clampPiece(raw) {
|
|
91
|
+
if (typeof raw !== 'string') return raw
|
|
92
|
+
if (raw.length <= EXPLORE_PER_PIECE_CHAR_CAP) return raw
|
|
93
|
+
return raw.slice(0, EXPLORE_PER_PIECE_CHAR_CAP) + EXPLORE_TRUNCATION_MARKER
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Build a merged answer with a hard cumulative-size cap. Mirrors the
|
|
97
|
+
// per-mode shape used by the regular merge path (single query returns
|
|
98
|
+
// the raw answer; multi-query prepends `### Query N:` headers and joins
|
|
99
|
+
// with `---`) but stops appending once the running total crosses the
|
|
100
|
+
// cap, then emits a single inline marker. Each piece is also pre-clamped
|
|
101
|
+
// to EXPLORE_PER_PIECE_CHAR_CAP so a single oversized response can't
|
|
102
|
+
// blow up before the running-total check fires.
|
|
103
|
+
// partialInfo: { completed, total, deadlineSecs } | null — appends footer when set.
|
|
104
|
+
function mergeExploreSettled(settled, queries, label, partialInfo) {
|
|
105
|
+
const isSingle = queries.length === 1
|
|
106
|
+
if (isSingle) {
|
|
107
|
+
const r = settled[0]
|
|
108
|
+
const raw = r.status === 'fulfilled'
|
|
109
|
+
? (r.value || '(no response)')
|
|
110
|
+
: `[${label} error] ${errText(r.reason)}`
|
|
111
|
+
// Single-query path: per-piece cap == cumulative cap effectively, but
|
|
112
|
+
// still pre-clamp to keep the post-clamp slice bounded and cheap.
|
|
113
|
+
const clamped = clampPiece(raw)
|
|
114
|
+
if (typeof clamped === 'string' && clamped.length > EXPLORE_OUTPUT_CHAR_CAP) {
|
|
115
|
+
return clamped.slice(0, EXPLORE_OUTPUT_CHAR_CAP) + EXPLORE_TRUNCATION_MARKER
|
|
116
|
+
}
|
|
117
|
+
return _appendPartialFooter(clamped, partialInfo)
|
|
118
|
+
}
|
|
119
|
+
const parts = []
|
|
120
|
+
let total = 0
|
|
121
|
+
let truncated = false
|
|
122
|
+
let truncatedAtPiece = -1
|
|
123
|
+
const sep = '\n\n'
|
|
124
|
+
for (let i = 0; i < settled.length; i++) {
|
|
125
|
+
const r = settled[i]
|
|
126
|
+
const header = `## Q${i + 1}: ${String(queries[i] ?? '').replace(/\s+/g, ' ').slice(0, 60)}`
|
|
127
|
+
const rawBody = r.status === 'fulfilled'
|
|
128
|
+
? (r.value || '(no response)')
|
|
129
|
+
: `[${label} error] ${errText(r.reason)}`
|
|
130
|
+
// Pre-clamp the body BEFORE template construction so a 400MB rogue
|
|
131
|
+
// response can't allocate a 400MB+ piece string just to be discarded.
|
|
132
|
+
const body = clampPiece(rawBody)
|
|
133
|
+
const piece = `${header}\n${body}`
|
|
134
|
+
const addLen = (parts.length === 0 ? 0 : sep.length) + piece.length
|
|
135
|
+
// Running-total guard: stop appending once the next piece would push
|
|
136
|
+
// us past the cumulative cap. Truncate the trailing piece to the
|
|
137
|
+
// remaining budget so we still emit something for the boundary query.
|
|
138
|
+
if (total + addLen > EXPLORE_OUTPUT_CHAR_CAP) {
|
|
139
|
+
const remaining = EXPLORE_OUTPUT_CHAR_CAP - total - (parts.length === 0 ? 0 : sep.length)
|
|
140
|
+
if (remaining > 0) {
|
|
141
|
+
parts.push(piece.slice(0, remaining))
|
|
142
|
+
total += (parts.length === 1 ? 0 : sep.length) + remaining
|
|
143
|
+
}
|
|
144
|
+
truncated = true
|
|
145
|
+
truncatedAtPiece = i + 1
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
parts.push(piece)
|
|
149
|
+
total += addLen
|
|
150
|
+
}
|
|
151
|
+
const merged = parts.join(sep)
|
|
152
|
+
if (!truncated) return _appendPartialFooter(merged, partialInfo)
|
|
153
|
+
const note = truncatedAtPiece > 0
|
|
154
|
+
? `\n\n[explore: merge truncated at piece ${truncatedAtPiece}/${settled.length}]`
|
|
155
|
+
: ''
|
|
156
|
+
return _appendPartialFooter(merged + EXPLORE_TRUNCATION_MARKER + note, partialInfo)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Append "(M/N ok, dl=Xs)" footer when partialInfo is truthy.
|
|
160
|
+
function _appendPartialFooter(text, partialInfo) {
|
|
161
|
+
if (!partialInfo) return text
|
|
162
|
+
const { completed, total, deadlineSecs } = partialInfo
|
|
163
|
+
return `${text}\n\n(${completed}/${total} ok, dl=${deadlineSecs}s)`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Preflight: reject explore cwd values that are structurally unbounded
|
|
167
|
+
// roots — fs root, a bare drive root, the home dir, or ~/.claude. This is
|
|
168
|
+
// a path-only gate by design: those roots are never a valid explore scope,
|
|
169
|
+
// and on Windows a probe walk under `path:'/'` returns a misleading 0-entry
|
|
170
|
+
// count, so a count-based check cannot catch them reliably — only the
|
|
171
|
+
// structural path check does.
|
|
172
|
+
//
|
|
173
|
+
// Returns a non-empty error string when rejected, '' when acceptable.
|
|
174
|
+
// Callers MUST abort with an MCP error when non-empty.
|
|
175
|
+
//
|
|
176
|
+
async function checkBroadCwdBlock(resolvedCwd, rawCwdInput) {
|
|
177
|
+
const display = (typeof rawCwdInput === 'string' && rawCwdInput.trim())
|
|
178
|
+
? rawCwdInput.trim()
|
|
179
|
+
: (resolvedCwd || '')
|
|
180
|
+
if (!resolvedCwd) return ''
|
|
181
|
+
// Hard-block system roots and home directory: their glob is unbounded in
|
|
182
|
+
// practice (50k+ entries) and a probe walk under `path:'/'` on Windows
|
|
183
|
+
// can return a misleading 0-entry count when the glob handler cannot
|
|
184
|
+
// resolve the root, letting the spawn proceed. The invariant is
|
|
185
|
+
// structural: these paths are never a valid explore scope.
|
|
186
|
+
const norm = String(resolvedCwd).replace(/\\/g, '/').replace(/\/+$/, '')
|
|
187
|
+
const isUnixRoot = norm === ''
|
|
188
|
+
const isDriveRoot = /^[A-Za-z]:$/.test(norm)
|
|
189
|
+
const home = homedir().replace(/\\/g, '/').replace(/\/+$/, '')
|
|
190
|
+
const isHomeRoot = norm.length > 0 && norm === home
|
|
191
|
+
const claudeDir = home + '/.claude'
|
|
192
|
+
const isClaudeDir = norm === claudeDir
|
|
193
|
+
if (isUnixRoot || isDriveRoot || isHomeRoot || isClaudeDir) {
|
|
194
|
+
return `Error: explore root too broad: "${display}" is a system root or home directory. Narrow to a specific project subdirectory.`
|
|
195
|
+
}
|
|
196
|
+
return ''
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Background dispatch registry. Entries live in-memory for the plugin server
|
|
200
|
+
// process lifetime — the merged answer is auto-pushed via the channel,
|
|
201
|
+
// and the registry is kept around for observability only. Pruned
|
|
202
|
+
// opportunistically to keep the map bounded.
|
|
203
|
+
const _dispatchResults = new Map() // id → { status, role, tool, queries, createdAt, completedAt?, content?, error? }
|
|
204
|
+
const DISPATCH_RESULT_MAX_ENTRIES = 200
|
|
205
|
+
const DISPATCH_RESULT_TTL_MS = 30 * 60_000 // 30 minutes — enough for the Lead to loop back, short enough to not hoard memory
|
|
206
|
+
// R15: hard caps to bound fan-out + background concurrency. Without these,
|
|
207
|
+
// model/prompt-injection abuse can spawn an unbounded number of hidden-role
|
|
208
|
+
// sub-sessions (one per query) or pile up background dispatches faster than
|
|
209
|
+
// they complete, exhausting the plugin server's memory/file-handle budget.
|
|
210
|
+
const MAX_FANOUT_QUERIES = 12
|
|
211
|
+
const MAX_ACTIVE_BG_DISPATCHES = 8
|
|
212
|
+
let _activeBgDispatches = 0
|
|
213
|
+
// (explore query-result cache + disk persistence + inflight coalescing
|
|
214
|
+
// removed — each sub-query now fans out uncached, matching native Explore)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
/* explore cache key builder removed
|
|
220
|
+
.update(siblingList.map((s) => normalizeQueryForCache(s)).sort().join('\u0001'))
|
|
221
|
+
removed */
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
/* explore inflight coalescing removed
|
|
229
|
+
removed */
|
|
230
|
+
|
|
231
|
+
/* explore inflight join/settle removed
|
|
232
|
+
removed */
|
|
233
|
+
|
|
234
|
+
// Shared fan-out controller/deadline/merge/error pipeline used by both
|
|
235
|
+
// sync (in-turn merged answer) and background (handle-then-push) paths.
|
|
236
|
+
// The two paths agree on every observable: parent-abort cascade, sub
|
|
237
|
+
// controllers, hard-error escalation + sibling abort, deadline race, and
|
|
238
|
+
// settled-rebuild. Per-path divergence is parameterized:
|
|
239
|
+
// - `isBackground` controls the deadline-rebuild shape: sync races the
|
|
240
|
+
// live promise against the immediate-reject ("late winner wins"),
|
|
241
|
+
// background uses the immediate-reject only (matches the original
|
|
242
|
+
// bg form byte-for-byte).
|
|
243
|
+
// - `onHardError()` fires once when the first hard sub-error escalates,
|
|
244
|
+
// letting the bg caller mutate the dispatch-registry entry to
|
|
245
|
+
// 'partial-error' while the sync caller skips the mutation.
|
|
246
|
+
// Sub-queries run uncached: each fans out to its own bridge role with no
|
|
247
|
+
// result-cache or inflight-coalescing layer (matches native Explore).
|
|
248
|
+
async function _runFanout({ queries, name, resolvedCwd, brief, spec, ctx, makeBridgeLlm, bridgeConfig, isBackground, onHardError }) {
|
|
249
|
+
// Parent abort → sub controllers link. Resolve parent signal first so an
|
|
250
|
+
// already-aborted parent cascades into freshly-created sub controllers in
|
|
251
|
+
// the same synchronous frame.
|
|
252
|
+
let parentSig = null
|
|
253
|
+
try {
|
|
254
|
+
if (ctx?.callerSessionId) {
|
|
255
|
+
const { getAbortSignalForSession } = await import('./session/abort-lookup.mjs')
|
|
256
|
+
parentSig = await getAbortSignalForSession(ctx.callerSessionId)
|
|
257
|
+
}
|
|
258
|
+
} catch (e) { try { process.stderr.write(`[ai-wrapped-dispatch] swallow: ${e?.message ?? e}\n`); } catch {} }
|
|
259
|
+
|
|
260
|
+
const subControllers = queries.map(() => { try { return new AbortController() } catch { return null } })
|
|
261
|
+
|
|
262
|
+
let _parentAbortHandler = null
|
|
263
|
+
if (parentSig) {
|
|
264
|
+
if (parentSig.aborted) {
|
|
265
|
+
subControllers.forEach(ac => { try { ac?.abort() } catch {} })
|
|
266
|
+
} else {
|
|
267
|
+
_parentAbortHandler = () => {
|
|
268
|
+
subControllers.forEach(ac => { try { ac?.abort() } catch {} })
|
|
269
|
+
}
|
|
270
|
+
parentSig.addEventListener('abort', _parentAbortHandler, { once: true })
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Per-request cancellation (ESC / MCP `notifications/cancelled`) → abort subs.
|
|
275
|
+
// ctx.requestSignal is the forwarded extra.signal from the CallTool handler.
|
|
276
|
+
// Unlike parentSig (session-level), it fires when the user cancels THIS
|
|
277
|
+
// specific tool request. Wiring it into the sub controllers makes ESC
|
|
278
|
+
// actually stop the explorer fan-out instead of letting it run to
|
|
279
|
+
// completion. Background path: the MCP request completes on handle return,
|
|
280
|
+
// so this signal never aborts there — harmless no-op.
|
|
281
|
+
const requestSig = ctx?.requestSignal || null
|
|
282
|
+
let _requestAbortHandler = null
|
|
283
|
+
if (requestSig && requestSig !== parentSig) {
|
|
284
|
+
if (requestSig.aborted) {
|
|
285
|
+
subControllers.forEach(ac => { try { ac?.abort() } catch {} })
|
|
286
|
+
} else {
|
|
287
|
+
_requestAbortHandler = () => {
|
|
288
|
+
subControllers.forEach(ac => { try { ac?.abort() } catch {} })
|
|
289
|
+
}
|
|
290
|
+
requestSig.addEventListener('abort', _requestAbortHandler, { once: true })
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let hardErrorEscalated = false
|
|
295
|
+
const escapedSettled = []
|
|
296
|
+
|
|
297
|
+
const promises = queries.map((q, i) => {
|
|
298
|
+
const subSignal = subControllers[i]?.signal ?? null
|
|
299
|
+
// No result cache / inflight coalescing: each sub-query runs its own
|
|
300
|
+
// bridge role directly, driven by this sub-query's abort controller.
|
|
301
|
+
const p = (async () => {
|
|
302
|
+
const llm = makeBridgeLlm({
|
|
303
|
+
role: spec.role,
|
|
304
|
+
cwd: resolvedCwd,
|
|
305
|
+
brief,
|
|
306
|
+
parentSessionId: ctx?.callerSessionId || null,
|
|
307
|
+
clientHostPid: ctx?.clientHostPid,
|
|
308
|
+
parentSignal: subSignal,
|
|
309
|
+
config: bridgeConfig ?? null,
|
|
310
|
+
})
|
|
311
|
+
return llm({ prompt: spec.build(q, resolvedCwd) })
|
|
312
|
+
})()
|
|
313
|
+
p.then(
|
|
314
|
+
(val) => { escapedSettled[i] = { status: 'fulfilled', value: val } },
|
|
315
|
+
(err) => {
|
|
316
|
+
escapedSettled[i] = { status: 'rejected', reason: err }
|
|
317
|
+
if (!hardErrorEscalated && isHardSubError(err)) {
|
|
318
|
+
hardErrorEscalated = true
|
|
319
|
+
subControllers.forEach((ac, j) => { if (j !== i) try { ac?.abort() } catch {} })
|
|
320
|
+
if (typeof onHardError === 'function') onHardError()
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
return p
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
// Deadline timer — race against all subs.
|
|
328
|
+
let deadlineTimer = null
|
|
329
|
+
let deadlineFired = false
|
|
330
|
+
const deadlineMs = _FANOUT_DEADLINE_MS
|
|
331
|
+
const deadlinePromise = new Promise((resolve) => {
|
|
332
|
+
deadlineTimer = setTimeout(() => {
|
|
333
|
+
deadlineFired = true
|
|
334
|
+
subControllers.forEach(ac => { try { ac?.abort() } catch {} })
|
|
335
|
+
resolve('__deadline__')
|
|
336
|
+
}, deadlineMs)
|
|
337
|
+
if (typeof deadlineTimer?.unref === 'function') deadlineTimer.unref()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
await Promise.race([Promise.allSettled(promises), deadlinePromise])
|
|
341
|
+
clearTimeout(deadlineTimer)
|
|
342
|
+
|
|
343
|
+
// Rebuild settled from what resolved so far. Background path uses the
|
|
344
|
+
// immediate-reject form; sync path races the live promise against the
|
|
345
|
+
// immediate-reject so a late winner can still land.
|
|
346
|
+
const settled = await Promise.allSettled(
|
|
347
|
+
promises.map((p, i) => {
|
|
348
|
+
if (escapedSettled[i] !== undefined) {
|
|
349
|
+
return escapedSettled[i].status === 'fulfilled'
|
|
350
|
+
? Promise.resolve(escapedSettled[i].value)
|
|
351
|
+
: Promise.reject(escapedSettled[i].reason)
|
|
352
|
+
}
|
|
353
|
+
const timeoutP = Promise.resolve(undefined).then(() => Promise.reject(new Error('bridge role timed out (deadline)')))
|
|
354
|
+
return isBackground ? timeoutP : Promise.race([p, timeoutP])
|
|
355
|
+
}),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
// Only genuinely fulfilled results count as 'ok' in the partial
|
|
359
|
+
// footer. Any rejection — timeout or otherwise — is not ok, since
|
|
360
|
+
// _appendPartialFooter renders completedCount as the "ok" tally.
|
|
361
|
+
const completedCount = settled.filter(r => r.status === 'fulfilled').length
|
|
362
|
+
const partialInfo = deadlineFired
|
|
363
|
+
? { completed: completedCount, total: queries.length, deadlineSecs: Math.round(deadlineMs / 1000) }
|
|
364
|
+
: null
|
|
365
|
+
|
|
366
|
+
if (parentSig && _parentAbortHandler) {
|
|
367
|
+
try { parentSig.removeEventListener('abort', _parentAbortHandler) } catch {}
|
|
368
|
+
}
|
|
369
|
+
if (requestSig && _requestAbortHandler) {
|
|
370
|
+
try { requestSig.removeEventListener('abort', _requestAbortHandler) } catch {}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { settled, partialInfo, hardErrorEscalated }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function _pruneDispatchResults() {
|
|
377
|
+
if (_dispatchResults.size < DISPATCH_RESULT_MAX_ENTRIES) return
|
|
378
|
+
const now = Date.now()
|
|
379
|
+
for (const [id, entry] of _dispatchResults) {
|
|
380
|
+
const age = now - (entry.completedAt || entry.createdAt || now)
|
|
381
|
+
if (entry.status !== 'running' && age > DISPATCH_RESULT_TTL_MS) _dispatchResults.delete(id)
|
|
382
|
+
}
|
|
383
|
+
if (_dispatchResults.size >= DISPATCH_RESULT_MAX_ENTRIES) {
|
|
384
|
+
// Still full — evict the oldest regardless of status.
|
|
385
|
+
const oldest = _dispatchResults.keys().next().value
|
|
386
|
+
if (oldest) _dispatchResults.delete(oldest)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export async function dispatchAiWrapped(name, args, ctx) {
|
|
391
|
+
let rawQuery = args.query
|
|
392
|
+
// MCP schema-less query field: some clients JSON-stringify arrays when the
|
|
393
|
+
// inputSchema does not declare an explicit `type`. Parse `'["a","b"]'` back
|
|
394
|
+
// into a real array so fan-out works whether the caller passed an array
|
|
395
|
+
// literal or a stringified one. Plain strings (no leading `[`) pass through.
|
|
396
|
+
if (typeof rawQuery === 'string') {
|
|
397
|
+
const trimmed = rawQuery.trim()
|
|
398
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
399
|
+
try {
|
|
400
|
+
const parsed = JSON.parse(trimmed)
|
|
401
|
+
if (Array.isArray(parsed)) rawQuery = parsed
|
|
402
|
+
} catch { /* not valid JSON array — keep as plain string */ }
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (rawQuery == null) return fail('query is required')
|
|
406
|
+
let queries = Array.isArray(rawQuery) ? rawQuery.slice() : [rawQuery]
|
|
407
|
+
if (queries.length === 0) return fail('query cannot be empty')
|
|
408
|
+
// R15: bound LLM-dispatch fan-out at the call boundary. Truncating here
|
|
409
|
+
// (before either sync or background path forks) guarantees one call cannot
|
|
410
|
+
// spawn more than MAX_FANOUT_QUERIES hidden-role sub-sessions via a
|
|
411
|
+
// hostile/poisoned `query:[...]`. The capped count is surfaced to the
|
|
412
|
+
// caller via the merged-answer notice below.
|
|
413
|
+
let _fanoutCapNotice = ''
|
|
414
|
+
if (queries.length > MAX_FANOUT_QUERIES) {
|
|
415
|
+
_fanoutCapNotice = `[capped ${queries.length}->${MAX_FANOUT_QUERIES} queries]`
|
|
416
|
+
queries = queries.slice(0, MAX_FANOUT_QUERIES)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const spec = ROLE_BY_TOOL[name]
|
|
420
|
+
if (!spec) throw new Error(`Unknown aiWrapped tool: ${name}`)
|
|
421
|
+
|
|
422
|
+
// recall / search are handled directly in server-main.mjs
|
|
423
|
+
// _dispatchToolImpl — they no longer reach this dispatcher at all.
|
|
424
|
+
// Only `explore` (LLM-routed hidden role) flows through here.
|
|
425
|
+
|
|
426
|
+
// Recursion break — the tool schema stays full across every session so
|
|
427
|
+
// that all roles share one cache shard. The counterweight lives here:
|
|
428
|
+
// when a hidden-role session (explorer / cycle1 / cycle2) calls back
|
|
429
|
+
// into an aiWrapped dispatcher, we reject the call at runtime. Without
|
|
430
|
+
// this, `explore` inside an explorer turn would spawn another explorer
|
|
431
|
+
// session and fan out forever.
|
|
432
|
+
if (ctx?.callerSessionId) {
|
|
433
|
+
try {
|
|
434
|
+
const { loadSession } = await import('./session/store.mjs')
|
|
435
|
+
const { isHiddenRole } = await import('./internal-roles.mjs')
|
|
436
|
+
const caller = loadSession(ctx.callerSessionId)
|
|
437
|
+
if (!caller) {
|
|
438
|
+
return fail(
|
|
439
|
+
`"${name}" blocked: caller session "${ctx.callerSessionId}" not found — recursion guard fails closed.`,
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
if (isHiddenRole(caller.role)) {
|
|
443
|
+
return fail(
|
|
444
|
+
`"${name}" is blocked inside the "${caller.role}" hidden role (recursion break). `
|
|
445
|
+
+ `Use direct read / code_graph (mode:search for a symbol name) / grep / glob for your query.`,
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
} catch (e) {
|
|
449
|
+
return fail(
|
|
450
|
+
`"${name}" blocked: recursion guard introspection failed (${e?.message || e}). Fail-closed for safety.`,
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const { makeBridgeLlm } = await import('./smart-bridge/bridge-llm.mjs')
|
|
456
|
+
const bridgeConfig = loadConfig()
|
|
457
|
+
|
|
458
|
+
// `brief` (default true) applies a ~3000-token cap to each bridge role
|
|
459
|
+
// answer before it rides back into the Lead context. Pass `brief:false`
|
|
460
|
+
// when the caller explicitly wants the uncapped synthesis. See
|
|
461
|
+
// bridge-llm.mjs::applyBriefCap for the cap shape.
|
|
462
|
+
const brief = args.brief !== false;
|
|
463
|
+
const hasExplicitCwdArg = typeof args.cwd === 'string' && args.cwd.trim()
|
|
464
|
+
const cwdInput = hasExplicitCwdArg
|
|
465
|
+
? args.cwd
|
|
466
|
+
: ctx?.callerCwd
|
|
467
|
+
// Only `explore` reaches this dispatcher (ROLE_BY_TOOL registers it alone;
|
|
468
|
+
// any other tool name throws at the spec lookup above).
|
|
469
|
+
let resolvedCwd
|
|
470
|
+
try {
|
|
471
|
+
resolvedCwd = resolveExploreCwd(cwdInput, ctx?.callerCwd, Boolean(hasExplicitCwdArg))
|
|
472
|
+
} catch (e) {
|
|
473
|
+
// Fail-loud: explicit cwd resolved outside callerCwd and does not exist
|
|
474
|
+
// (no silent rebase to callerCwd). The sole call site can surface the
|
|
475
|
+
// mistake to Lead directly instead of running explore against the
|
|
476
|
+
// wrong tree.
|
|
477
|
+
return fail(errText(e))
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Hard-block broad cwds for explore before spawning any bridge roles.
|
|
481
|
+
// V8 string-limit risk: scanning home / ~/.claude / fs-root can blow the
|
|
482
|
+
// mcp server process. Fail fast here so neither the sync nor background
|
|
483
|
+
// path ever launches a bridge role against a dangerous root.
|
|
484
|
+
const _earlyBroadErr = await checkBroadCwdBlock(resolvedCwd, hasExplicitCwdArg ? args.cwd : '')
|
|
485
|
+
if (_earlyBroadErr) return fail(_earlyBroadErr)
|
|
486
|
+
|
|
487
|
+
// Sync by default — the merged bridge role answer lands in-turn as the MCP
|
|
488
|
+
// tool response, no channel round-trip, no turn fragmentation. Opt into
|
|
489
|
+
// background=true for heavy multi-angle queries that risk exceeding the
|
|
490
|
+
// 120s harness-owned MCP request ceiling (the harness severs the request at
|
|
491
|
+
// that fixed limit); in that case a handle is returned immediately and the
|
|
492
|
+
// merged answer is pushed via the channel bridge when ready.
|
|
493
|
+
const background = typeof args.background === 'boolean'
|
|
494
|
+
? args.background
|
|
495
|
+
: false
|
|
496
|
+
|
|
497
|
+
if (!background) {
|
|
498
|
+
// Sync fan-out: shared controller/deadline/cache/merge pipeline runs the
|
|
499
|
+
// bridge roles; the merged answer lands in-turn.
|
|
500
|
+
//
|
|
501
|
+
// Crash-safety net: register a recoverable handle BEFORE running and persist
|
|
502
|
+
// the merged body on the completion path BEFORE removing the pending entry,
|
|
503
|
+
// so a transport tear-down landing between persist and remove can't silently
|
|
504
|
+
// lose a finished answer (recoverPending replays it on next boot). NOTE: a
|
|
505
|
+
// user cancellation (requestSignal abort, detected below) is the deliberate
|
|
506
|
+
// exception — it drops the pending entry WITHOUT replay, because a cancelled
|
|
507
|
+
// dispatch must not resurface later.
|
|
508
|
+
const _dataDir = process.env.CLAUDE_PLUGIN_DATA
|
|
509
|
+
const handle = `dispatch_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
510
|
+
try { addPending(_dataDir, handle, name, queries, ctx?.routingSessionId, ctx?.clientHostPid) } catch {}
|
|
511
|
+
|
|
512
|
+
// Cancellation detection. ctx.requestSignal is the forwarded extra.signal
|
|
513
|
+
// (server-main aiWrapped route) — it fires on an MCP `notifications/cancelled`
|
|
514
|
+
// for THIS request, i.e. the user pressing ESC (or a harness transport
|
|
515
|
+
// sever). _runFanout now wires this same signal into the explorer sub
|
|
516
|
+
// controllers, so when it fires the fan-out is actively aborted rather than
|
|
517
|
+
// run to completion. We also capture it here as `_severed` so the exits
|
|
518
|
+
// below drop the pending entry and return in-turn WITHOUT resurrecting a
|
|
519
|
+
// cancelled result through the dispatch_result channel. The abort may land
|
|
520
|
+
// mid-fan-out, so register a listener in addition to the synchronous check.
|
|
521
|
+
const _sig = ctx?.requestSignal
|
|
522
|
+
let _severed = false
|
|
523
|
+
const _onSeverAbort = () => { _severed = true }
|
|
524
|
+
if (_sig) {
|
|
525
|
+
if (_sig.aborted) _severed = true
|
|
526
|
+
else _sig.addEventListener('abort', _onSeverAbort, { once: true })
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
const { settled, partialInfo, hardErrorEscalated: _hardErrorEscalated } = await _runFanout({
|
|
531
|
+
queries, name, resolvedCwd, brief, spec, ctx, makeBridgeLlm,
|
|
532
|
+
bridgeConfig,
|
|
533
|
+
isBackground: false,
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
// Cancellation exit (ESC / transport sever): requestSignal fired and
|
|
537
|
+
// _runFanout's requestSignal→subController link already aborted the
|
|
538
|
+
// explorer subs. A cancelled dispatch must NOT persist for replay or
|
|
539
|
+
// resurface later through the channel — drop the pending entry and return
|
|
540
|
+
// in-turn (the return is harmlessly discarded if the transport is dead).
|
|
541
|
+
if (_severed) {
|
|
542
|
+
try { removePending(_dataDir, handle) } catch {}
|
|
543
|
+
return ok('[explore cancelled by user]')
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Only `explore` reaches this dispatcher (ROLE_BY_TOOL registers it alone;
|
|
547
|
+
// any other tool name throws at the spec lookup above).
|
|
548
|
+
const merged = mergeExploreSettled(settled, queries, spec.label, partialInfo)
|
|
549
|
+
|
|
550
|
+
// All-failed detection: every entry rejected. Surface as MCP isError so
|
|
551
|
+
// caller doesn't merge the failures back into context as if they were
|
|
552
|
+
// normal results.
|
|
553
|
+
const allFailed = settled.every(r => r.status === 'rejected')
|
|
554
|
+
|
|
555
|
+
// Persist the finished body (success OR all-failed) BEFORE acking — mirror
|
|
556
|
+
// the background path's persist→ack→remove ordering. Await the disk tail so
|
|
557
|
+
// a tear-down between persist and remove cannot lose the body. Best-effort:
|
|
558
|
+
// a persist failure must not block the in-turn answer.
|
|
559
|
+
const _handleLine = `[explore handle: ${handle}]`
|
|
560
|
+
const _mergedWithHandle = `${_handleLine}\n${merged}`
|
|
561
|
+
// Align the PERSISTED body with the RETURNED body: fold in the fan-out cap
|
|
562
|
+
// notice when present so a restart replay through recoverPending carries
|
|
563
|
+
// the same `[capped N->M queries]` notice the in-turn return would have
|
|
564
|
+
// shown — not a silently truncated body.
|
|
565
|
+
const _syncMerged = _fanoutCapNotice ? `${_fanoutCapNotice}\n${_mergedWithHandle}` : _mergedWithHandle
|
|
566
|
+
const _syncMergedCapped = capDispatchRetrievalBody(_syncMerged).text
|
|
567
|
+
const _syncPersistTail = () => Promise.resolve(
|
|
568
|
+
setPendingResult(_dataDir, handle, name, queries, _syncMerged, !!allFailed, ctx?.routingSessionId, ctx?.clientHostPid),
|
|
569
|
+
).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] sync persist failed: ${e?.message ?? e}\n`); } catch {} })
|
|
570
|
+
const _okCount = settled.filter(r => r.status === 'fulfilled').length
|
|
571
|
+
const _hardAllFailed = _hardErrorEscalated && !allFailed && _okCount === 0
|
|
572
|
+
const _awaitSyncPersist = allFailed || _hardAllFailed
|
|
573
|
+
if (_awaitSyncPersist) {
|
|
574
|
+
try { await _syncPersistTail() } catch (e) { try { process.stderr.write(`[ai-wrapped-dispatch] sync persist failed: ${e?.message ?? e}\n`); } catch {} }
|
|
575
|
+
} else {
|
|
576
|
+
_syncPersistTail()
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Hard-error escalation: any hard sub error and not all already covered.
|
|
580
|
+
if (_hardErrorEscalated && !allFailed) {
|
|
581
|
+
const hardFailed = settled.filter(r => r.status === 'rejected' && isHardSubError(r.reason)).length
|
|
582
|
+
if (_okCount === 0) {
|
|
583
|
+
// All-hard-error exit. A late cancellation (abort landing during the
|
|
584
|
+
// sync merge) is handled the same as a normal exit: drop the pending
|
|
585
|
+
// entry and return in-turn — never channel-push a cancelled result.
|
|
586
|
+
try { removePending(_dataDir, handle) } catch {}
|
|
587
|
+
return fail(_syncMergedCapped)
|
|
588
|
+
}
|
|
589
|
+
// Partial-error: some completed, annotate but don't fail the whole call.
|
|
590
|
+
process.stderr.write(`[ai-wrapped-dispatch] partial-error: ${hardFailed} hard errors, ${_okCount} ok — escalated\n`)
|
|
591
|
+
}
|
|
592
|
+
if (allFailed) {
|
|
593
|
+
// All-failed exit — drop the pending entry and return in-turn.
|
|
594
|
+
try { removePending(_dataDir, handle) } catch {}
|
|
595
|
+
return fail(_syncMergedCapped)
|
|
596
|
+
}
|
|
597
|
+
// Normal success exit — pop the finalized pending entry and return in-turn.
|
|
598
|
+
try { removePending(_dataDir, handle) } catch {}
|
|
599
|
+
return ok(_syncMergedCapped)
|
|
600
|
+
} finally {
|
|
601
|
+
if (_sig) {
|
|
602
|
+
try { _sig.removeEventListener('abort', _onSeverAbort) } catch {}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Background dispatch path. The caller (Lead) gets an immediate handle;
|
|
608
|
+
// bridge roles stream in the background and the merged answer is pushed
|
|
609
|
+
// via the channel notification bridge.
|
|
610
|
+
//
|
|
611
|
+
// R15: bound background concurrency. Without this, a flood of background
|
|
612
|
+
// dispatches (one per `background:true` call) accumulates indefinitely —
|
|
613
|
+
// _pruneDispatchResults only evicts finished results, not admission. Reject
|
|
614
|
+
// new background dispatches when the in-flight count is at the cap so the
|
|
615
|
+
// model/caller cannot exhaust the plugin server via prompt-injection abuse.
|
|
616
|
+
if (_activeBgDispatches >= MAX_ACTIVE_BG_DISPATCHES) {
|
|
617
|
+
return fail(`dispatch capacity exceeded - retry shortly (active=${_activeBgDispatches}/${MAX_ACTIVE_BG_DISPATCHES})`)
|
|
618
|
+
}
|
|
619
|
+
_activeBgDispatches += 1
|
|
620
|
+
_pruneDispatchResults()
|
|
621
|
+
const id = `dispatch_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
622
|
+
_dispatchResults.set(id, {
|
|
623
|
+
status: 'running',
|
|
624
|
+
tool: name,
|
|
625
|
+
role: spec.role,
|
|
626
|
+
queries,
|
|
627
|
+
createdAt: Date.now(),
|
|
628
|
+
})
|
|
629
|
+
// Persist so a plugin restart mid-dispatch can emit a single Aborted
|
|
630
|
+
// notification on next bootstrap instead of silently orphaning the handle.
|
|
631
|
+
addPending(process.env.CLAUDE_PLUGIN_DATA, id, name, queries, ctx?.routingSessionId, ctx?.clientHostPid)
|
|
632
|
+
// Starting a bridge dispatch counts as session activity — keeps
|
|
633
|
+
// background tasks suppressed while long-running work is in flight.
|
|
634
|
+
notifyActivity()
|
|
635
|
+
// (start banner removed — notifyFn takes no opts so silent_to_agent is
|
|
636
|
+
// not available; pushing "X started" to the channel is channel noise.
|
|
637
|
+
// The merged result arrives later via pushDispatchResult.)
|
|
638
|
+
// Wire caller abort: when the caller session aborts (ESC, new prompt),
|
|
639
|
+
// mark the dispatch handle cancelled so a later result push doesn't echo
|
|
640
|
+
// a stale answer back to a session that already moved on. Best-effort:
|
|
641
|
+
// background bridge roles continue running on the bridge, but their result
|
|
642
|
+
// is suppressed at push time.
|
|
643
|
+
let _callerAborted = false;
|
|
644
|
+
try {
|
|
645
|
+
if (ctx?.callerSessionId) {
|
|
646
|
+
import('./session/abort-lookup.mjs').then(({ getAbortSignalForSession }) => {
|
|
647
|
+
Promise.resolve(getAbortSignalForSession(ctx.callerSessionId)).then((sig) => {
|
|
648
|
+
if (!sig) return;
|
|
649
|
+
if (sig.aborted) { _callerAborted = true; return; }
|
|
650
|
+
sig.addEventListener('abort', () => {
|
|
651
|
+
_callerAborted = true;
|
|
652
|
+
const entry = _dispatchResults.get(id);
|
|
653
|
+
if (entry && entry.status === 'running') {
|
|
654
|
+
entry.status = 'cancelled';
|
|
655
|
+
entry.completedAt = Date.now();
|
|
656
|
+
}
|
|
657
|
+
}, { once: true });
|
|
658
|
+
}).catch(() => {});
|
|
659
|
+
}).catch(() => {});
|
|
660
|
+
}
|
|
661
|
+
} catch {}
|
|
662
|
+
// Background fan-out with parent abort cascade + deadline.
|
|
663
|
+
;(async () => {
|
|
664
|
+
// Shared fan-out pipeline. The bg-only hook flips the dispatch-registry
|
|
665
|
+
// entry to 'partial-error' on the first hard sub error.
|
|
666
|
+
const { settled, partialInfo: bgPartialInfo } = await _runFanout({
|
|
667
|
+
queries, name, resolvedCwd, brief, spec, ctx, makeBridgeLlm,
|
|
668
|
+
bridgeConfig,
|
|
669
|
+
isBackground: true,
|
|
670
|
+
onHardError: () => {
|
|
671
|
+
const bgEntry = _dispatchResults.get(id)
|
|
672
|
+
if (bgEntry && bgEntry.status === 'running') bgEntry.status = 'partial-error'
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
// Only `explore` reaches this dispatcher (ROLE_BY_TOOL registers it alone;
|
|
677
|
+
// any other tool name throws at the spec lookup above).
|
|
678
|
+
const merged = mergeExploreSettled(settled, queries, spec.label, bgPartialInfo)
|
|
679
|
+
_pruneDispatchResults()
|
|
680
|
+
const entry = _dispatchResults.get(id)
|
|
681
|
+
const allFailed = settled.every(r => r.status === 'rejected')
|
|
682
|
+
if (entry) {
|
|
683
|
+
// Preserve partial-error stamped by onHardError; completion must not
|
|
684
|
+
// overwrite that signal when at least one sub failed hard but others
|
|
685
|
+
// succeeded. allFailed still escalates to 'error'.
|
|
686
|
+
entry.status = allFailed
|
|
687
|
+
? 'error'
|
|
688
|
+
: (entry.status === 'partial-error' ? 'partial-error' : 'done')
|
|
689
|
+
entry.isError = allFailed
|
|
690
|
+
entry.content = merged
|
|
691
|
+
entry.completedAt = Date.now()
|
|
692
|
+
}
|
|
693
|
+
_activeBgDispatches = Math.max(0, _activeBgDispatches - 1)
|
|
694
|
+
if (_callerAborted) {
|
|
695
|
+
// Caller already moved on; suppress notification but persist the
|
|
696
|
+
// completed body before removing so recoverPending on next boot
|
|
697
|
+
// re-delivers the actual answer rather than the Aborted boilerplate
|
|
698
|
+
// (matches the persist→notify→remove order used by pushDispatchResult).
|
|
699
|
+
const _dataDir = process.env.CLAUDE_PLUGIN_DATA
|
|
700
|
+
if (_dataDir) {
|
|
701
|
+
import('./dispatch-persist.mjs').then(({ setPendingResult, removePending: _rm }) =>
|
|
702
|
+
Promise.resolve(setPendingResult(_dataDir, id, name, queries, merged, !!allFailed, ctx?.routingSessionId, ctx?.clientHostPid))
|
|
703
|
+
.then(() => _rm(_dataDir, id)),
|
|
704
|
+
).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] caller-aborted persist failed: ${e?.message ?? e}\n`); } catch {} })
|
|
705
|
+
}
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
const _bgMerged = _fanoutCapNotice ? `${_fanoutCapNotice}\n${merged}` : merged
|
|
709
|
+
// pushDispatchResult owns the persist→notify→remove sequence (see
|
|
710
|
+
// setPendingResult / removePending in dispatch-persist.mjs); do NOT
|
|
711
|
+
// pre-remove the pending entry here — that would race the persist and
|
|
712
|
+
// a crash between addPending and notify could lose the result.
|
|
713
|
+
pushDispatchResult(ctx, id, name, queries, _bgMerged, { error: allFailed })
|
|
714
|
+
})().catch((err) => {
|
|
715
|
+
const msg = errText(err)
|
|
716
|
+
_pruneDispatchResults()
|
|
717
|
+
const entry = _dispatchResults.get(id)
|
|
718
|
+
if (entry) {
|
|
719
|
+
entry.status = 'error'
|
|
720
|
+
entry.error = msg
|
|
721
|
+
entry.completedAt = Date.now()
|
|
722
|
+
}
|
|
723
|
+
_activeBgDispatches = Math.max(0, _activeBgDispatches - 1)
|
|
724
|
+
// Same persist+notify-before-remove invariant as the success path —
|
|
725
|
+
// pushDispatchResult drives removal after the error body is persisted
|
|
726
|
+
// and notified.
|
|
727
|
+
pushDispatchResult(ctx, id, name, queries, `[${spec.label} dispatch error] ${msg}`, { error: true })
|
|
728
|
+
})
|
|
729
|
+
const queryCount = queries.length === 1 ? `1 query` : `${queries.length} queries`
|
|
730
|
+
const _startNotice = _fanoutCapNotice ? `${_fanoutCapNotice} ` : ''
|
|
731
|
+
return ok(`${_startNotice}${name} started — ${queryCount}. Merged answer will be auto-pushed via the channel (handle ${id}).`)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
function _escapeXml(str) {
|
|
736
|
+
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function buildExplorerPrompt(query, cwd) {
|
|
740
|
+
// Full isolation: each explorer receives ONLY its own <query>. Peer queries
|
|
741
|
+
// are never injected, so a weaker model cannot bleed into or re-answer sibling
|
|
742
|
+
// topics — it never sees them. The descriptive-only contract lives at
|
|
743
|
+
// system level (rules/bridge/30-explorer.md via collect.mjs): brief-level
|
|
744
|
+
// constraints alone proved insufficient — haiku still rendered verdicts on
|
|
745
|
+
// evaluative queries. The trailing one-liner is reinforcement only; small
|
|
746
|
+
// models weight the instruction after the query over the query body.
|
|
747
|
+
return `<query>${_escapeXml(query)}</query>\nReminder: describe with file:line evidence; no verdicts, ratings, or recommendations.`
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Resolve user-provided cwd: expand `~`, resolve relatives against the
|
|
752
|
+
* launch workspace. Falls back to null so callers use process.cwd().
|
|
753
|
+
*/
|
|
754
|
+
function resolveCwd(input, baseCwd = process.cwd()) {
|
|
755
|
+
if (!input || typeof input !== 'string') return null
|
|
756
|
+
const trimmed = input.trim()
|
|
757
|
+
if (!trimmed) return null
|
|
758
|
+
const expanded = trimmed.startsWith('~')
|
|
759
|
+
? trimmed.replace(/^~/, homedir())
|
|
760
|
+
: trimmed
|
|
761
|
+
const base = (typeof baseCwd === 'string' && baseCwd) ? baseCwd : process.cwd()
|
|
762
|
+
return isAbsolute(expanded) ? expanded : resolvePath(base, expanded)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function resolveExploreCwd(input, callerCwd, hasExplicitCwdArg = false) {
|
|
766
|
+
const base = resolveCwd(callerCwd, process.cwd())
|
|
767
|
+
const resolved = resolveCwd(input, base || process.cwd())
|
|
768
|
+
// UNC / SMB reject: exploring a network share root auto-authenticates to
|
|
769
|
+
// the remote host and leaks the current user's NTLM hash. Reject the
|
|
770
|
+
// resolved cwd before any bridge role can walk it. Mirrors the read/list
|
|
771
|
+
// path's UNC guard.
|
|
772
|
+
if (typeof isUncPath === 'function' && isUncPath(resolved)) {
|
|
773
|
+
throw new Error(
|
|
774
|
+
`explore cwd "${input}" resolves to a UNC / SMB path ("${resolved}") — network credential leak risk; pass a local absolute path`,
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
if (!hasExplicitCwdArg || !base || !resolved) return resolved
|
|
778
|
+
// Explicit cwd: must point to a real directory regardless of whether it
|
|
779
|
+
// sits under callerCwd. An under-callerCwd path that doesn't exist is
|
|
780
|
+
// still almost certainly a typo (e.g. cwd:'missing'); a path outside
|
|
781
|
+
// callerCwd that does exist is the deliberate redirect branch
|
|
782
|
+
// (Lead exploring a sibling tree, plugin source, etc.) — both are
|
|
783
|
+
// accepted only when the directory actually exists.
|
|
784
|
+
if (_cwdIsExistingDir(resolved)) return resolved
|
|
785
|
+
// Fail-loud rather than silently rebasing to callerCwd: a non-existent
|
|
786
|
+
// explicit cwd is almost always a typo and silent rebase would run the
|
|
787
|
+
// explore against the wrong tree without warning. The single caller in
|
|
788
|
+
// dispatchAiWrapped catches this and returns it through fail().
|
|
789
|
+
throw new Error(
|
|
790
|
+
`explore cwd "${input}" does not exist (resolved to "${resolved}") — pass a valid absolute path or omit cwd to use the launch workspace`,
|
|
791
|
+
)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function _cwdIsExistingDir(p) {
|
|
795
|
+
if (!p || typeof p !== 'string') return false
|
|
796
|
+
let st
|
|
797
|
+
try { st = statSync(p) } catch { return false }
|
|
798
|
+
return Boolean(st && st.isDirectory())
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isPathInside(baseCwd, targetCwd) {
|
|
802
|
+
if (!baseCwd || !targetCwd) return false
|
|
803
|
+
const rel = relative(resolvePath(baseCwd), resolvePath(targetCwd))
|
|
804
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Resolve a short model tag for the given hidden role, mirroring the
|
|
809
|
+
* `modelTag` format that bridge/worker lifecycle notifications use in
|
|
810
|
+
* src/agent/index.mjs (e.g. `3-5-sonnet`). Best-effort — returns an
|
|
811
|
+
* empty string when the preset / config can't be resolved so the header
|
|
812
|
+
* still renders (falls back to `[{tool}] Done.`).
|
|
813
|
+
*/
|
|
814
|
+
export function resolveAgentModelTag(role) {
|
|
815
|
+
try {
|
|
816
|
+
const presetName = resolvePresetName({ role })
|
|
817
|
+
if (!presetName) return ''
|
|
818
|
+
const config = loadConfig()
|
|
819
|
+
const preset = config?.presets?.find((p) => p.id === presetName || p.name === presetName)
|
|
820
|
+
const raw = preset?.model
|
|
821
|
+
if (!raw || typeof raw !== 'string') return ''
|
|
822
|
+
const stripped = raw.startsWith('claude-') ? raw.slice('claude-'.length) : raw
|
|
823
|
+
return stripped || ''
|
|
824
|
+
} catch {
|
|
825
|
+
return ''
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Build the `Done.` header that wraps async-result notifications, mirroring
|
|
831
|
+
* the Pool B worker completion shape emitted in src/agent/index.mjs:
|
|
832
|
+
* [{model-tag}] [{role}] <content>
|
|
833
|
+
* Dispatch re-uses the same pattern so the user sees a consistent
|
|
834
|
+
* `Done.` header across bridge worker output and recall/search/explore
|
|
835
|
+
* dispatch result delivery.
|
|
836
|
+
*
|
|
837
|
+
* When the model tag can't be resolved, falls back to `[{tool}] Done.`.
|
|
838
|
+
* When the tool is empty (shouldn't happen), falls back to `Done.`.
|
|
839
|
+
*/
|
|
840
|
+
export function buildDispatchResultHeader(tool, modelTag) {
|
|
841
|
+
const toolPart = tool ? `[${tool}] ` : ''
|
|
842
|
+
const tagPart = modelTag ? `[${modelTag}] ` : ''
|
|
843
|
+
return `${tagPart}${toolPart}Done.`
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function capDispatchRetrievalBody(body) {
|
|
847
|
+
let bodyStr = typeof body === 'string' ? body : String(body ?? '')
|
|
848
|
+
bodyStr = stripSoftWarns(bodyStr)
|
|
849
|
+
bodyStr = stripAnsi(bodyStr)
|
|
850
|
+
bodyStr = normalizeWhitespace(bodyStr)
|
|
851
|
+
bodyStr = dedupRepeatedLines(bodyStr)
|
|
852
|
+
const bodyBytes = Buffer.byteLength(bodyStr, 'utf8')
|
|
853
|
+
let bodyLines = bodyStr.length === 0 ? 0 : 1
|
|
854
|
+
for (let i = 0; i < bodyStr.length; i += 1) {
|
|
855
|
+
if (bodyStr.charCodeAt(i) === 10) bodyLines += 1
|
|
856
|
+
}
|
|
857
|
+
const { text, truncated } = smartReadTruncate(bodyStr, bodyLines, bodyBytes)
|
|
858
|
+
return { text, truncated }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Spool the full, untruncated dispatch body to disk when the notify body was
|
|
862
|
+
// smart-truncated, mirroring _saveLeadDirectFullOutput in server-main.mjs.
|
|
863
|
+
// Best-effort: a spool failure must never break the notify path, so the caller
|
|
864
|
+
// only appends the pointer line when this returns a path.
|
|
865
|
+
function saveDispatchFullOutput(dataDir, id, rawBody) {
|
|
866
|
+
try {
|
|
867
|
+
const dir = resolvePath(dataDir, 'tool-results', 'dispatch')
|
|
868
|
+
mkdirSync(dir, { recursive: true })
|
|
869
|
+
const safeId = String(id || 'dispatch').replace(/[^A-Za-z0-9_-]+/g, '_').slice(0, 80) || 'dispatch'
|
|
870
|
+
const file = resolvePath(dir, `${safeId}.txt`)
|
|
871
|
+
writeFileSync(file, rawBody, 'utf8')
|
|
872
|
+
return file
|
|
873
|
+
} catch {
|
|
874
|
+
return null
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Age-based GC for non-per-session tool-results subdirs (`lead-direct/`,
|
|
879
|
+
// `dispatch/`): these accumulate one file per output and are never reopened,
|
|
880
|
+
// so an mtime TTL is the only reliable cleanup signal. Runs once per process
|
|
881
|
+
// boot (see invocation below), mirroring the stale-log sweep in
|
|
882
|
+
// src/channels/index.mjs. 7-day TTL keeps recent forensics while bounding leak.
|
|
883
|
+
const _TOOL_RESULTS_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
884
|
+
function _gcToolResultsOnce() {
|
|
885
|
+
const dataDir = process.env.CLAUDE_PLUGIN_DATA
|
|
886
|
+
if (!dataDir) return
|
|
887
|
+
const now = Date.now()
|
|
888
|
+
for (const sub of ['lead-direct', 'dispatch']) {
|
|
889
|
+
try {
|
|
890
|
+
const dir = resolvePath(dataDir, 'tool-results', sub)
|
|
891
|
+
for (const f of readdirSync(dir)) {
|
|
892
|
+
const p = resolvePath(dir, f)
|
|
893
|
+
try { if (now - statSync(p).mtimeMs > _TOOL_RESULTS_TTL_MS) unlinkSync(p) } catch {}
|
|
894
|
+
}
|
|
895
|
+
} catch {}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
try { _gcToolResultsOnce() } catch {}
|
|
899
|
+
|
|
900
|
+
export function pushDispatchResult(ctx, id, tool, queries, body, flags = {}) {
|
|
901
|
+
const notify = ctx?.notifyFn
|
|
902
|
+
if (typeof notify !== 'function') {
|
|
903
|
+
// notifyFn absent means the background result has nowhere to go — the
|
|
904
|
+
// promise would silently vanish. Write a visible stderr line so the
|
|
905
|
+
// operator can diagnose "auto-pushed" answers that never arrived, and
|
|
906
|
+
// return a structured marker so callers can detect the gap.
|
|
907
|
+
try {
|
|
908
|
+
process.stderr.write(`[ai-wrapped-dispatch] pushDispatchResult: no notifyFn — result lost tool=${tool} id=${id}\n`)
|
|
909
|
+
} catch {}
|
|
910
|
+
return { lost: true, tool, id, reason: 'no-notify-fn' }
|
|
911
|
+
}
|
|
912
|
+
const queryCount = queries.length === 1
|
|
913
|
+
? `1 query`
|
|
914
|
+
: `${queries.length} queries`
|
|
915
|
+
const bodyHeader = flags.error
|
|
916
|
+
? `${tool} failed`
|
|
917
|
+
: `${tool} — ${queryCount}`
|
|
918
|
+
// Smart truncation — large recall/search/explore merged bodies
|
|
919
|
+
// (multi-query fan-out) can blow past the 30 KB smart-read cap and waste
|
|
920
|
+
// Lead context. Apply the same head/tail summariser used by `read`
|
|
921
|
+
// (single + array form) so Lead still sees the interesting frames (first queries
|
|
922
|
+
// and final queries) without paying for the middle mass. Truncation acts
|
|
923
|
+
// on the body only — the `Done.` header is prepended AFTER, so it never
|
|
924
|
+
// gets cut.
|
|
925
|
+
const rawBody = typeof body === 'string' ? body : String(body ?? '')
|
|
926
|
+
const { text: cappedBody, truncated: bodyTruncated } = capDispatchRetrievalBody(body)
|
|
927
|
+
// When smart-truncation actually dropped content, spool the full raw body to
|
|
928
|
+
// tool-results/dispatch/<id>.txt and point the notify body at it so Lead can
|
|
929
|
+
// recover the complete output instead of reconstructing it by hand.
|
|
930
|
+
let notifyBody = `${bodyHeader}\n\n${cappedBody}`
|
|
931
|
+
if (bodyTruncated) {
|
|
932
|
+
const _dataDirSpool = process.env.CLAUDE_PLUGIN_DATA
|
|
933
|
+
if (_dataDirSpool && id) {
|
|
934
|
+
const _fullPath = saveDispatchFullOutput(_dataDirSpool, id, rawBody)
|
|
935
|
+
if (_fullPath) notifyBody += `\n[full output saved: ${_fullPath}]`
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const persistBody = `${bodyHeader}\n\n${rawBody}`
|
|
939
|
+
// Prepend a `Done.` wrapper that mirrors the Pool B worker
|
|
940
|
+
// completion header in src/agent/index.mjs (`${modelTag}[${role}] ...`).
|
|
941
|
+
// When the model tag can't be resolved, the helper falls back to
|
|
942
|
+
// `[{tool}] Done.` — still better than no header.
|
|
943
|
+
const spec = ROLE_BY_TOOL[tool]
|
|
944
|
+
const modelTag = spec ? resolveAgentModelTag(spec.role) : ''
|
|
945
|
+
const doneHeader = flags.error
|
|
946
|
+
? buildDispatchResultHeader(tool, modelTag).replace(/Done\.$/, 'Failed.')
|
|
947
|
+
: buildDispatchResultHeader(tool, modelTag)
|
|
948
|
+
const notifyContent = `${doneHeader}\n\n${notifyBody}`
|
|
949
|
+
const persistContent = `${doneHeader}\n\n${persistBody}`
|
|
950
|
+
// NOTE: duplicate notification on reconnect is acceptable — the goal is
|
|
951
|
+
// "no silent loss". Host-side dedup is not implemented; a reconnect-window
|
|
952
|
+
// duplicate surfaces the answer twice rather than not at all.
|
|
953
|
+
const _dataDir = process.env.CLAUDE_PLUGIN_DATA
|
|
954
|
+
const _qs = Array.isArray(queries) ? queries : [String(queries ?? '')]
|
|
955
|
+
// Persist the merged result BODY before notify so a torn-down transport
|
|
956
|
+
// can't lose the answer. recoverPending replays the persisted content on
|
|
957
|
+
// next boot instead of the generic Aborted boilerplate.
|
|
958
|
+
// setPendingResult enqueues a debounced/serialized write and returns the
|
|
959
|
+
// tail Promise that resolves AFTER the disk write completes. We must
|
|
960
|
+
// await that tail (not just the function return) before notify, so a
|
|
961
|
+
// crash between notify and disk-flush can't drop the answer — recoverPending
|
|
962
|
+
// replays from the persisted body, which has to actually be on disk first.
|
|
963
|
+
const _persistPromise = (_dataDir && id && tool)
|
|
964
|
+
? import('./dispatch-persist.mjs').then(({ setPendingResult }) => {
|
|
965
|
+
const tail = setPendingResult(_dataDir, id, tool, _qs, persistContent, !!flags.error, ctx?.routingSessionId, ctx?.clientHostPid)
|
|
966
|
+
// Defensive: older builds returned void. Coerce to a Promise so the
|
|
967
|
+
// chain still awaits something rather than resolving immediately.
|
|
968
|
+
return tail && typeof tail.then === 'function' ? tail : Promise.resolve()
|
|
969
|
+
}).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] setPendingResult failed: ${e?.message ?? e}\n`); } catch {} })
|
|
970
|
+
: Promise.resolve()
|
|
971
|
+
try {
|
|
972
|
+
_persistPromise.then(() => {
|
|
973
|
+
return Promise.resolve(
|
|
974
|
+
notify(notifyContent, {
|
|
975
|
+
type: 'dispatch_result',
|
|
976
|
+
dispatch_id: id,
|
|
977
|
+
tool,
|
|
978
|
+
// Daemon routing: deliver this result to the dispatching terminal
|
|
979
|
+
// via caller_session_id (owner-only in daemon; no session → drop).
|
|
980
|
+
caller_session_id: ctx?.routingSessionId,
|
|
981
|
+
...(typeof ctx?.clientHostPid === 'number' && ctx.clientHostPid > 0
|
|
982
|
+
? { client_host_pid: String(ctx.clientHostPid) }
|
|
983
|
+
: {}),
|
|
984
|
+
instruction: `The ${tool} dispatch you started earlier (${id}) has returned — use this answer in your next step.`,
|
|
985
|
+
}),
|
|
986
|
+
)
|
|
987
|
+
}).then(() => {
|
|
988
|
+
// Notify resolved successfully — safe to remove the pending entry.
|
|
989
|
+
if (_dataDir && id) {
|
|
990
|
+
import('./dispatch-persist.mjs').then(({ removePending }) => removePending(_dataDir, id)).catch((e) => { try { process.stderr.write(`[ai-wrapped-dispatch] removePending failed: ${e?.message ?? e}\n`); } catch {} })
|
|
991
|
+
}
|
|
992
|
+
}).catch((err) => {
|
|
993
|
+
try {
|
|
994
|
+
process.stderr.write(`[ai-wrapped-dispatch] pushDispatchResult async failed: tool=${tool} id=${id} err=${err?.message ?? String(err)} — leaving in pending for recoverPending\n`)
|
|
995
|
+
} catch {}
|
|
996
|
+
// Leave the pending entry — recoverPending will retry on next boot.
|
|
997
|
+
})
|
|
998
|
+
} catch (err) {
|
|
999
|
+
try { process.stderr.write(`[ai-wrapped-dispatch] pushDispatchResult failed: tool=${tool} id=${id} err=${err?.message ?? String(err)}\n`); } catch {}
|
|
1000
|
+
// Sync-throw safety net: _persistPromise already fired above (best-effort).
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function ok(text) {
|
|
1005
|
+
return { content: [{ type: 'text', text }] }
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function fail(msg) {
|
|
1009
|
+
return { content: [{ type: 'text', text: `[aiWrapped error] ${msg}` }], isError: true }
|
|
1010
|
+
}
|