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,1975 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { randomBytes, createHash } from 'crypto';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { join, resolve as pathResolve } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import { getProvider, providerInputExcludesCache } from '../providers/registry.mjs';
|
|
8
|
+
import { agentLoop } from './loop.mjs';
|
|
9
|
+
import { getMcpTools } from '../mcp/client.mjs';
|
|
10
|
+
import { getInternalTools, executeInternalTool } from '../internal-tools.mjs';
|
|
11
|
+
import { BUILTIN_TOOLS, executeBuiltinTool } from '../tools/builtin.mjs';
|
|
12
|
+
import { PATCH_TOOL_DEFS } from '../tools/patch-tool-defs.mjs';
|
|
13
|
+
import { CODE_GRAPH_TOOL_DEFS } from '../tools/code-graph-tool-defs.mjs';
|
|
14
|
+
import { executeCodeGraphTool } from '../tools/code-graph.mjs';
|
|
15
|
+
import { closeBashSession } from '../tools/bash-session.mjs';
|
|
16
|
+
import { collectSkillsCached, buildSkillToolDefs, loadAgentTemplate, loadRoleTemplate, composeSystemPrompt, collectProjectMd } from '../context/collect.mjs';
|
|
17
|
+
import { saveSession, saveSessionAsync, loadSession, deleteSession, listStoredSessions, getStoredSessionsRaw, sweepStaleSessions, markSessionClosed, publishHeartbeat, deleteHeartbeat, setLiveSession } from './store.mjs';
|
|
18
|
+
import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached, invalidatePrefetchCache } from './read-dedup.mjs';
|
|
19
|
+
import { clearOffloadSession } from './tool-result-offload.mjs';
|
|
20
|
+
import { classifyResultKind } from './result-classification.mjs';
|
|
21
|
+
import { createAbortController } from '../../../shared/abort-controller.mjs';
|
|
22
|
+
import { logLlmCall } from '../../../shared/llm/usage-log.mjs';
|
|
23
|
+
import { resolvePluginData, DEFAULT_PLUGIN, DEFAULT_MARKETPLACE } from '../../../shared/plugin-paths.mjs';
|
|
24
|
+
import { traceBridgeTool, appendBridgeTrace } from '../bridge-trace.mjs';
|
|
25
|
+
import { isHiddenRole } from '../internal-roles.mjs';
|
|
26
|
+
import { runWithCwdOverride, pwd } from '../../../shared/user-cwd.mjs';
|
|
27
|
+
import { maxMtimeRecursive } from '../cache-mtime.mjs';
|
|
28
|
+
// Phase B: Pool B Tier 2 content builder (common rules only).
|
|
29
|
+
// Loaded once per process via createRequire so the CJS module reaches us.
|
|
30
|
+
const _require = createRequire(import.meta.url);
|
|
31
|
+
const _rulesBuilder = (() => {
|
|
32
|
+
const candidates = [
|
|
33
|
+
process.env.CLAUDE_PLUGIN_ROOT && join(process.env.CLAUDE_PLUGIN_ROOT, 'lib', 'rules-builder.cjs'),
|
|
34
|
+
].filter(Boolean);
|
|
35
|
+
for (const p of candidates) {
|
|
36
|
+
try { return _require(p); } catch { /* fall through */ }
|
|
37
|
+
}
|
|
38
|
+
// Fallback: walk up from this file's location to find lib/rules-builder.cjs.
|
|
39
|
+
try { return _require('../../../../lib/rules-builder.cjs'); } catch { return null; }
|
|
40
|
+
})();
|
|
41
|
+
|
|
42
|
+
// bridgeRules is the bridge shared prefix (shared rules + bridge common rules +
|
|
43
|
+
// user agent configs). It's rebuilt from disk
|
|
44
|
+
// by rules-builder.cjs on every call; since createSession fires on every
|
|
45
|
+
// Pool B/C bridge turn, that's a lot of redundant readFileSync + concat.
|
|
46
|
+
// BP1/BP3 cache — invalidated by source file mtime, not a timer.
|
|
47
|
+
// Cheap: O(sentinel-count) stat calls on each bridge turn, no I/O otherwise.
|
|
48
|
+
// BP1 cache — single shared entry. buildBridgeInjectionContent is
|
|
49
|
+
// role-agnostic (true cross-role common), so every bridge role reuses the
|
|
50
|
+
// same prefix bytes.
|
|
51
|
+
let _bridgeRulesCache = null;
|
|
52
|
+
let _bridgeRulesMtime = 0;
|
|
53
|
+
function _buildBridgeRules() {
|
|
54
|
+
if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeInjectionContent !== 'function') return '';
|
|
55
|
+
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT
|
|
56
|
+
|| join(homedir(), '.claude', 'plugins', 'marketplaces', DEFAULT_MARKETPLACE, 'external_plugins', DEFAULT_PLUGIN);
|
|
57
|
+
const DATA_DIR = resolvePluginData();
|
|
58
|
+
const RULES_DIR = join(PLUGIN_ROOT, 'rules');
|
|
59
|
+
const mtime = maxMtimeRecursive([
|
|
60
|
+
join(RULES_DIR, 'shared'),
|
|
61
|
+
join(RULES_DIR, 'bridge'),
|
|
62
|
+
join(DATA_DIR, 'roles'),
|
|
63
|
+
join(DATA_DIR, 'mixdog-config.json'),
|
|
64
|
+
]);
|
|
65
|
+
if (_bridgeRulesCache !== null && mtime <= _bridgeRulesMtime) {
|
|
66
|
+
return _bridgeRulesCache;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const built = _rulesBuilder.buildBridgeInjectionContent({ PLUGIN_ROOT, DATA_DIR });
|
|
70
|
+
_bridgeRulesCache = built;
|
|
71
|
+
_bridgeRulesMtime = mtime;
|
|
72
|
+
return built;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
throw new Error(`[session] bridge common rules build failed: ${e.message}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// BP3 role-specific cache — keyed by role. webhook / schedule / hidden
|
|
79
|
+
// retrieval roles each have their own scoped instruction set; other roles
|
|
80
|
+
// return ''.
|
|
81
|
+
const _roleSpecificCache = new Map(); // role → { value, mtime }
|
|
82
|
+
function _buildRoleSpecific(currentRole) {
|
|
83
|
+
if (!_rulesBuilder || typeof _rulesBuilder.buildBridgeRoleSpecificContent !== 'function') return '';
|
|
84
|
+
if (!currentRole) return '';
|
|
85
|
+
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT
|
|
86
|
+
|| join(homedir(), '.claude', 'plugins', 'marketplaces', DEFAULT_MARKETPLACE, 'external_plugins', DEFAULT_PLUGIN);
|
|
87
|
+
const DATA_DIR = resolvePluginData();
|
|
88
|
+
const RULES_DIR = join(PLUGIN_ROOT, 'rules');
|
|
89
|
+
const mtime = maxMtimeRecursive([
|
|
90
|
+
join(RULES_DIR, 'shared'),
|
|
91
|
+
join(DATA_DIR, 'mixdog-config.json'),
|
|
92
|
+
join(DATA_DIR, 'webhooks'),
|
|
93
|
+
join(DATA_DIR, 'schedules'),
|
|
94
|
+
]);
|
|
95
|
+
const entry = _roleSpecificCache.get(currentRole);
|
|
96
|
+
if (entry && mtime <= entry.mtime) {
|
|
97
|
+
return entry.value;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const built = _rulesBuilder.buildBridgeRoleSpecificContent({ PLUGIN_ROOT, DATA_DIR, currentRole });
|
|
101
|
+
_roleSpecificCache.set(currentRole, { mtime, value: built });
|
|
102
|
+
return built;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
throw new Error(`[session] role-specific rules build failed (role: ${currentRole}): ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Smart Bridge is optional — injected via setSmartBridge() during plugin init
|
|
109
|
+
// so session creation never depends on a circular import. If never injected,
|
|
110
|
+
// createSession simply falls back to classic preset-only behavior.
|
|
111
|
+
let _smartBridgeApi = null;
|
|
112
|
+
let _smartBridgeWarned = false;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Inject the Smart Bridge singleton. Called once by agent/index.mjs init()
|
|
116
|
+
* after initSmartBridge(). Safe to call multiple times — later calls
|
|
117
|
+
* replace the previous reference.
|
|
118
|
+
*/
|
|
119
|
+
export function setSmartBridge(api) {
|
|
120
|
+
_smartBridgeApi = api || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getSmartBridgeSync() {
|
|
124
|
+
return _smartBridgeApi;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Thrown when a session is closed while a call is in-flight. Callers (bridge
|
|
129
|
+
* handler, CLI) should render this as "cancelled" rather than a hard error.
|
|
130
|
+
*/
|
|
131
|
+
export class SessionClosedError extends Error {
|
|
132
|
+
constructor(sessionId, reason, closeReason) {
|
|
133
|
+
super(reason ? `Session "${sessionId}" closed: ${reason}` : `Session "${sessionId}" closed`);
|
|
134
|
+
this.name = 'SessionClosedError';
|
|
135
|
+
this.sessionId = sessionId;
|
|
136
|
+
this.cancelled = true;
|
|
137
|
+
// closeReason is the diagnostic enum (request-abort / manual /
|
|
138
|
+
// idle-sweep / runner-crash). Kept separate from `reason` (the free
|
|
139
|
+
// -form message) so consumers can branch on it without regex parsing.
|
|
140
|
+
this.reason = closeReason || null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const HEARTBEAT_THROTTLE_MS = 60_000; // 60s
|
|
144
|
+
|
|
145
|
+
// Merge externally-connected MCP tools with the plugin's in-process tools
|
|
146
|
+
// (registered by agent's toolExecutor bridge). Internal tools are exposed
|
|
147
|
+
// under their bare names — no mcp__ prefix, since the dispatcher in
|
|
148
|
+
// server.mjs handles them directly without a transport.
|
|
149
|
+
// Sorted deterministically by name — protects BP_1 hash stability from
|
|
150
|
+
// listTools() ordering churn. Anthropic / OpenAI / Gemini all hash the
|
|
151
|
+
// tools array verbatim, so any reorder rewrites the prefix.
|
|
152
|
+
// No cache: getMcpTools() and getInternalTools() are O(n) in-memory reads;
|
|
153
|
+
// the sort overhead on ~30 tools is negligible.
|
|
154
|
+
function _getMcpTools() {
|
|
155
|
+
const mcp = getMcpTools() || [];
|
|
156
|
+
const internalRaw = getInternalTools() || [];
|
|
157
|
+
const internal = internalRaw.map(t => ({
|
|
158
|
+
name: t.name,
|
|
159
|
+
description: typeof t.description === 'string' ? t.description : '',
|
|
160
|
+
inputSchema: t.inputSchema || { type: 'object', properties: {} },
|
|
161
|
+
// Keep annotations so the permission filter / role invariants can
|
|
162
|
+
// tell read-only from write-capable internal tools, and so
|
|
163
|
+
// bridgeHidden can be read during deny filtering.
|
|
164
|
+
annotations: t.annotations || {},
|
|
165
|
+
}));
|
|
166
|
+
return [...mcp, ...internal].sort((a, b) => {
|
|
167
|
+
const an = a?.name || '';
|
|
168
|
+
const bn = b?.name || '';
|
|
169
|
+
return an < bn ? -1 : an > bn ? 1 : 0;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Phase D-2 — profile.tools resolution.
|
|
174
|
+
//
|
|
175
|
+
// `toolSpec` may be:
|
|
176
|
+
// • Array<string> (profile.tools) — toolset ids like "tools:filesystem",
|
|
177
|
+
// "tools:git", "tools:mcp", "tools:search",
|
|
178
|
+
// "tools:readonly", or the literal "full"
|
|
179
|
+
// • 'full' / 'readonly' / 'mcp' — legacy preset.tools strings
|
|
180
|
+
// • null / undefined — same as 'full' (historical default)
|
|
181
|
+
//
|
|
182
|
+
// Array form is the Phase B/D target: each profile declares its tool surface
|
|
183
|
+
// explicitly, BP_1 hash differs across profiles with different tool subsets
|
|
184
|
+
// (by design — sub-task profile cannot see bash; worker-full can), and
|
|
185
|
+
// adding a new toolset id here is a localised change.
|
|
186
|
+
//
|
|
187
|
+
// Unified-shard policy — the session's tool array normally never narrows
|
|
188
|
+
// with permission or role. Bridge sessions share the same schema so BP_1
|
|
189
|
+
// stays bit-identical and the provider-side cache shard is shared
|
|
190
|
+
// workspace-wide. Rare specialist roles may pass schemaAllowedTools from a
|
|
191
|
+
// declarative hidden-role toolSchemaProfile to keep their first-turn routing
|
|
192
|
+
// surface intentionally tiny; runtime permission guards in loop.mjs remain
|
|
193
|
+
// the fail-safe either way.
|
|
194
|
+
|
|
195
|
+
const SESSION_ROUTE_TOOL_ORDER = [
|
|
196
|
+
'code_graph',
|
|
197
|
+
'glob',
|
|
198
|
+
'list',
|
|
199
|
+
'grep',
|
|
200
|
+
'read',
|
|
201
|
+
'edit',
|
|
202
|
+
'write',
|
|
203
|
+
'apply_patch',
|
|
204
|
+
'bash',
|
|
205
|
+
'job_wait',
|
|
206
|
+
];
|
|
207
|
+
const SESSION_ROUTE_TOOL_RANK = new Map(SESSION_ROUTE_TOOL_ORDER.map((name, index) => [name, index]));
|
|
208
|
+
const FILESYSTEM_TOOL_NAMES = new Set([
|
|
209
|
+
'code_graph',
|
|
210
|
+
'glob',
|
|
211
|
+
'list',
|
|
212
|
+
'grep',
|
|
213
|
+
'read',
|
|
214
|
+
'edit',
|
|
215
|
+
'write',
|
|
216
|
+
'apply_patch',
|
|
217
|
+
]);
|
|
218
|
+
const READONLY_TOOL_NAMES = new Set([
|
|
219
|
+
'code_graph',
|
|
220
|
+
'glob',
|
|
221
|
+
'list',
|
|
222
|
+
'grep',
|
|
223
|
+
'read',
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
function orderSessionTools(tools) {
|
|
227
|
+
return tools.map((tool, index) => ({ tool, index }))
|
|
228
|
+
.sort((a, b) => {
|
|
229
|
+
const ar = SESSION_ROUTE_TOOL_RANK.get(a.tool?.name) ?? 10_000;
|
|
230
|
+
const br = SESSION_ROUTE_TOOL_RANK.get(b.tool?.name) ?? 10_000;
|
|
231
|
+
if (ar !== br) return ar - br;
|
|
232
|
+
return a.index - b.index;
|
|
233
|
+
})
|
|
234
|
+
.map((entry) => entry.tool);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const ALL_BUILTIN_SESSION_TOOLS = orderSessionTools(_dedupByName([
|
|
238
|
+
...BUILTIN_TOOLS,
|
|
239
|
+
...PATCH_TOOL_DEFS,
|
|
240
|
+
...CODE_GRAPH_TOOL_DEFS,
|
|
241
|
+
]));
|
|
242
|
+
|
|
243
|
+
function resolveSessionTools(toolSpec, skills, { ownerIsBridge = false } = {}) {
|
|
244
|
+
const mcp = _getMcpTools();
|
|
245
|
+
// Bridge sessions freeze the 3 skill meta-tools into the schema
|
|
246
|
+
// unconditionally — concrete skill resolution is cwd-scoped at tool-call
|
|
247
|
+
// time (loop.mjs), so the schema bytes stay bit-identical across roles /
|
|
248
|
+
// cwds and the provider cache shard does not fragment.
|
|
249
|
+
const skillTools = buildSkillToolDefs(skills, { ownerIsBridge });
|
|
250
|
+
return _computeBaseTools(toolSpec, mcp, skillTools);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Dedup by name, first occurrence wins. BUILTIN_TOOLS is passed in ahead
|
|
254
|
+
// of the MCP-registered internal tools so plugin-side definitions take
|
|
255
|
+
// precedence when both surfaces declare the same name (e.g. read / grep / glob).
|
|
256
|
+
// Without this merge, Anthropic rejected the request with
|
|
257
|
+
// "tools: Tool names must be unique" and the orchestrator burned up to
|
|
258
|
+
// 20 iterations retrying before the final answer landed.
|
|
259
|
+
function _dedupByName(tools) {
|
|
260
|
+
const seen = new Map();
|
|
261
|
+
for (const t of tools) {
|
|
262
|
+
const n = t?.name;
|
|
263
|
+
if (!n || seen.has(n)) continue;
|
|
264
|
+
seen.set(n, t);
|
|
265
|
+
}
|
|
266
|
+
return [...seen.values()];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Bridge visibility is declared per-tool via annotations.bridgeHidden.
|
|
270
|
+
// Tools with bridgeHidden:true are stripped from bridge sessions at schema
|
|
271
|
+
// build time (see deny filtering below). No code-level name list needed.
|
|
272
|
+
|
|
273
|
+
function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
274
|
+
if (Array.isArray(toolSpec)) {
|
|
275
|
+
if (toolSpec.length === 0) {
|
|
276
|
+
// Explicit "no tools" — skill meta tools still travel so the model
|
|
277
|
+
// can at least discover and invoke skills if that is the one
|
|
278
|
+
// dynamic surface the profile retains.
|
|
279
|
+
return _dedupByName([...skillTools]);
|
|
280
|
+
}
|
|
281
|
+
if (toolSpec.includes('full')) {
|
|
282
|
+
return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
|
|
283
|
+
}
|
|
284
|
+
const byName = new Map();
|
|
285
|
+
const add = (tool) => { if (tool?.name && !byName.has(tool.name)) byName.set(tool.name, tool); };
|
|
286
|
+
const addMany = (arr) => { for (const t of arr) add(t); };
|
|
287
|
+
for (const tagRaw of toolSpec) {
|
|
288
|
+
const tag = String(tagRaw || '').trim();
|
|
289
|
+
switch (tag) {
|
|
290
|
+
case 'tools:filesystem':
|
|
291
|
+
addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => FILESYSTEM_TOOL_NAMES.has(t.name)));
|
|
292
|
+
break;
|
|
293
|
+
case 'tools:readonly':
|
|
294
|
+
addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name)));
|
|
295
|
+
break;
|
|
296
|
+
case 'tools:bash':
|
|
297
|
+
case 'tools:git':
|
|
298
|
+
case 'tools:analysis':
|
|
299
|
+
// Three aliases for the same surface — `bash` is the only
|
|
300
|
+
// shell-class tool. `tools:git` / `tools:analysis` exist so
|
|
301
|
+
// profile authors can name the intent (git workflows / data
|
|
302
|
+
// analysis) without inventing new toolset ids.
|
|
303
|
+
addMany(ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'bash'));
|
|
304
|
+
break;
|
|
305
|
+
case 'tools:mcp':
|
|
306
|
+
addMany(mcp);
|
|
307
|
+
break;
|
|
308
|
+
case 'tools:search':
|
|
309
|
+
// Name-pattern match: picks up `search` and any future tool
|
|
310
|
+
// whose name contains `search`. `recall` and `explore` deliberately do NOT match
|
|
311
|
+
// — they need `tools:mcp` (full mcp surface) or their own
|
|
312
|
+
// toolset id if a role wants targeted retrieval. Public bridge
|
|
313
|
+
// roles never reach the wrapper bodies regardless: see the
|
|
314
|
+
// isBlockedPublicWrapperCall guard in session/loop.mjs.
|
|
315
|
+
addMany(mcp.filter(t => /search/i.test(t?.name || '')));
|
|
316
|
+
break;
|
|
317
|
+
default:
|
|
318
|
+
process.stderr.write(`[session] unknown toolset id "${tag}" (profile.tools); skipping\n`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return _dedupByName([...byName.values(), ...skillTools]);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
switch (toolSpec) {
|
|
325
|
+
case 'mcp':
|
|
326
|
+
return _dedupByName([...mcp, ...skillTools]);
|
|
327
|
+
case 'readonly': {
|
|
328
|
+
const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
|
|
329
|
+
return _dedupByName([...readTools, ...mcp, ...skillTools]);
|
|
330
|
+
}
|
|
331
|
+
case 'full':
|
|
332
|
+
default:
|
|
333
|
+
return _dedupByName([...ALL_BUILTIN_SESSION_TOOLS, ...mcp, ...skillTools]);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function permissionFromToolSpec(toolSpec) {
|
|
338
|
+
if (toolSpec === 'readonly') return 'read';
|
|
339
|
+
if (toolSpec === 'mcp') return 'mcp';
|
|
340
|
+
if (Array.isArray(toolSpec)) {
|
|
341
|
+
const tags = new Set(toolSpec.map(t => String(t || '').trim()));
|
|
342
|
+
const hasWriteOrShell = tags.has('full')
|
|
343
|
+
|| tags.has('tools:filesystem')
|
|
344
|
+
|| tags.has('tools:bash')
|
|
345
|
+
|| tags.has('tools:git')
|
|
346
|
+
|| tags.has('tools:analysis');
|
|
347
|
+
if (tags.has('tools:readonly') && !hasWriteOrShell) return 'read';
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let nextId = Date.now();
|
|
353
|
+
// Known context windows for the current-generation models this plugin
|
|
354
|
+
// routes to. Anything not listed falls through to guessContextWindow() —
|
|
355
|
+
// local llama/mistral/phi default to 8192, everything else 128000. Keep
|
|
356
|
+
// this map trimmed to live models; older generations slow down reads
|
|
357
|
+
// without buying anything.
|
|
358
|
+
const CONTEXT_WINDOWS = {
|
|
359
|
+
// OpenAI GPT-5.x family
|
|
360
|
+
'gpt-5.5': 1000000,
|
|
361
|
+
'gpt-5.4-mini': 1000000,
|
|
362
|
+
'gpt-5.4-nano': 1000000,
|
|
363
|
+
// Anthropic Claude 4.x
|
|
364
|
+
'claude-opus-4-8': 1000000,
|
|
365
|
+
'claude-opus-4-7': 1000000,
|
|
366
|
+
'claude-sonnet-4-6': 1000000,
|
|
367
|
+
'claude-haiku-4-5-20251001': 200000,
|
|
368
|
+
// Google Gemini 3.x
|
|
369
|
+
'gemini-3.1-pro': 1000000,
|
|
370
|
+
'gemini-3-pro': 1000000,
|
|
371
|
+
'gemini-3.5-flash': 1000000,
|
|
372
|
+
'gemini-3-flash': 1000000,
|
|
373
|
+
};
|
|
374
|
+
function guessContextWindow(model) {
|
|
375
|
+
if (CONTEXT_WINDOWS[model])
|
|
376
|
+
return CONTEXT_WINDOWS[model];
|
|
377
|
+
if (model.includes('llama') || model.includes('mistral') || model.includes('phi'))
|
|
378
|
+
return 8192;
|
|
379
|
+
return 128000;
|
|
380
|
+
}
|
|
381
|
+
// Provider-scoped unified cache key. Goal: all orchestrator-internal
|
|
382
|
+
// dispatches (bridge/maintenance/mcp/scheduler/webhook) targeting the
|
|
383
|
+
// same provider land in a single server-side cache shard, so the
|
|
384
|
+
// shared prefix (tools + system + pool system prompt) is reused
|
|
385
|
+
// regardless of role. Per-role / per-session differentiation lives in
|
|
386
|
+
// the message tail, which is naturally separated by content hashing.
|
|
387
|
+
const PROVIDER_ALIAS = {
|
|
388
|
+
'openai-oauth': 'codex', // ChatGPT subscription (Codex backend)
|
|
389
|
+
'anthropic-oauth': 'claude', // Claude Max subscription
|
|
390
|
+
'openai': 'openai',
|
|
391
|
+
'anthropic': 'anthropic',
|
|
392
|
+
'gemini': 'gemini',
|
|
393
|
+
'deepseek': 'deepseek',
|
|
394
|
+
'xai': 'xai',
|
|
395
|
+
};
|
|
396
|
+
function providerCacheKey(provider, override) {
|
|
397
|
+
if (override) return String(override);
|
|
398
|
+
if (!provider) return 'mixdog-default';
|
|
399
|
+
return `mixdog-${PROVIDER_ALIAS[provider] || provider}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Prefetch permission guard ─────────────────────────────────────────────────
|
|
403
|
+
// Mirrors _checkWorkerPermission in loop.mjs for tool calls that originate
|
|
404
|
+
// in the prefetch path (outside the agent loop). Returns an error string if
|
|
405
|
+
// blocked, or null if allowed.
|
|
406
|
+
const _permEvalForPrefetch = (() => {
|
|
407
|
+
const _req = createRequire(import.meta.url);
|
|
408
|
+
try {
|
|
409
|
+
const { dirname: _pdir, resolve: _pres } = _req('path');
|
|
410
|
+
const _hooksLib = _pres(_pdir(fileURLToPath(import.meta.url)), '../../../../hooks/lib/permission-evaluator.cjs');
|
|
411
|
+
return _req(_hooksLib).evaluatePermission;
|
|
412
|
+
} catch { return null; }
|
|
413
|
+
})();
|
|
414
|
+
function _guardedPrefetchTool(toolName, toolArgs, session) {
|
|
415
|
+
if (!_permEvalForPrefetch) return null;
|
|
416
|
+
// Same baseline as _checkWorkerPermission: when no explicit mode is
|
|
417
|
+
// attached to the session, run the evaluator under 'default' so the
|
|
418
|
+
// bypass-proof hard-deny patterns still apply during prefetch dispatch.
|
|
419
|
+
const permissionMode = session?.permissionMode || 'default';
|
|
420
|
+
const projectDir = session?.cwd || undefined;
|
|
421
|
+
const userCwd = session?.cwd || undefined;
|
|
422
|
+
const MCP_PFX = 'mcp__plugin_mixdog_mixdog__';
|
|
423
|
+
const fullName = toolName.startsWith(MCP_PFX) || toolName.startsWith('mcp__') ? toolName : `${MCP_PFX}${toolName}`;
|
|
424
|
+
try {
|
|
425
|
+
const { decision, reason } = _permEvalForPrefetch({ toolName: fullName, toolInput: toolArgs || {}, permissionMode, projectDir, userCwd });
|
|
426
|
+
if (decision === 'deny' || decision === 'ask') {
|
|
427
|
+
return `Error: prefetch tool "${toolName}" blocked (decision=${decision}): ${reason}`;
|
|
428
|
+
}
|
|
429
|
+
} catch (e) {
|
|
430
|
+
process.stderr.write(`[prefetch-guard] evaluator error: ${e?.message}\n`);
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function _tryBridgeExplicitPrefetch(session, explicitPrefetch) {
|
|
436
|
+
if (!explicitPrefetch || typeof explicitPrefetch !== 'object') return null;
|
|
437
|
+
if (session?.owner !== 'bridge') return null;
|
|
438
|
+
const parts = [];
|
|
439
|
+
const failed = [];
|
|
440
|
+
const totalEntries = [];
|
|
441
|
+
// files[] — string entries use the default head excerpt; object entries
|
|
442
|
+
// {path, n?, full?} let the caller widen the window or pull the full file
|
|
443
|
+
// so worker doesn't have to re-read deep ranges of an already-prefetched
|
|
444
|
+
// file (a recurring iter burner observed in baseline session telemetry).
|
|
445
|
+
const _rawFilesIn = Array.isArray(explicitPrefetch.files) ? explicitPrefetch.files : [];
|
|
446
|
+
const _readOptsByFile = new Map();
|
|
447
|
+
const files = [];
|
|
448
|
+
const _seenFiles = new Set();
|
|
449
|
+
const _addPrefetchFile = (file, opts = null) => {
|
|
450
|
+
if (typeof file !== 'string' || !file) return;
|
|
451
|
+
if (!_seenFiles.has(file)) {
|
|
452
|
+
_seenFiles.add(file);
|
|
453
|
+
files.push(file);
|
|
454
|
+
}
|
|
455
|
+
if (!opts || Object.keys(opts).length === 0) return;
|
|
456
|
+
const prev = _readOptsByFile.get(file) || {};
|
|
457
|
+
const merged = { ...prev };
|
|
458
|
+
if (opts.mode === 'full') {
|
|
459
|
+
merged.mode = 'full';
|
|
460
|
+
delete merged.n;
|
|
461
|
+
} else if (merged.mode !== 'full' && Number.isFinite(opts.n) && opts.n > 0) {
|
|
462
|
+
merged.n = Math.max(Number(merged.n) || 0, opts.n);
|
|
463
|
+
}
|
|
464
|
+
if (Object.keys(merged).length > 0) _readOptsByFile.set(file, merged);
|
|
465
|
+
};
|
|
466
|
+
for (const entry of _rawFilesIn) {
|
|
467
|
+
if (typeof entry === 'string' && entry) {
|
|
468
|
+
_addPrefetchFile(entry);
|
|
469
|
+
} else if (entry && typeof entry === 'object' && typeof entry.path === 'string' && entry.path) {
|
|
470
|
+
const opts = {};
|
|
471
|
+
if (entry.full === true) opts.mode = 'full';
|
|
472
|
+
else if (Number.isFinite(entry.n) && entry.n > 0) opts.n = entry.n;
|
|
473
|
+
_addPrefetchFile(entry.path, opts);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (files.length > 0) {
|
|
477
|
+
const _pfGuard = _guardedPrefetchTool('read', { path: files }, session);
|
|
478
|
+
if (_pfGuard) {
|
|
479
|
+
process.stderr.write(`[bridge-prefetch] files read blocked: ${_pfGuard}\n`);
|
|
480
|
+
failed.push(...files);
|
|
481
|
+
totalEntries.push(...files);
|
|
482
|
+
} else {
|
|
483
|
+
totalEntries.push(...files);
|
|
484
|
+
// R20: per-file prefetch cache (cross-dispatch, process-local).
|
|
485
|
+
// Try each file from cache first; batch misses into one disk read.
|
|
486
|
+
const { resolve: _pfResolve, isAbsolute: _pfIsAbs, normalize: _pfNorm } = await import('path');
|
|
487
|
+
const _pfCwd = session.cwd || null;
|
|
488
|
+
function _pfAbsPath(f) {
|
|
489
|
+
const abs = _pfIsAbs(f) ? f : _pfResolve(_pfCwd || process.cwd(), f);
|
|
490
|
+
return _pfNorm(abs);
|
|
491
|
+
}
|
|
492
|
+
const fileHits = []; // { file, abs, content } — satisfied from cache
|
|
493
|
+
const fileMisses = []; // { file, abs } — need disk read
|
|
494
|
+
for (const f of files) {
|
|
495
|
+
const abs = _pfAbsPath(f);
|
|
496
|
+
// Skip the cross-dispatch cache when the caller asked for a
|
|
497
|
+
// non-default window (custom n or full-file). Cache key is the
|
|
498
|
+
// path alone, so a default-window cache hit would silently feed
|
|
499
|
+
// the wrong slice back to the next caller.
|
|
500
|
+
const hit = _readOptsByFile.has(f) ? null : tryPrefetchCached(abs);
|
|
501
|
+
if (hit) {
|
|
502
|
+
fileHits.push({ file: f, abs, content: hit.content });
|
|
503
|
+
} else {
|
|
504
|
+
fileMisses.push({ file: f, abs });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Disk read for misses (single batch call).
|
|
508
|
+
const missFiles = fileMisses.map(m => m.file);
|
|
509
|
+
const missResults = {}; // file → content string
|
|
510
|
+
if (missFiles.length > 0) {
|
|
511
|
+
// Read each miss file individually so we can cache per-file.
|
|
512
|
+
// The files list is small (typically 2-5), so N awaits is fine.
|
|
513
|
+
await Promise.all(missFiles.map(async (f) => {
|
|
514
|
+
const opts = _readOptsByFile.get(f) || {};
|
|
515
|
+
const readArgs = { path: f };
|
|
516
|
+
if (opts.mode === 'full') {
|
|
517
|
+
readArgs.mode = 'full';
|
|
518
|
+
} else {
|
|
519
|
+
readArgs.mode = 'head';
|
|
520
|
+
readArgs.n = Number.isFinite(opts.n) ? opts.n : 120;
|
|
521
|
+
}
|
|
522
|
+
const out = await executeInternalTool('read', readArgs).catch((e) => {
|
|
523
|
+
process.stderr.write(`[bridge-prefetch] file read failed (${f}): ${e && e.message || e}\n`);
|
|
524
|
+
return null;
|
|
525
|
+
});
|
|
526
|
+
if (out !== null) {
|
|
527
|
+
missResults[f] = String(out);
|
|
528
|
+
}
|
|
529
|
+
}));
|
|
530
|
+
// Cache successful miss results.
|
|
531
|
+
for (const { file, abs } of fileMisses) {
|
|
532
|
+
const content = missResults[file];
|
|
533
|
+
if (content && classifyResultKind(content) !== 'error') {
|
|
534
|
+
// Only cache default-window reads; custom-window results
|
|
535
|
+
// would poison the shared cross-dispatch cache.
|
|
536
|
+
if (!_readOptsByFile.has(file)) setPrefetchCached(abs, content);
|
|
537
|
+
} else if (content === undefined || classifyResultKind(content) === 'error') {
|
|
538
|
+
failed.push(file);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Assemble combined output preserving original file order.
|
|
543
|
+
const readParts = [];
|
|
544
|
+
const hitByFile = new Map(fileHits.map((h) => [h.file, h]));
|
|
545
|
+
for (const f of files) {
|
|
546
|
+
const hitEntry = hitByFile.get(f);
|
|
547
|
+
if (hitEntry) {
|
|
548
|
+
readParts.push(hitEntry.content);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const content = missResults[f];
|
|
552
|
+
if (content && classifyResultKind(content) !== 'error') {
|
|
553
|
+
readParts.push(content);
|
|
554
|
+
}
|
|
555
|
+
// else: already pushed to failed above
|
|
556
|
+
}
|
|
557
|
+
if (readParts.length > 0) {
|
|
558
|
+
parts.push(`### prefetch files\nread ${readParts.length}\n\n${readParts.join('\n\n')}`);
|
|
559
|
+
}
|
|
560
|
+
// Log hit/miss counters so dispatch telemetry shows prefetch effectiveness.
|
|
561
|
+
process.stderr.write(
|
|
562
|
+
`[prefetch] files=${files.length} cached=${fileHits.length} miss=${fileMisses.length} failed=${failed.length}\n`
|
|
563
|
+
);
|
|
564
|
+
// Attach stats to session so post-hoc analyzers (inspect-session.mjs)
|
|
565
|
+
// can see prefetch effectiveness without parsing stderr logs.
|
|
566
|
+
if (session && typeof session === 'object') {
|
|
567
|
+
if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0 };
|
|
568
|
+
session.prefetchStats.files += files.length;
|
|
569
|
+
session.prefetchStats.cached += fileHits.length;
|
|
570
|
+
session.prefetchStats.miss += fileMisses.length;
|
|
571
|
+
session.prefetchStats.failed += failed.length;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// callers[]
|
|
576
|
+
const callers = Array.isArray(explicitPrefetch.callers) ? explicitPrefetch.callers.filter(c => c && typeof c.symbol === 'string') : [];
|
|
577
|
+
{
|
|
578
|
+
const callerTasks = callers.map(({ symbol, file }) => {
|
|
579
|
+
const cgArgs = { mode: 'callers', symbol };
|
|
580
|
+
if (file) cgArgs.file = file;
|
|
581
|
+
if (session?.cwd) cgArgs.cwd = session.cwd;
|
|
582
|
+
totalEntries.push(symbol);
|
|
583
|
+
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
584
|
+
if (blocked) {
|
|
585
|
+
process.stderr.write(`[bridge-prefetch] callers(${symbol}) blocked: ${blocked}\n`);
|
|
586
|
+
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
587
|
+
}
|
|
588
|
+
return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
|
|
589
|
+
.then(out => ({ symbol, out }))
|
|
590
|
+
.catch(e => {
|
|
591
|
+
process.stderr.write(`[bridge-prefetch] callers(${symbol}) failed: ${e && e.message || e}\n`);
|
|
592
|
+
return { symbol, out: null };
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
const callerResults = await Promise.allSettled(callerTasks);
|
|
596
|
+
for (const r of callerResults) {
|
|
597
|
+
const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
|
|
598
|
+
if (blocked) { failed.push(symbol); continue; }
|
|
599
|
+
if (out && classifyResultKind(String(out)) !== 'error') {
|
|
600
|
+
parts.push(`### prefetch callers ${symbol}\n${out}`);
|
|
601
|
+
} else {
|
|
602
|
+
failed.push(symbol);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// references[]
|
|
607
|
+
const references = Array.isArray(explicitPrefetch.references) ? explicitPrefetch.references.filter(r => r && typeof r.symbol === 'string') : [];
|
|
608
|
+
{
|
|
609
|
+
const refTasks = references.map(({ symbol, file }) => {
|
|
610
|
+
const cgArgs = { mode: 'references', symbol };
|
|
611
|
+
if (file) cgArgs.file = file;
|
|
612
|
+
if (session?.cwd) cgArgs.cwd = session.cwd;
|
|
613
|
+
totalEntries.push(symbol);
|
|
614
|
+
const blocked = _guardedPrefetchTool('code_graph', cgArgs, session);
|
|
615
|
+
if (blocked) {
|
|
616
|
+
process.stderr.write(`[bridge-prefetch] references(${symbol}) blocked: ${blocked}\n`);
|
|
617
|
+
return Promise.resolve({ symbol, out: null, blocked: true });
|
|
618
|
+
}
|
|
619
|
+
return executeCodeGraphTool('code_graph', cgArgs, session?.cwd)
|
|
620
|
+
.then(out => ({ symbol, out }))
|
|
621
|
+
.catch(e => {
|
|
622
|
+
process.stderr.write(`[bridge-prefetch] references(${symbol}) failed: ${e && e.message || e}\n`);
|
|
623
|
+
return { symbol, out: null };
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
const refResults = await Promise.allSettled(refTasks);
|
|
627
|
+
for (const r of refResults) {
|
|
628
|
+
const { symbol, out, blocked } = r.status === 'fulfilled' ? r.value : { symbol: '?', out: null };
|
|
629
|
+
if (blocked) { failed.push(symbol); continue; }
|
|
630
|
+
if (out && classifyResultKind(String(out)) !== 'error') {
|
|
631
|
+
parts.push(`### prefetch references ${symbol}\n${out}`);
|
|
632
|
+
} else {
|
|
633
|
+
failed.push(symbol);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (session && typeof session === 'object' && (callers.length > 0 || references.length > 0)) {
|
|
638
|
+
if (!session.prefetchStats) session.prefetchStats = { files: 0, cached: 0, miss: 0, failed: 0, callers: 0, references: 0 };
|
|
639
|
+
session.prefetchStats.callers = (session.prefetchStats.callers || 0) + callers.length;
|
|
640
|
+
session.prefetchStats.references = (session.prefetchStats.references || 0) + references.length;
|
|
641
|
+
}
|
|
642
|
+
if (parts.length === 0) {
|
|
643
|
+
// All entries failed but Lead presence must still be signalled — emit
|
|
644
|
+
// warn-only so the gate logic can distinguish "prefetch was requested"
|
|
645
|
+
// from "no prefetch at all".
|
|
646
|
+
if (totalEntries.length > 0 && failed.length > 0) {
|
|
647
|
+
return `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>`;
|
|
648
|
+
}
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
const warnLine = failed.length > 0
|
|
652
|
+
? `<prefetch-warn>${failed.length} of ${totalEntries.length} prefetch entries failed: ${[...new Set(failed)].join(', ')}</prefetch-warn>\n`
|
|
653
|
+
: '';
|
|
654
|
+
return `${warnLine}<prefetch>\n${parts.join('\n\n')}\n</prefetch>`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// --- bridge spawn (createSession) ---
|
|
658
|
+
// opts can pass either a `preset` object (from config.presets) or raw provider/model.
|
|
659
|
+
// Preset shape: { name, provider, model, effort?, fast?, tools? }
|
|
660
|
+
//
|
|
661
|
+
// Smart Bridge integration:
|
|
662
|
+
// opts.taskType / opts.role / opts.profileId — enables profile-aware routing.
|
|
663
|
+
// Rule-based SmartRouter resolves these synchronously; the resolved
|
|
664
|
+
// profile controls context filtering (skip.skills/memory/etc) and cache
|
|
665
|
+
// strategy. If no rule matches, falls back to classic preset behavior.
|
|
666
|
+
// opts.profile — pre-resolved profile (bypasses router; used by async
|
|
667
|
+
// callers who already ran SmartBridge.resolve()).
|
|
668
|
+
// opts.providerCacheOpts — pre-resolved cache options merged into ask() sendOpts.
|
|
669
|
+
export function createSession(opts) {
|
|
670
|
+
const presetObj = opts.preset && typeof opts.preset === 'object' ? opts.preset : null;
|
|
671
|
+
|
|
672
|
+
// --- Smart Bridge profile resolution (best-effort, sync) ---
|
|
673
|
+
let profile = opts.profile || null;
|
|
674
|
+
let providerCacheOpts = opts.providerCacheOpts || null;
|
|
675
|
+
if (!profile && (opts.taskType || opts.role || opts.profileId)) {
|
|
676
|
+
const smartBridge = getSmartBridgeSync();
|
|
677
|
+
if (smartBridge) {
|
|
678
|
+
try {
|
|
679
|
+
const resolved = smartBridge.resolveSync({
|
|
680
|
+
taskType: opts.taskType,
|
|
681
|
+
role: opts.role,
|
|
682
|
+
profileId: opts.profileId,
|
|
683
|
+
preset: presetObj?.name || (typeof opts.preset === 'string' ? opts.preset : null),
|
|
684
|
+
provider: opts.provider || presetObj?.provider,
|
|
685
|
+
});
|
|
686
|
+
if (resolved) {
|
|
687
|
+
profile = resolved.profile;
|
|
688
|
+
providerCacheOpts = resolved.providerCacheOpts;
|
|
689
|
+
}
|
|
690
|
+
} catch (e) {
|
|
691
|
+
// Smart Bridge error — log once, fall back to classic behavior.
|
|
692
|
+
if (!_smartBridgeWarned) {
|
|
693
|
+
_smartBridgeWarned = true;
|
|
694
|
+
process.stderr.write(`[session] smart bridge resolve failed: ${e.message}\n`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const providerName = opts.provider || presetObj?.provider
|
|
701
|
+
|| (profile?.preferredProviders?.[0]);
|
|
702
|
+
const modelName = opts.model || presetObj?.model;
|
|
703
|
+
// opts.tools (caller-supplied) wins over presetObj.tools — caller
|
|
704
|
+
// intent ('tools:readonly' from Pool C, etc.) must override the
|
|
705
|
+
// preset's default 'full'. Previous priority let HAIKU's tools='full'
|
|
706
|
+
// shadow Pool C's explicit readonly request, leaking write tools and
|
|
707
|
+
// bash into a read-only agent.
|
|
708
|
+
const toolPreset = opts.tools || presetObj?.tools || (typeof opts.preset === 'string' ? opts.preset : null) || 'full';
|
|
709
|
+
const effort = presetObj?.effort || opts.effort || null;
|
|
710
|
+
const fast = presetObj?.fast === true || opts.fast === true;
|
|
711
|
+
if (!providerName)
|
|
712
|
+
throw new Error('createSession: provider is required');
|
|
713
|
+
if (!modelName)
|
|
714
|
+
throw new Error('createSession: model is required');
|
|
715
|
+
const provider = getProvider(providerName);
|
|
716
|
+
if (!provider)
|
|
717
|
+
throw new Error(`Provider "${providerName}" not found or not enabled`);
|
|
718
|
+
const id = `sess_${process.pid}_${nextId++}_${Date.now()}_${randomBytes(16).toString('hex')}`;
|
|
719
|
+
const messages = [];
|
|
720
|
+
const agentTemplate = opts.agent ? loadAgentTemplate(opts.agent, opts.cwd) : null;
|
|
721
|
+
const skills = collectSkillsCached(opts.cwd);
|
|
722
|
+
|
|
723
|
+
// Bridge shared prefix (bit-identical across roles). Hidden roles reuse the
|
|
724
|
+
// same shared bridge rules so the cache shard stays stable across bridge
|
|
725
|
+
// callers. User-defined data (DATA_DIR roles/schedules/webhooks) is baked
|
|
726
|
+
// into BP1 as a single fixed-value monolithic block so every role shares
|
|
727
|
+
// one cache shard. A user edit invalidates BP1 once and the new prefix
|
|
728
|
+
// re-warms across all roles together.
|
|
729
|
+
const bridgeRulesRole = opts.role || profile?.taskType || null;
|
|
730
|
+
const bridgeRules = opts.skipBridgeRules ? '' : _buildBridgeRules();
|
|
731
|
+
const roleSpecific = opts.skipBridgeRules ? '' : _buildRoleSpecific(bridgeRulesRole);
|
|
732
|
+
// Project MD (cwd-based, Tier 3 slot).
|
|
733
|
+
const projectContext = collectProjectMd(opts.cwd);
|
|
734
|
+
|
|
735
|
+
// Role template (Phase B §4 — UI-managed). Reads <DATA_DIR>/roles/<role>.md
|
|
736
|
+
// and parses frontmatter (description, permission). The template is
|
|
737
|
+
// injected into the Tier 3 system-reminder so role differences never
|
|
738
|
+
// touch the BP_2 cache prefix.
|
|
739
|
+
const resolvedRole = opts.role || profile?.taskType || null;
|
|
740
|
+
const dataDir = process.env.CLAUDE_PLUGIN_DATA;
|
|
741
|
+
const roleTemplate = resolvedRole && dataDir
|
|
742
|
+
? loadRoleTemplate(resolvedRole, dataDir)
|
|
743
|
+
: null;
|
|
744
|
+
|
|
745
|
+
// Bridge sessions must not inherit role/profile/preset tool narrowing: Pool
|
|
746
|
+
// B and Pool C share one bit-identical tool schema for BP_1/BP_2 cache
|
|
747
|
+
// reuse, and permission differences are enforced only at call time. Raw
|
|
748
|
+
// non-bridge callers keep the historical profile.tools / preset.tools
|
|
749
|
+
// behaviour.
|
|
750
|
+
const toolSpec = opts.owner === 'bridge'
|
|
751
|
+
? 'full'
|
|
752
|
+
: (Array.isArray(profile?.tools) ? profile.tools : toolPreset);
|
|
753
|
+
|
|
754
|
+
// Prompt permission is metadata only. Preset tool restrictions must NOT
|
|
755
|
+
// enter the prompt, or they split the shared bridge cache tail; they map
|
|
756
|
+
// to toolPermission below and are enforced only at call time.
|
|
757
|
+
const permission = opts.permission
|
|
758
|
+
|| roleTemplate?.permission
|
|
759
|
+
|| null;
|
|
760
|
+
const toolPermission = opts.permission
|
|
761
|
+
|| profile?.permission
|
|
762
|
+
|| roleTemplate?.permission
|
|
763
|
+
|| permissionFromToolSpec(toolPreset)
|
|
764
|
+
|| null;
|
|
765
|
+
let toolsForRouting = resolveSessionTools(toolSpec, skills, { ownerIsBridge: opts.owner === 'bridge' });
|
|
766
|
+
// Fail-closed permission intersection: when a role declares an explicit
|
|
767
|
+
// permission (from user-workflow.json or the role template), intersect the
|
|
768
|
+
// resolved tool list with the permission's allow/deny lists. If the
|
|
769
|
+
// intersection produces an empty set the permission config is broken —
|
|
770
|
+
// fail closed (zero tools) rather than silently falling back to the full
|
|
771
|
+
// preset, which would grant the role more surface than declared.
|
|
772
|
+
if (toolPermission && typeof toolPermission === 'object') {
|
|
773
|
+
const allowSet = Array.isArray(toolPermission.allow) && toolPermission.allow.length > 0
|
|
774
|
+
? new Set(toolPermission.allow.map(n => String(n).toLowerCase()))
|
|
775
|
+
: null;
|
|
776
|
+
const denySet = Array.isArray(toolPermission.deny) && toolPermission.deny.length > 0
|
|
777
|
+
? new Set(toolPermission.deny.map(n => String(n).toLowerCase()))
|
|
778
|
+
: null;
|
|
779
|
+
if (allowSet || denySet) {
|
|
780
|
+
const filtered = toolsForRouting.filter(t => {
|
|
781
|
+
const name = String(t?.name || '').toLowerCase();
|
|
782
|
+
if (denySet && denySet.has(name)) return false;
|
|
783
|
+
if (allowSet && !allowSet.has(name)) return false;
|
|
784
|
+
return true;
|
|
785
|
+
});
|
|
786
|
+
// Fail-closed: an empty intersection means the permission config is
|
|
787
|
+
// misconfigured — do not silently fall back to the full preset.
|
|
788
|
+
toolsForRouting = filtered;
|
|
789
|
+
if (filtered.length === 0) {
|
|
790
|
+
process.stderr.write(`[session] WARN: role permission intersection produced 0 tools — failing closed (role=${opts.role || 'unknown'})
|
|
791
|
+
`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const { baseRules, roleCatalog, sessionMarker, volatileTail } = composeSystemPrompt({
|
|
797
|
+
userPrompt: opts.systemPrompt,
|
|
798
|
+
bridgeRules: bridgeRules || undefined,
|
|
799
|
+
roleSpecific: roleSpecific || undefined,
|
|
800
|
+
agentTemplate: agentTemplate || undefined,
|
|
801
|
+
roleTemplate: roleTemplate || undefined,
|
|
802
|
+
hasSkills: skills.length > 0,
|
|
803
|
+
profile: profile || undefined,
|
|
804
|
+
role: resolvedRole,
|
|
805
|
+
skipRoleReminder: opts.skipRoleReminder || false,
|
|
806
|
+
permission,
|
|
807
|
+
taskBrief: opts.taskBrief || null,
|
|
808
|
+
projectContext: projectContext || null,
|
|
809
|
+
tools: toolsForRouting,
|
|
810
|
+
bashIsPersistent: opts.owner === 'bridge' && toolsForRouting.some(t => t?.name === 'bash'),
|
|
811
|
+
// Effective cwd rides in tier3Reminder so explore-like tools know
|
|
812
|
+
// their search root without needing to shove "Override cwd:" into
|
|
813
|
+
// the user message body (that used to fragment the shard prefix).
|
|
814
|
+
cwd: opts.cwd || null,
|
|
815
|
+
// BP2 catalog policy — explicit-cache providers see the unified
|
|
816
|
+
// all-roles catalog; implicit-prefix-hash providers keep self-only.
|
|
817
|
+
provider: providerName || null,
|
|
818
|
+
});
|
|
819
|
+
// 4-BP layout (see composeSystemPrompt docs):
|
|
820
|
+
// system block #1 = baseRules — BP1 (1h) shared across ALL roles
|
|
821
|
+
// system block #2 = roleCatalog — BP2 (1h) scoped role catalog + project
|
|
822
|
+
// first <system-reminder> user = sessionMarker — BP3 (1h) role-specific task body
|
|
823
|
+
// second <system-reminder> user = volatileTail — rides near BP4 (5m)
|
|
824
|
+
// Anthropic multi-block system pins each block with cache_control.
|
|
825
|
+
// OpenAI gets a stable provider cache key/session prefix. Gemini relies
|
|
826
|
+
// on implicit prompt caching only, so hits are observed, not treated as a
|
|
827
|
+
// guaranteed warm shard.
|
|
828
|
+
if (baseRules) {
|
|
829
|
+
messages.push({ role: 'system', content: baseRules });
|
|
830
|
+
}
|
|
831
|
+
if (roleCatalog) {
|
|
832
|
+
messages.push({ role: 'system', content: roleCatalog });
|
|
833
|
+
}
|
|
834
|
+
if (sessionMarker) {
|
|
835
|
+
messages.push({ role: 'user', content: `<system-reminder>\n${sessionMarker}\n</system-reminder>` });
|
|
836
|
+
messages.push({ role: 'assistant', content: 'Session context noted.' });
|
|
837
|
+
}
|
|
838
|
+
if (volatileTail) {
|
|
839
|
+
messages.push({ role: 'user', content: `<system-reminder>\n${volatileTail}\n</system-reminder>` });
|
|
840
|
+
messages.push({ role: 'assistant', content: 'Understood.' });
|
|
841
|
+
}
|
|
842
|
+
if (opts.files?.length) {
|
|
843
|
+
const fileContext = opts.files
|
|
844
|
+
.map(f => `### ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
|
|
845
|
+
.join('\n\n');
|
|
846
|
+
messages.push({ role: 'user', content: `Reference files:\n\n${fileContext}` });
|
|
847
|
+
messages.push({ role: 'assistant', content: 'Understood. I have the files in context.' });
|
|
848
|
+
}
|
|
849
|
+
let tools = toolsForRouting;
|
|
850
|
+
|
|
851
|
+
// Schema filtering applied after schema build:
|
|
852
|
+
// - opts.schemaAllowedTools : declarative hidden-role schema profile
|
|
853
|
+
// allowlist for tiny specialist roles where one-shot tool routing
|
|
854
|
+
// beats the shared-schema cache win.
|
|
855
|
+
// - opts.disallowedTools : per-call caller override (Anthropic
|
|
856
|
+
// BuiltInAgentDefinition pattern)
|
|
857
|
+
// - annotations.bridgeHidden : declarative per-tool flag (tools.json
|
|
858
|
+
// and internal tool defs). Pool A (Lead) still sees all tools.
|
|
859
|
+
//
|
|
860
|
+
const hasCallerAllow = Array.isArray(opts.schemaAllowedTools);
|
|
861
|
+
const callerAllow = hasCallerAllow ? opts.schemaAllowedTools.map(n => String(n).toLowerCase()) : [];
|
|
862
|
+
if (hasCallerAllow) {
|
|
863
|
+
const allowSet = new Set(callerAllow);
|
|
864
|
+
const before = tools.length;
|
|
865
|
+
tools = tools.filter(t => allowSet.has(String(t?.name || '').toLowerCase()));
|
|
866
|
+
if (tools.length !== before) {
|
|
867
|
+
process.stderr.write(`[session] schemaAllowedTools=${callerAllow.join(',')} kept ${tools.length}/${before} tools\n`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const callerDeny = Array.isArray(opts.disallowedTools) ? opts.disallowedTools.map(n => String(n)) : [];
|
|
871
|
+
if (callerDeny.length) {
|
|
872
|
+
const denySet = new Set(callerDeny);
|
|
873
|
+
const before = tools.length;
|
|
874
|
+
tools = tools.filter(t => !denySet.has(String(t?.name || '').toLowerCase()));
|
|
875
|
+
if (tools.length !== before) {
|
|
876
|
+
process.stderr.write(`[session] disallowedTools=${callerDeny.join(',')} stripped ${before - tools.length} tools\n`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (opts.owner === 'bridge') {
|
|
880
|
+
const before = tools.length;
|
|
881
|
+
tools = tools.filter(t => !t?.annotations?.bridgeHidden);
|
|
882
|
+
if (tools.length !== before) {
|
|
883
|
+
process.stderr.write(`[session] bridgeHidden stripped ${before - tools.length} tools\n`);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Bridge tool canonicalization: keep route-sensitive tools in policy order
|
|
888
|
+
// while preserving deterministic MCP/skill order for BP1 shard stability.
|
|
889
|
+
if (opts.owner === 'bridge') {
|
|
890
|
+
tools = orderSessionTools(tools);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Unified-shard policy — no broad role-specific schema filter. Keep
|
|
894
|
+
// bridge schemas shared unless a hidden-role schema profile explicitly
|
|
895
|
+
// passes schemaAllowedTools for a small specialist; broad role
|
|
896
|
+
// whitelists would fragment the cache shard.
|
|
897
|
+
if (resolvedRole) {
|
|
898
|
+
process.stderr.write(`[session] role=${resolvedRole} permission=${permission || 'full'} toolPermission=${toolPermission || 'full'} tools=${tools.length}\n`);
|
|
899
|
+
}
|
|
900
|
+
const session = {
|
|
901
|
+
id,
|
|
902
|
+
provider: providerName,
|
|
903
|
+
model: modelName,
|
|
904
|
+
messages,
|
|
905
|
+
contextWindow: guessContextWindow(modelName),
|
|
906
|
+
tools,
|
|
907
|
+
preset: toolPreset,
|
|
908
|
+
presetName: presetObj?.name || null,
|
|
909
|
+
effort,
|
|
910
|
+
fast,
|
|
911
|
+
agent: opts.agent,
|
|
912
|
+
owner: opts.owner || 'user',
|
|
913
|
+
mcpPid: process.pid,
|
|
914
|
+
scopeKey: opts.scopeKey || null,
|
|
915
|
+
lane: opts.lane || 'bridge',
|
|
916
|
+
cwd: opts.cwd,
|
|
917
|
+
createdAt: Date.now(),
|
|
918
|
+
updatedAt: Date.now(),
|
|
919
|
+
lastHeartbeatAt: null,
|
|
920
|
+
totalInputTokens: 0,
|
|
921
|
+
totalOutputTokens: 0,
|
|
922
|
+
// Refreshed on each completed ask() — surfaced by bridge type=list for
|
|
923
|
+
// debugging + consumed by store.mjs's idle-sweep to reclaim stalled
|
|
924
|
+
// bridge sessions past RUNNING_STALL_MS.
|
|
925
|
+
lastUsedAt: Date.now(),
|
|
926
|
+
tokensCumulative: 0,
|
|
927
|
+
role: opts.role || null,
|
|
928
|
+
taskType: opts.taskType || null,
|
|
929
|
+
maxLoopIterations: Number.isFinite(opts.maxLoopIterations) ? opts.maxLoopIterations : null,
|
|
930
|
+
// Bridge tag (auto worker{n} on spawn) persisted so the forked status
|
|
931
|
+
// process (statusline) + aggregator can read it from the session JSON.
|
|
932
|
+
// In-process send/close still resolve via _tagSessionRegistry.
|
|
933
|
+
bridgeTag: opts.bridgeTag || null,
|
|
934
|
+
// Prompt permission is separate from runtime toolPermission so preset
|
|
935
|
+
// restrictions do not fragment the bridge cache prefix.
|
|
936
|
+
permission: permission || null,
|
|
937
|
+
toolPermission: toolPermission || null,
|
|
938
|
+
// Origin tag written into every bridge-trace usage row so analytics
|
|
939
|
+
// can slice by (sourceType, sourceName) — e.g. maintenance/cycle1,
|
|
940
|
+
// scheduler/daily-standup, webhook/github-push, lead/worker.
|
|
941
|
+
sourceType: opts.sourceType || null,
|
|
942
|
+
sourceName: opts.sourceName || null,
|
|
943
|
+
// Provider-scoped unified cache key — one shard per provider,
|
|
944
|
+
// shared across all roles / sources (bridge/maintenance/mcp/
|
|
945
|
+
// scheduler/webhook). Role or source-specific context must be
|
|
946
|
+
// injected into the message tail, not the shared prefix.
|
|
947
|
+
promptCacheKey: providerCacheKey(presetObj?.provider || opts.provider, opts.cacheKeyOverride),
|
|
948
|
+
// Bridge shell continuity: when a bridge session explicitly opts into
|
|
949
|
+
// persistent shell state (`bash` with `persistent:true`, or direct
|
|
950
|
+
// `bash_session`), the minted bash_session id is stored here so later
|
|
951
|
+
// opted-in `bash` calls can reuse the same shell state.
|
|
952
|
+
implicitBashSessionId: null,
|
|
953
|
+
// Tracks every persistent bash session id minted during this
|
|
954
|
+
// orchestrator session so closeSession can kill them all, not just
|
|
955
|
+
// the most recently recorded one.
|
|
956
|
+
allBashSessionIds: [],
|
|
957
|
+
// Smart Bridge metadata — optional. Applied on every ask() to merge
|
|
958
|
+
// profile-driven cache settings into provider sendOpts.
|
|
959
|
+
profileId: profile?.id || null,
|
|
960
|
+
permissionMode: opts.permissionMode ?? null,
|
|
961
|
+
providerCacheOpts: providerCacheOpts || null,
|
|
962
|
+
ownerSessionId: opts.ownerSessionId || null,
|
|
963
|
+
clientHostPid: opts.clientHostPid || null,
|
|
964
|
+
};
|
|
965
|
+
// In-process registry + async debounced save: same-process create → load
|
|
966
|
+
// reads live memory; disk flush is for cross-process / restart durability.
|
|
967
|
+
setLiveSession(session);
|
|
968
|
+
saveSession(session);
|
|
969
|
+
return session;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ── Runtime liveness map ──────────────────────────────────────────────
|
|
973
|
+
// In-memory only. Tracks per-session stage + stream heartbeat so bridge type=list
|
|
974
|
+
// can surface whether a session is actually alive vs stuck. Never persisted —
|
|
975
|
+
// heartbeats would otherwise churn the session JSON on every SSE delta.
|
|
976
|
+
// Entry shape: {
|
|
977
|
+
// stage, lastStreamDeltaAt, lastToolCall, lastError, updatedAt,
|
|
978
|
+
// controller?: AbortController, // set while an ask is in flight
|
|
979
|
+
// generation?: number, // snapshot taken at ask start
|
|
980
|
+
// closed?: boolean, // flipped by closeSession()
|
|
981
|
+
// }
|
|
982
|
+
const _runtimeState = new Map();
|
|
983
|
+
const VALID_STAGES = new Set([
|
|
984
|
+
'connecting', 'requesting', 'streaming', 'tool_running', 'idle', 'error', 'done', 'cancelling',
|
|
985
|
+
]);
|
|
986
|
+
function _touchRuntime(id) {
|
|
987
|
+
let entry = _runtimeState.get(id);
|
|
988
|
+
if (!entry) {
|
|
989
|
+
entry = { stage: 'idle', lastStreamDeltaAt: null, lastToolCall: null, lastError: null, updatedAt: Date.now() };
|
|
990
|
+
_runtimeState.set(id, entry);
|
|
991
|
+
}
|
|
992
|
+
return entry;
|
|
993
|
+
}
|
|
994
|
+
export function updateSessionStage(id, stage) {
|
|
995
|
+
if (!id || !VALID_STAGES.has(stage)) return;
|
|
996
|
+
const entry = _touchRuntime(id);
|
|
997
|
+
const now = Date.now();
|
|
998
|
+
entry.stage = stage;
|
|
999
|
+
entry.lastProgressAt = now;
|
|
1000
|
+
entry.updatedAt = now;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Reset heartbeat-visible fields for a new ask. Preserves controller/generation/
|
|
1004
|
+
* closed (lifecycle) but clears the previous run's streaming state so stale
|
|
1005
|
+
* lastToolCall / lastStreamDeltaAt from the previous ask don't leak into the
|
|
1006
|
+
* new one.
|
|
1007
|
+
*/
|
|
1008
|
+
export function markSessionAskStart(id) {
|
|
1009
|
+
if (!id) return;
|
|
1010
|
+
const entry = _touchRuntime(id);
|
|
1011
|
+
entry.stage = 'connecting';
|
|
1012
|
+
entry.lastStreamDeltaAt = null;
|
|
1013
|
+
entry.lastToolCall = null;
|
|
1014
|
+
entry.lastError = null;
|
|
1015
|
+
// A new ask starts a fresh turn lifecycle — clear any stale empty-final
|
|
1016
|
+
// classification from the prior turn so inspectBridgeEntry doesn't keep
|
|
1017
|
+
// short-circuiting to 'empty-synthesis' (which would disable stall
|
|
1018
|
+
// detection for the entire new turn).
|
|
1019
|
+
entry.emptyFinal = false;
|
|
1020
|
+
entry.emptyFinalAt = null;
|
|
1021
|
+
// askStartedAt is the watchdog's fallback reference when a session
|
|
1022
|
+
// hangs before any stream delta arrives. Without it, a provider that
|
|
1023
|
+
// never returns a first token would stall forever because the watchdog
|
|
1024
|
+
// keys solely on lastStreamDeltaAt.
|
|
1025
|
+
const now = Date.now();
|
|
1026
|
+
entry.askStartedAt = now;
|
|
1027
|
+
entry.lastProgressAt = now;
|
|
1028
|
+
entry.updatedAt = now;
|
|
1029
|
+
// Publish heartbeat immediately so the status aggregator picks the
|
|
1030
|
+
// session up in the connecting / requesting window. Without this the
|
|
1031
|
+
// .hb file only landed on the first stream chunk — producing a 3–10s
|
|
1032
|
+
// (xhigh: 30s+) invisible gap where bridge sessions ran but the CC
|
|
1033
|
+
// statusline showed no maintenance/agent badge. STREAM_FRESH_MS (5 min)
|
|
1034
|
+
// still drops a session whose provider truly never returns a chunk;
|
|
1035
|
+
// markSessionStreamDelta keeps refreshing once chunks arrive.
|
|
1036
|
+
publishHeartbeat(id, now);
|
|
1037
|
+
}
|
|
1038
|
+
export async function markSessionStreamDelta(id) {
|
|
1039
|
+
if (!id) return;
|
|
1040
|
+
// Non-creating lookup: a live ask ALWAYS has a runtime entry (markSessionAskStart
|
|
1041
|
+
// creates it before streaming begins). _touchRuntime would instead resurrect a
|
|
1042
|
+
// blank entry — and closeSession()/idle-sweep clear _runtimeState on a deferred
|
|
1043
|
+
// tick while a detached provider stream may still be trickling deltas. A delta
|
|
1044
|
+
// arriving after that clear must NOT re-create an entry or it would republish the
|
|
1045
|
+
// .hb heartbeat that markSessionClosed deleted, orphaning a dead session's
|
|
1046
|
+
// heartbeat indefinitely (the disk tombstone blocks ask resumption but not this
|
|
1047
|
+
// path). Skip a missing, tombstoned, or aborted entry — never refresh liveness.
|
|
1048
|
+
const entry = _runtimeState.get(id);
|
|
1049
|
+
if (!entry || entry.closed || entry.controller?.signal?.aborted) return;
|
|
1050
|
+
const now = Date.now();
|
|
1051
|
+
entry.lastStreamDeltaAt = now;
|
|
1052
|
+
entry.lastProgressAt = now;
|
|
1053
|
+
// Only promote to 'streaming' if we were in a pre-stream stage; never downgrade
|
|
1054
|
+
// mid-tool (tool_running has its own delta source if the tool streams back).
|
|
1055
|
+
if (entry.stage === 'connecting' || entry.stage === 'requesting') {
|
|
1056
|
+
entry.stage = 'streaming';
|
|
1057
|
+
}
|
|
1058
|
+
// Lightweight heartbeat (≤5s self-throttled) for the status aggregator.
|
|
1059
|
+
// Disk-side session.lastHeartbeatAt below is the heavy 60s zombie-reaper
|
|
1060
|
+
// signal; the .hb file is the fast fresh-session signal consumed by the
|
|
1061
|
+
// status line.
|
|
1062
|
+
publishHeartbeat(id, now);
|
|
1063
|
+
const session = loadSession(id);
|
|
1064
|
+
if (session && now - (session.lastHeartbeatAt || 0) > HEARTBEAT_THROTTLE_MS) {
|
|
1065
|
+
session.lastHeartbeatAt = now;
|
|
1066
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1067
|
+
}
|
|
1068
|
+
entry.updatedAt = now;
|
|
1069
|
+
}
|
|
1070
|
+
export function markSessionToolCall(id, toolName) {
|
|
1071
|
+
if (!id) return;
|
|
1072
|
+
const entry = _touchRuntime(id);
|
|
1073
|
+
entry.stage = 'tool_running';
|
|
1074
|
+
entry.lastToolCall = toolName || null;
|
|
1075
|
+
entry.toolStartedAt = Date.now();
|
|
1076
|
+
entry.lastProgressAt = entry.toolStartedAt;
|
|
1077
|
+
entry.updatedAt = entry.toolStartedAt;
|
|
1078
|
+
publishHeartbeat(id, entry.toolStartedAt);
|
|
1079
|
+
}
|
|
1080
|
+
export function markSessionDone(id, { empty = false } = {}) {
|
|
1081
|
+
if (!id) return;
|
|
1082
|
+
const entry = _touchRuntime(id);
|
|
1083
|
+
entry.stage = 'done';
|
|
1084
|
+
entry.lastError = null;
|
|
1085
|
+
entry.askStartedAt = null;
|
|
1086
|
+
entry.toolStartedAt = null;
|
|
1087
|
+
// Non-empty completion: drop any stale empty-final flag so a subsequent
|
|
1088
|
+
// ask on the same reusable runtime entry starts clean. Empty-final
|
|
1089
|
+
// completions preserve the flag (set by markSessionEmptyFinal just prior).
|
|
1090
|
+
if (!empty) {
|
|
1091
|
+
entry.emptyFinal = false;
|
|
1092
|
+
entry.emptyFinalAt = null;
|
|
1093
|
+
}
|
|
1094
|
+
const doneTs = Date.now();
|
|
1095
|
+
entry.doneAt = doneTs;
|
|
1096
|
+
entry.lastProgressAt = doneTs;
|
|
1097
|
+
entry.updatedAt = doneTs;
|
|
1098
|
+
// Terminal stage — drop the heartbeat so the status badge releases
|
|
1099
|
+
// immediately. A subsequent ask on the same session re-publishes via
|
|
1100
|
+
// markSessionStreamDelta on the first chunk.
|
|
1101
|
+
deleteHeartbeat(id);
|
|
1102
|
+
}
|
|
1103
|
+
// Tag a session as having completed with empty final synthesis (no
|
|
1104
|
+
// content/reasoning). Distinct from `markSessionDone`: still a success
|
|
1105
|
+
// (no abort), but the stall watchdog and post-mortem tools can
|
|
1106
|
+
// distinguish "finished empty" from "finished with content" without
|
|
1107
|
+
// mistaking the silence for a stall.
|
|
1108
|
+
export function markSessionEmptyFinal(id) {
|
|
1109
|
+
if (!id) return;
|
|
1110
|
+
const entry = _touchRuntime(id);
|
|
1111
|
+
entry.emptyFinal = true;
|
|
1112
|
+
entry.emptyFinalAt = Date.now();
|
|
1113
|
+
}
|
|
1114
|
+
export function markSessionError(id, msg) {
|
|
1115
|
+
if (!id) return;
|
|
1116
|
+
const entry = _touchRuntime(id);
|
|
1117
|
+
entry.stage = 'error';
|
|
1118
|
+
entry.lastError = msg ? String(msg).slice(0, 200) : null;
|
|
1119
|
+
entry.askStartedAt = null;
|
|
1120
|
+
entry.toolStartedAt = null;
|
|
1121
|
+
// Error path is a non-empty completion (we have an error message, not a
|
|
1122
|
+
// silent empty final). Clear the flag so the next ask starts clean.
|
|
1123
|
+
entry.emptyFinal = false;
|
|
1124
|
+
entry.emptyFinalAt = null;
|
|
1125
|
+
const errTs = Date.now();
|
|
1126
|
+
entry.doneAt = errTs;
|
|
1127
|
+
entry.lastProgressAt = errTs;
|
|
1128
|
+
entry.updatedAt = errTs;
|
|
1129
|
+
deleteHeartbeat(id);
|
|
1130
|
+
}
|
|
1131
|
+
export function getSessionRuntime(id) {
|
|
1132
|
+
return id ? (_runtimeState.get(id) || null) : null;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* Iterate all active session runtimes. Used by the stream watchdog.
|
|
1136
|
+
* Returns an iterable of [sessionId, entry] pairs; consumers should
|
|
1137
|
+
* treat entries as read-only snapshots and avoid mutating them.
|
|
1138
|
+
*/
|
|
1139
|
+
export function forEachSessionRuntime() {
|
|
1140
|
+
return _runtimeState.entries();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// --- Incremental metric persistence (fix A) ---
|
|
1144
|
+
// Per-session idempotency tracking: sessionId → Set of seen iterationIndex keys.
|
|
1145
|
+
const _metricSeenIter = new Map();
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Persist incremental usage delta immediately after each provider.send iteration.
|
|
1149
|
+
* Idempotency key `sessionId:iterationIndex` ensures a retry of the same iteration
|
|
1150
|
+
* index overwrites instead of double-counting.
|
|
1151
|
+
*/
|
|
1152
|
+
export async function persistIterationMetrics(delta) {
|
|
1153
|
+
if (!delta || !delta.sessionId) return;
|
|
1154
|
+
const { sessionId, iterationIndex, deltaInput, deltaOutput, deltaCachedRead, deltaCacheWrite, ts } = delta;
|
|
1155
|
+
let seen = _metricSeenIter.get(sessionId);
|
|
1156
|
+
if (!seen) {
|
|
1157
|
+
seen = new Set();
|
|
1158
|
+
_metricSeenIter.set(sessionId, seen);
|
|
1159
|
+
}
|
|
1160
|
+
const ikey = `${sessionId}:${iterationIndex}`;
|
|
1161
|
+
const isReplay = seen.has(ikey);
|
|
1162
|
+
seen.add(ikey);
|
|
1163
|
+
const runtimeEntry = _runtimeState.get(sessionId);
|
|
1164
|
+
const session = runtimeEntry?.session ?? loadSession(sessionId);
|
|
1165
|
+
if (!session || session.closed) return;
|
|
1166
|
+
if (!isReplay) {
|
|
1167
|
+
session.totalInputTokens = (session.totalInputTokens || 0) + (deltaInput || 0);
|
|
1168
|
+
session.totalOutputTokens = (session.totalOutputTokens || 0) + (deltaOutput || 0);
|
|
1169
|
+
session.tokensCumulative = (session.tokensCumulative || 0) + (deltaInput || 0) + (deltaOutput || 0);
|
|
1170
|
+
// Cache totals — additive fields, default 0 on legacy sessions; both
|
|
1171
|
+
// are undefined-safe so the schema migrates lazily as new iterations
|
|
1172
|
+
// land. Keeps live + terminal aggregates in lock-step (loop.mjs already
|
|
1173
|
+
// includes cached_read / cache_write in its terminal usage rollup).
|
|
1174
|
+
session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (deltaCachedRead || 0);
|
|
1175
|
+
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (deltaCacheWrite || 0);
|
|
1176
|
+
// Window snapshot updated per iteration so bridge type=list reflects the
|
|
1177
|
+
// most-recent provider-reported input size even for short dispatches
|
|
1178
|
+
// that finish before askSession's terminal save lands.
|
|
1179
|
+
session.lastInputTokens = deltaInput || 0;
|
|
1180
|
+
session.lastOutputTokens = deltaOutput || 0;
|
|
1181
|
+
session.lastCachedReadTokens = deltaCachedRead || 0;
|
|
1182
|
+
// Normalized last-call context footprint: how many prompt tokens the
|
|
1183
|
+
// model actually saw on the most-recent send, comparable ACROSS
|
|
1184
|
+
// providers. Anthropic reports input_tokens EXCLUDING cache (cache_read
|
|
1185
|
+
// is a separate field), so the cached portion must be added back to
|
|
1186
|
+
// reflect real context size; openai/grok/gemini already fold cached
|
|
1187
|
+
// tokens INTO the input count, so input alone is the footprint.
|
|
1188
|
+
const _inputExcludesCache = providerInputExcludesCache(session.provider);
|
|
1189
|
+
session.lastContextTokens = _inputExcludesCache
|
|
1190
|
+
? (deltaInput || 0) + (deltaCachedRead || 0)
|
|
1191
|
+
: (deltaInput || 0);
|
|
1192
|
+
}
|
|
1193
|
+
session.lastIterationIndex = iterationIndex;
|
|
1194
|
+
session.updatedAt = ts || Date.now();
|
|
1195
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** Force-flush session metrics to disk. Used by watchdog terminal-reap (fix B). */
|
|
1199
|
+
export async function flushSessionMetrics(sessionId) {
|
|
1200
|
+
if (!sessionId) return;
|
|
1201
|
+
const session = loadSession(sessionId);
|
|
1202
|
+
if (!session) return;
|
|
1203
|
+
session.updatedAt = Date.now();
|
|
1204
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Mark session hidden so listSessions() filters it out (runtime-only). */
|
|
1208
|
+
export function hideSessionFromList(sessionId) {
|
|
1209
|
+
if (!sessionId) return;
|
|
1210
|
+
const entry = _runtimeState.get(sessionId);
|
|
1211
|
+
if (entry) entry.listHidden = true;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
export function getSessionAbortSignal(sessionId) {
|
|
1215
|
+
return _runtimeState.get(sessionId)?.controller?.signal ?? null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Return the most recent "session is making progress" timestamp.
|
|
1220
|
+
*
|
|
1221
|
+
* Combines three independent progress signals so an idle watchdog can stay
|
|
1222
|
+
* alive across both streaming and long tool calls:
|
|
1223
|
+
* - lastStreamDeltaAt: provider stream chunk landed
|
|
1224
|
+
* - toolStartedAt: a tool call just kicked off (nested tool work may
|
|
1225
|
+
* stall the outer stream for a while; this keeps the watchdog from
|
|
1226
|
+
* killing legitimate sub-agent runs)
|
|
1227
|
+
* - askStartedAt: ask just started; covers the pre-stream connect window
|
|
1228
|
+
*
|
|
1229
|
+
* Returns 0 when the runtime entry is unknown so callers can decide to
|
|
1230
|
+
* either skip the watchdog or treat 0 as "no progress yet".
|
|
1231
|
+
*/
|
|
1232
|
+
export function getSessionLastProgressAt(sessionId) {
|
|
1233
|
+
const entry = _runtimeState.get(sessionId);
|
|
1234
|
+
if (!entry) return 0;
|
|
1235
|
+
return Math.max(
|
|
1236
|
+
entry.lastStreamDeltaAt || 0,
|
|
1237
|
+
entry.toolStartedAt || 0,
|
|
1238
|
+
entry.askStartedAt || 0,
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Link a parent AbortSignal to a sub-session's controller so that aborting
|
|
1244
|
+
* the parent (fan-out deadline or caller ESC) tears down the bridge role's
|
|
1245
|
+
* provider call promptly. Safe to call after prepareBridgeSession but before
|
|
1246
|
+
* askSession completes. No-op if the session runtime isn't found.
|
|
1247
|
+
*
|
|
1248
|
+
* @param {string} sessionId — the sub-session to abort
|
|
1249
|
+
* @param {AbortSignal} parentSignal — upstream signal (from fan-out coordinator)
|
|
1250
|
+
*/
|
|
1251
|
+
export function linkParentSignalToSession(sessionId, parentSignal) {
|
|
1252
|
+
if (!(parentSignal instanceof AbortSignal)) return;
|
|
1253
|
+
const entry = _touchRuntime(sessionId);
|
|
1254
|
+
if (!entry.controller) entry.controller = createAbortController();
|
|
1255
|
+
if (parentSignal.aborted) {
|
|
1256
|
+
try { entry.controller.abort(new Error('parent signal aborted')); } catch { /* ignore */ }
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
parentSignal.addEventListener('abort', () => {
|
|
1260
|
+
try { entry.controller?.abort(new Error('parent signal aborted')); } catch { /* ignore */ }
|
|
1261
|
+
}, { once: true });
|
|
1262
|
+
}
|
|
1263
|
+
function _clearSessionRuntime(id) {
|
|
1264
|
+
if (id) {
|
|
1265
|
+
_runtimeState.delete(id);
|
|
1266
|
+
// R15: also drop the per-session metric-idempotency Set; otherwise it
|
|
1267
|
+
// grows O(sessions x iterations) for the whole server lifetime since
|
|
1268
|
+
// nothing else deletes from _metricSeenIter on session close.
|
|
1269
|
+
_metricSeenIter.delete(id);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* Wrap an async call so that if the session's controller aborts mid-flight,
|
|
1275
|
+
* the wrapper settles with a SessionClosedError even if the underlying promise
|
|
1276
|
+
* hasn't returned yet. The original promise is kept alive with a detached
|
|
1277
|
+
* `.catch()` to prevent unhandled-rejection warnings once it eventually
|
|
1278
|
+
* settles. Callers still must check generation/closed after await returns
|
|
1279
|
+
* to handle providers that ignore the AbortSignal entirely.
|
|
1280
|
+
*/
|
|
1281
|
+
export async function _api_call_with_interrupt(sessionId, fn) {
|
|
1282
|
+
const entry = _touchRuntime(sessionId);
|
|
1283
|
+
if (!entry.controller) entry.controller = createAbortController();
|
|
1284
|
+
const signal = entry.controller.signal;
|
|
1285
|
+
if (signal.aborted) throw new SessionClosedError(sessionId, 'aborted before call');
|
|
1286
|
+
const underlying = fn(signal);
|
|
1287
|
+
underlying.catch(() => {}); // prevent unhandled rejection if we race ahead
|
|
1288
|
+
let onAbort = null;
|
|
1289
|
+
const aborted = new Promise((_, reject) => {
|
|
1290
|
+
onAbort = () => reject(new SessionClosedError(sessionId, 'aborted during call'));
|
|
1291
|
+
if (signal.aborted) onAbort();
|
|
1292
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
1293
|
+
});
|
|
1294
|
+
try {
|
|
1295
|
+
return await Promise.race([underlying, aborted]);
|
|
1296
|
+
} finally {
|
|
1297
|
+
// If the underlying promise settled first, the abort listener is
|
|
1298
|
+
// still attached. Remove it to avoid accumulating listeners across
|
|
1299
|
+
// many asks on the same session.
|
|
1300
|
+
if (onAbort && !signal.aborted) {
|
|
1301
|
+
try { signal.removeEventListener('abort', onAbort); } catch { /* ignore */ }
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Per-session mutex: queues concurrent askSession calls to prevent message loss
|
|
1307
|
+
const _sessionLocks = new Map();
|
|
1308
|
+
// Per-session pending-message queue (Claude Code `pendingMessages` pattern).
|
|
1309
|
+
// A `bridge type=send` to a worker whose turn is still in flight ENQUEUES the
|
|
1310
|
+
// message here instead of rejecting; askSession drains the queue after each
|
|
1311
|
+
// turn and runs the messages as the next user turn(s), preserving order — the
|
|
1312
|
+
// queued send runs AFTER the in-flight prompt, which also closes the spawn
|
|
1313
|
+
// startup race (a send landing before the initial turn settles no longer
|
|
1314
|
+
// jumps ahead of the original prompt). Map<sessionId, string[]>. Shared with
|
|
1315
|
+
// index.mjs's bridge send handler via the enqueue/drain accessors below — one
|
|
1316
|
+
// queue contract, two call sites.
|
|
1317
|
+
const _sessionPendingMessages = new Map();
|
|
1318
|
+
export function enqueuePendingMessage(sessionId, message) {
|
|
1319
|
+
if (!sessionId || typeof message !== 'string' || !message) return 0;
|
|
1320
|
+
let q = _sessionPendingMessages.get(sessionId);
|
|
1321
|
+
if (!q) { q = []; _sessionPendingMessages.set(sessionId, q); }
|
|
1322
|
+
q.push(message);
|
|
1323
|
+
return q.length;
|
|
1324
|
+
}
|
|
1325
|
+
export function drainPendingMessages(sessionId) {
|
|
1326
|
+
const q = _sessionPendingMessages.get(sessionId);
|
|
1327
|
+
if (!q || q.length === 0) return [];
|
|
1328
|
+
_sessionPendingMessages.delete(sessionId);
|
|
1329
|
+
return q;
|
|
1330
|
+
}
|
|
1331
|
+
function acquireSessionLock(sessionId) {
|
|
1332
|
+
let entry = _sessionLocks.get(sessionId);
|
|
1333
|
+
if (!entry) {
|
|
1334
|
+
entry = { promise: Promise.resolve(), count: 0 };
|
|
1335
|
+
_sessionLocks.set(sessionId, entry);
|
|
1336
|
+
}
|
|
1337
|
+
entry.count++;
|
|
1338
|
+
const prev = entry.promise;
|
|
1339
|
+
let release;
|
|
1340
|
+
entry.promise = new Promise(r => { release = r; });
|
|
1341
|
+
// Self-heal: if the previous holder rejected, swallow so subsequent
|
|
1342
|
+
// queued waiters don't propagate that rejection and brick the lock chain.
|
|
1343
|
+
return prev.catch(() => {}).then(() => () => {
|
|
1344
|
+
entry.count--;
|
|
1345
|
+
if (entry.count === 0) _sessionLocks.delete(sessionId);
|
|
1346
|
+
release();
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
export async function askSession(sessionId, prompt, context, onToolCall, cwdOverride, explicitPrefetch) {
|
|
1351
|
+
const _askStartedAt = Date.now();
|
|
1352
|
+
const _promptSrc = 'prompt';
|
|
1353
|
+
const _prefetchFiles = (explicitPrefetch?.files?.length) || 0;
|
|
1354
|
+
const _prefetchCallers = (explicitPrefetch?.callers?.length) || 0;
|
|
1355
|
+
const _prefetchRefs = (explicitPrefetch?.references?.length) || 0;
|
|
1356
|
+
if (process.env.MIXDOG_DEBUG_BRIDGE) {
|
|
1357
|
+
process.stderr.write(`[bridge-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
|
|
1358
|
+
}
|
|
1359
|
+
const unlock = await acquireSessionLock(sessionId);
|
|
1360
|
+
const _lockWaitedMs = Date.now() - _askStartedAt;
|
|
1361
|
+
if (process.env.MIXDOG_DEBUG_BRIDGE) {
|
|
1362
|
+
process.stderr.write(`[bridge-trace] lock-acquired waitedMs=${_lockWaitedMs}\n`);
|
|
1363
|
+
}
|
|
1364
|
+
// The mutex is held for the WHOLE askSession call, including any follow-up
|
|
1365
|
+
// turns drained from the pending-message queue below — the single outer
|
|
1366
|
+
// try/finally releases it exactly once. _result holds the last turn's
|
|
1367
|
+
// return value (the queued tail turns supersede the original prompt's
|
|
1368
|
+
// result, mirroring how a live chat returns the latest turn).
|
|
1369
|
+
let _result;
|
|
1370
|
+
// Local FIFO of follow-up prompts drained from the pending-message queue
|
|
1371
|
+
// after each turn — keeps queued `bridge type=send` messages in order.
|
|
1372
|
+
const _pendingTail = [];
|
|
1373
|
+
// Hoisted so the outer finally (which runs once after the whole turn loop)
|
|
1374
|
+
// can compare against the last turn's generation.
|
|
1375
|
+
let askGeneration = 0;
|
|
1376
|
+
try {
|
|
1377
|
+
// Turn loop (pendingMessages pattern): run the current prompt, then drain
|
|
1378
|
+
// any `bridge type=send` messages that were queued while this turn was in
|
|
1379
|
+
// flight and run them — in order — as the next user turn(s). Because the
|
|
1380
|
+
// queued send always lands AFTER the in-flight prompt here, ordering is
|
|
1381
|
+
// preserved and the spawn/connecting startup race disappears.
|
|
1382
|
+
for (;;) {
|
|
1383
|
+
// After the first turn, the next prompt comes from the drained queue.
|
|
1384
|
+
// (On the first iteration _pendingTail is empty and `prompt` is the
|
|
1385
|
+
// caller's original message.)
|
|
1386
|
+
if (_pendingTail.length > 0) {
|
|
1387
|
+
prompt = _pendingTail.shift();
|
|
1388
|
+
// Queued follow-ups are plain user turns — no caller context /
|
|
1389
|
+
// prefetch is re-applied (those belonged to the original ask).
|
|
1390
|
+
context = null;
|
|
1391
|
+
explicitPrefetch = null;
|
|
1392
|
+
}
|
|
1393
|
+
// ── Synchronous pre-await setup (must happen before any await so
|
|
1394
|
+
// closeSession() can't interleave between load and registration) ──
|
|
1395
|
+
const preSession = loadSession(sessionId);
|
|
1396
|
+
if (!preSession) {
|
|
1397
|
+
throw new Error(`Session "${sessionId}" not found`);
|
|
1398
|
+
}
|
|
1399
|
+
if (preSession.closed === true) {
|
|
1400
|
+
throw new SessionClosedError(sessionId, 'session already closed');
|
|
1401
|
+
}
|
|
1402
|
+
askGeneration = typeof preSession.generation === 'number' ? preSession.generation : 0;
|
|
1403
|
+
const runtime = _touchRuntime(sessionId);
|
|
1404
|
+
// Fresh controller per ask — the previous ask's controller may have aborted.
|
|
1405
|
+
runtime.controller = createAbortController();
|
|
1406
|
+
runtime.generation = askGeneration;
|
|
1407
|
+
runtime.closed = false;
|
|
1408
|
+
markSessionAskStart(sessionId);
|
|
1409
|
+
// Preprocessing is inside try so provider-not-available / trim failures
|
|
1410
|
+
// fall into the catch and mark the session as errored rather than
|
|
1411
|
+
// leaving stage='connecting' forever.
|
|
1412
|
+
try {
|
|
1413
|
+
const session = preSession;
|
|
1414
|
+
const provider = getProvider(session.provider);
|
|
1415
|
+
// Register the live session object into runtime so closeSession()
|
|
1416
|
+
// can read allBashSessionIds that loop.mjs appends mid-turn.
|
|
1417
|
+
runtime.session = session;
|
|
1418
|
+
if (!provider)
|
|
1419
|
+
throw new Error(`Provider "${session.provider}" not available`);
|
|
1420
|
+
// Cap caller-supplied / prefetched context so an oversized
|
|
1421
|
+
// payload can't blow the session token budget before the
|
|
1422
|
+
// first model call. 32 KB ~ 8k tokens at the 4 B/tok
|
|
1423
|
+
// working average; longer is silently truncated with a
|
|
1424
|
+
// visible marker so the model still sees the prefix and
|
|
1425
|
+
// a hint about the cut.
|
|
1426
|
+
const _CTX_CHAR_CAP = 32 * 1024;
|
|
1427
|
+
const _capCtx = (text) => {
|
|
1428
|
+
if (typeof text !== 'string') return '';
|
|
1429
|
+
if (text.length <= _CTX_CHAR_CAP) return text;
|
|
1430
|
+
return `${text.slice(0, _CTX_CHAR_CAP)}\n\n... [context truncated; original ${text.length} chars]`;
|
|
1431
|
+
};
|
|
1432
|
+
// Inline context + prefetch INTO the prompt as a single user turn,
|
|
1433
|
+
// marked with explicit section headers. The previous design pushed
|
|
1434
|
+
// context as separate user messages with pre-injected assistant
|
|
1435
|
+
// "Noted." acks; that conversational pattern taught some models a
|
|
1436
|
+
// low-effort rhythm and they responded with "Noted." / empty tags
|
|
1437
|
+
// even to the real task. Single-turn structure with a labelled
|
|
1438
|
+
// `# Task` block forces the model to treat the brief as the work
|
|
1439
|
+
// unit, not as another piece of context to ack.
|
|
1440
|
+
const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
|
|
1441
|
+
let _contextBlock = '';
|
|
1442
|
+
if (context) {
|
|
1443
|
+
_contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
|
|
1444
|
+
}
|
|
1445
|
+
if (explicitPrefetchResult) {
|
|
1446
|
+
_contextBlock += `# Prefetch\n${_capCtx(explicitPrefetchResult)}\n\n`;
|
|
1447
|
+
}
|
|
1448
|
+
const beforeCount = session.messages.length + 1;
|
|
1449
|
+
// Soft warning only; real size management (compaction primary,
|
|
1450
|
+
// byte-budget trim as safety net) lives in agentLoop. Selecting a
|
|
1451
|
+
// 25% pre-trim here would starve compaction's 50% threshold.
|
|
1452
|
+
const softBudget = Math.floor(session.contextWindow * 0.25);
|
|
1453
|
+
const promptTokenEstimate = prompt.length * 0.5; // conservative for CJK
|
|
1454
|
+
if (promptTokenEstimate > softBudget * 0.7) {
|
|
1455
|
+
process.stderr.write(`[session] Warning: prompt is very large (est. ${Math.round(promptTokenEstimate)} tokens vs ${softBudget} soft budget)\n`);
|
|
1456
|
+
}
|
|
1457
|
+
const effectiveCwd = cwdOverride || session.cwd;
|
|
1458
|
+
const _userTurnContent = _contextBlock
|
|
1459
|
+
? `${_contextBlock}# Task\n${prompt}`
|
|
1460
|
+
: prompt;
|
|
1461
|
+
const outgoing = [...session.messages, { role: 'user', content: _userTurnContent }];
|
|
1462
|
+
// Per-turn injected-context trace row (complements kind:"usage").
|
|
1463
|
+
// Cheap byte-length accounting — no hashing, no payload bodies.
|
|
1464
|
+
// Honors the same MIXDOG_BRIDGE_TRACE_DISABLE gate as usage rows;
|
|
1465
|
+
// appendBridgeTrace is a no-op when that env is set.
|
|
1466
|
+
try {
|
|
1467
|
+
const _ctxBytes = Buffer.byteLength(context || '', 'utf8');
|
|
1468
|
+
const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
|
|
1469
|
+
const _promptBytes = Buffer.byteLength(prompt || '', 'utf8');
|
|
1470
|
+
const _userTurnBytes = Buffer.byteLength(_userTurnContent, 'utf8');
|
|
1471
|
+
const _messagesBytes = Buffer.byteLength(JSON.stringify(session.messages || []), 'utf8');
|
|
1472
|
+
const _totalBytes = _userTurnBytes + _messagesBytes;
|
|
1473
|
+
appendBridgeTrace({
|
|
1474
|
+
kind: 'context',
|
|
1475
|
+
sessionId,
|
|
1476
|
+
model: session.model,
|
|
1477
|
+
provider: session.provider,
|
|
1478
|
+
totalBytes: _totalBytes,
|
|
1479
|
+
breakdown: {
|
|
1480
|
+
contextBytes: _ctxBytes,
|
|
1481
|
+
prefetchBytes: _prefetchBytes,
|
|
1482
|
+
promptBytes: _promptBytes,
|
|
1483
|
+
userTurnBytes: _userTurnBytes,
|
|
1484
|
+
messagesBytes: _messagesBytes,
|
|
1485
|
+
messagesCount: Array.isArray(session.messages) ? session.messages.length : 0,
|
|
1486
|
+
},
|
|
1487
|
+
});
|
|
1488
|
+
} catch { /* trace must never break the ask path */ }
|
|
1489
|
+
const result = await _api_call_with_interrupt(sessionId, (signal) =>
|
|
1490
|
+
agentLoop(provider, outgoing, session.model, session.tools, onToolCall, effectiveCwd, {
|
|
1491
|
+
effort: session.effort || null,
|
|
1492
|
+
fast: session.fast === true,
|
|
1493
|
+
sessionId,
|
|
1494
|
+
onUsageDelta: (d) => persistIterationMetrics(d).catch(() => {}),
|
|
1495
|
+
promptCacheKey: session.promptCacheKey || sessionId,
|
|
1496
|
+
// Provider-scoped cache key (mixdog-codex, mixdog-claude…).
|
|
1497
|
+
// Distinct from sessionId — providers that pool sockets
|
|
1498
|
+
// per-session (openai-oauth WS) use sessionId as the
|
|
1499
|
+
// pool bucket and providerCacheKey as the server-side
|
|
1500
|
+
// prompt-cache shard so parallel callers don't collide
|
|
1501
|
+
// on a mid-turn socket while still sharing prefix cache.
|
|
1502
|
+
providerCacheKey: session.promptCacheKey || null,
|
|
1503
|
+
signal,
|
|
1504
|
+
providerState: session.providerState ?? undefined,
|
|
1505
|
+
session,
|
|
1506
|
+
// Smart Bridge cache settings — merged last so session overrides
|
|
1507
|
+
// don't get overridden by defaults. When session has no profile,
|
|
1508
|
+
// providerCacheOpts is null and this spread is a no-op.
|
|
1509
|
+
...(session.providerCacheOpts || {}),
|
|
1510
|
+
onStageChange: (stage) => updateSessionStage(sessionId, stage),
|
|
1511
|
+
onStreamDelta: () => markSessionStreamDelta(sessionId).catch(() => {}),
|
|
1512
|
+
}),
|
|
1513
|
+
);
|
|
1514
|
+
// Post-loop validation: if closeSession() landed while we were awaiting,
|
|
1515
|
+
// drop the save so the tombstone on disk isn't overwritten.
|
|
1516
|
+
const currentRuntime = _runtimeState.get(sessionId);
|
|
1517
|
+
if (currentRuntime?.closed || currentRuntime?.generation !== askGeneration) {
|
|
1518
|
+
const reason = currentRuntime?.closedReason;
|
|
1519
|
+
throw new SessionClosedError(sessionId, `closed during call (reason=${reason || 'unknown'})`, reason || null);
|
|
1520
|
+
}
|
|
1521
|
+
// Update and save. outgoing is mutated in place by agentLoop
|
|
1522
|
+
// (compaction + safety trim), so its length reflects post-loop state.
|
|
1523
|
+
const messagesDropped = Math.max(0, beforeCount - outgoing.length);
|
|
1524
|
+
session.messages = outgoing;
|
|
1525
|
+
if (result.content || result.reasoningContent) {
|
|
1526
|
+
session.messages.push({
|
|
1527
|
+
role: 'assistant',
|
|
1528
|
+
content: result.content || '',
|
|
1529
|
+
...(typeof result.reasoningContent === 'string' && result.reasoningContent
|
|
1530
|
+
? { reasoningContent: result.reasoningContent }
|
|
1531
|
+
: {}),
|
|
1532
|
+
});
|
|
1533
|
+
} else {
|
|
1534
|
+
// Empty terminal turn: still persist a forensic record so
|
|
1535
|
+
// post-mortem inspection can distinguish "work landed but
|
|
1536
|
+
// synthesis missing" from "session never ran". Stop reason,
|
|
1537
|
+
// usage, iterations, and tool-call totals survive even when
|
|
1538
|
+
// the assistant produced no content/reasoning.
|
|
1539
|
+
const _emptyStop = result?.stopReason ?? result?.stop_reason ?? null;
|
|
1540
|
+
const _emptyUsage = result?.usage ? {
|
|
1541
|
+
inputTokens: result.usage.inputTokens || 0,
|
|
1542
|
+
outputTokens: result.usage.outputTokens || 0,
|
|
1543
|
+
cachedTokens: result.usage.cachedTokens || 0,
|
|
1544
|
+
cacheWriteTokens: result.usage.cacheWriteTokens || 0,
|
|
1545
|
+
} : null;
|
|
1546
|
+
// Provider content-block classification — distinguishes a
|
|
1547
|
+
// thinking-only stall (model emitted reasoning blocks but no
|
|
1548
|
+
// text/tool_use) from a true silent empty turn. Anthropic
|
|
1549
|
+
// providers (anthropic.mjs, anthropic-oauth.mjs) set these
|
|
1550
|
+
// fields on the result; other providers may omit them.
|
|
1551
|
+
const _emptyHasThinking = typeof result?.hasThinkingContent === 'boolean'
|
|
1552
|
+
? result.hasThinkingContent
|
|
1553
|
+
: null;
|
|
1554
|
+
const _emptyBlockTypes = Array.isArray(result?.contentBlockTypes)
|
|
1555
|
+
? result.contentBlockTypes.slice()
|
|
1556
|
+
: null;
|
|
1557
|
+
session.messages.push({
|
|
1558
|
+
role: 'assistant',
|
|
1559
|
+
content: '',
|
|
1560
|
+
emptyFinal: true,
|
|
1561
|
+
stopReason: _emptyStop,
|
|
1562
|
+
iterations: result?.iterations ?? null,
|
|
1563
|
+
toolCallsTotal: result?.toolCallsTotal ?? null,
|
|
1564
|
+
usage: _emptyUsage,
|
|
1565
|
+
...(_emptyHasThinking !== null ? { hasThinkingContent: _emptyHasThinking } : {}),
|
|
1566
|
+
...(_emptyBlockTypes !== null ? { contentBlockTypes: _emptyBlockTypes } : {}),
|
|
1567
|
+
ts: Date.now(),
|
|
1568
|
+
});
|
|
1569
|
+
try {
|
|
1570
|
+
const _blockTypesStr = _emptyBlockTypes ? _emptyBlockTypes.join(',') || 'none' : 'unknown';
|
|
1571
|
+
const _thinkingStr = _emptyHasThinking === null ? 'unknown' : String(_emptyHasThinking);
|
|
1572
|
+
process.stderr.write(`[session] empty-final persisted sessionId=${sessionId} stopReason=${_emptyStop ?? 'unknown'} iterations=${result?.iterations ?? 0} toolCallsTotal=${result?.toolCallsTotal ?? 0} outTokens=${_emptyUsage?.outputTokens ?? 0} hasThinking=${_thinkingStr} blockTypes=${_blockTypesStr}\n`);
|
|
1573
|
+
} catch {}
|
|
1574
|
+
}
|
|
1575
|
+
session.updatedAt = Date.now();
|
|
1576
|
+
session.lastUsedAt = Date.now();
|
|
1577
|
+
if (result.usage) {
|
|
1578
|
+
session.totalInputTokens += result.usage.inputTokens;
|
|
1579
|
+
session.totalOutputTokens += result.usage.outputTokens;
|
|
1580
|
+
session.tokensCumulative = (session.tokensCumulative || 0)
|
|
1581
|
+
+ (result.usage.inputTokens || 0)
|
|
1582
|
+
+ (result.usage.outputTokens || 0);
|
|
1583
|
+
// Cache totals — same `||0` undefined-safe accumulation pattern as
|
|
1584
|
+
// persistIterationMetrics so live + terminal paths stay in lock-step
|
|
1585
|
+
// and legacy sessions migrate lazily on first iteration.
|
|
1586
|
+
session.totalCachedReadTokens = (session.totalCachedReadTokens || 0) + (result.usage.cachedTokens || 0);
|
|
1587
|
+
session.totalCacheWriteTokens = (session.totalCacheWriteTokens || 0) + (result.usage.cacheWriteTokens || 0);
|
|
1588
|
+
// Window snapshot = the current context size, which is the LAST
|
|
1589
|
+
// single call — NOT result.usage (that is lastUsage, the per-turn
|
|
1590
|
+
// SUM accumulated with += across iterations in agentLoop). Use
|
|
1591
|
+
// lastTurnUsage (the final iteration's raw usage) so this reflects
|
|
1592
|
+
// "what's in the window now" rather than the lifetime sum.
|
|
1593
|
+
const _lastTurn = result.lastTurnUsage || result.usage || {};
|
|
1594
|
+
session.lastInputTokens = _lastTurn.inputTokens || 0;
|
|
1595
|
+
session.lastOutputTokens = _lastTurn.outputTokens || 0;
|
|
1596
|
+
session.lastCachedReadTokens = _lastTurn.cachedTokens || 0;
|
|
1597
|
+
session.lastCacheWriteTokens = _lastTurn.cacheWriteTokens || 0;
|
|
1598
|
+
// Provider-normalized footprint, identical formula to
|
|
1599
|
+
// persistIterationMetrics so both writers agree: Anthropic
|
|
1600
|
+
// input_tokens excludes cache (add it back), openai/grok/gemini
|
|
1601
|
+
// already include it.
|
|
1602
|
+
const _inputExcludesCache = providerInputExcludesCache(session.provider);
|
|
1603
|
+
session.lastContextTokens = _inputExcludesCache
|
|
1604
|
+
? (_lastTurn.inputTokens || 0) + (_lastTurn.cachedTokens || 0)
|
|
1605
|
+
: (_lastTurn.inputTokens || 0);
|
|
1606
|
+
}
|
|
1607
|
+
// Smart Bridge cache stats — record hit/miss after every successful
|
|
1608
|
+
// ask so the registry reflects all bridge traffic, not just
|
|
1609
|
+
// maintenance cycles. Guarded against any smart-bridge error so
|
|
1610
|
+
// metric recording never breaks the ask itself.
|
|
1611
|
+
let prefixHashForLog = null;
|
|
1612
|
+
if (session.profileId && result.usage && _smartBridgeApi) {
|
|
1613
|
+
try {
|
|
1614
|
+
const profile = _smartBridgeApi.getProfile(session.profileId);
|
|
1615
|
+
if (profile) {
|
|
1616
|
+
// Collect every leading system-role message (BP1, BP2, ...)
|
|
1617
|
+
// until the first non-system message so the registry hash
|
|
1618
|
+
// captures the full ordered provider prefix, not just BP1.
|
|
1619
|
+
const systemMsgs = [];
|
|
1620
|
+
for (const m of session.messages) {
|
|
1621
|
+
if (m?.role !== 'system') break;
|
|
1622
|
+
systemMsgs.push(typeof m.content === 'string' ? m.content : '');
|
|
1623
|
+
}
|
|
1624
|
+
_smartBridgeApi.recordCall(profile, session.provider, {
|
|
1625
|
+
systemPrompt: systemMsgs,
|
|
1626
|
+
tools: session.tools || [],
|
|
1627
|
+
usage: result.usage,
|
|
1628
|
+
});
|
|
1629
|
+
const entry = _smartBridgeApi.registry?.data?.profiles?.[session.profileId]?.[session.provider];
|
|
1630
|
+
prefixHashForLog = entry?.prefixHash || null;
|
|
1631
|
+
}
|
|
1632
|
+
} catch {}
|
|
1633
|
+
}
|
|
1634
|
+
// Append to bridge-trace.jsonl with the rich bridge usage fields.
|
|
1635
|
+
if (result.usage) {
|
|
1636
|
+
const inputTokens = result.usage.inputTokens || 0;
|
|
1637
|
+
const outputTokens = result.usage.outputTokens || 0;
|
|
1638
|
+
const cacheReadTokens = result.usage.cachedTokens || 0;
|
|
1639
|
+
const cacheWriteTokens = result.usage.cacheWriteTokens || 0;
|
|
1640
|
+
// Unified total-prompt field. Anthropic = input+cache_read+cache_write
|
|
1641
|
+
// (additive); OpenAI/Codex/Gemini = input_tokens already includes the
|
|
1642
|
+
// cached portion (inclusive), so the fallback must not double-count.
|
|
1643
|
+
const { isInclusiveProvider, computeCostUsd } = await import('../../../shared/llm/cost.mjs');
|
|
1644
|
+
const inclusive = isInclusiveProvider(session.provider);
|
|
1645
|
+
const promptTokens = typeof result.usage.promptTokens === 'number'
|
|
1646
|
+
? result.usage.promptTokens
|
|
1647
|
+
: (inclusive
|
|
1648
|
+
? Math.max(inputTokens, cacheReadTokens + cacheWriteTokens)
|
|
1649
|
+
: inputTokens + cacheReadTokens + cacheWriteTokens);
|
|
1650
|
+
let costUsd = result.usage.costUsd || 0;
|
|
1651
|
+
if (!costUsd) {
|
|
1652
|
+
try {
|
|
1653
|
+
costUsd = computeCostUsd({
|
|
1654
|
+
model: session.model,
|
|
1655
|
+
provider: session.provider,
|
|
1656
|
+
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
1657
|
+
});
|
|
1658
|
+
} catch { /* best-effort */ }
|
|
1659
|
+
}
|
|
1660
|
+
logLlmCall({
|
|
1661
|
+
ts: new Date().toISOString(),
|
|
1662
|
+
sourceType: session.sourceType || 'lead',
|
|
1663
|
+
sourceName: session.sourceName || session.role || null,
|
|
1664
|
+
preset: session.presetName || null,
|
|
1665
|
+
model: session.model,
|
|
1666
|
+
provider: session.provider,
|
|
1667
|
+
duration: Date.now() - _askStartedAt,
|
|
1668
|
+
profileId: session.profileId || null,
|
|
1669
|
+
sessionId: session.id,
|
|
1670
|
+
inputTokens,
|
|
1671
|
+
outputTokens,
|
|
1672
|
+
cacheReadTokens,
|
|
1673
|
+
cacheWriteTokens,
|
|
1674
|
+
promptTokens,
|
|
1675
|
+
prefixHash: prefixHashForLog,
|
|
1676
|
+
costUsd,
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
// Persist opaque providerState for future stateful providers.
|
|
1680
|
+
// No provider currently emits it (Codex OAuth is stateless per
|
|
1681
|
+
// contract), so this branch is dormant — kept so a future
|
|
1682
|
+
// Responses-API provider with stable continuation can plug in
|
|
1683
|
+
// without reworking the session shape.
|
|
1684
|
+
if (result.providerState !== undefined) {
|
|
1685
|
+
session.providerState = result.providerState;
|
|
1686
|
+
}
|
|
1687
|
+
await saveSessionAsync(session, { expectedGeneration: askGeneration });
|
|
1688
|
+
// Tag empty-synthesis BEFORE markSessionDone so the watchdog
|
|
1689
|
+
// (which inspects entry.emptyFinal first) classifies the
|
|
1690
|
+
// terminal state correctly even if it ticks during unwind.
|
|
1691
|
+
const isEmptyFinal = !result.content && !result.reasoningContent;
|
|
1692
|
+
if (isEmptyFinal) {
|
|
1693
|
+
markSessionEmptyFinal(sessionId);
|
|
1694
|
+
}
|
|
1695
|
+
markSessionDone(sessionId, { empty: isEmptyFinal });
|
|
1696
|
+
_result = {
|
|
1697
|
+
...result,
|
|
1698
|
+
trimmed: messagesDropped > 0,
|
|
1699
|
+
messagesDropped,
|
|
1700
|
+
};
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
if (err instanceof SessionClosedError) {
|
|
1703
|
+
// Cancellation is not an error; propagate silently so callers
|
|
1704
|
+
// can render it as "cancelled" rather than a red failure.
|
|
1705
|
+
throw err;
|
|
1706
|
+
}
|
|
1707
|
+
markSessionError(sessionId, err && err.message ? err.message : String(err));
|
|
1708
|
+
throw err;
|
|
1709
|
+
}
|
|
1710
|
+
// ── Turn complete. Drain the pending-message queue (Claude Code
|
|
1711
|
+
// pendingMessages): any `bridge type=send` that arrived while this
|
|
1712
|
+
// turn was in flight runs next, in order, as a follow-up user turn.
|
|
1713
|
+
// The mutex is still held, so a send racing this drain either landed
|
|
1714
|
+
// before (picked up here) or enqueues for the next loop. When the
|
|
1715
|
+
// queue is empty we return the latest turn's result. ──
|
|
1716
|
+
const _drained = drainPendingMessages(sessionId);
|
|
1717
|
+
if (_drained.length > 0) {
|
|
1718
|
+
_pendingTail.push(..._drained);
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
return _result;
|
|
1722
|
+
}
|
|
1723
|
+
} finally {
|
|
1724
|
+
// Clear the controller only if it's still ours (closeSession may have
|
|
1725
|
+
// swapped it). Leave the rest of the runtime entry intact so bridge type=list
|
|
1726
|
+
// can still surface the final stage (done/error/cancelling).
|
|
1727
|
+
const entry = _runtimeState.get(sessionId);
|
|
1728
|
+
if (entry && entry.generation === askGeneration) {
|
|
1729
|
+
entry.controller = null;
|
|
1730
|
+
// Detach the live session reference; ask is over.
|
|
1731
|
+
entry.session = null;
|
|
1732
|
+
}
|
|
1733
|
+
unlock();
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
// Session lookup by scopeKey — used by CLI bridge to resume a pinned
|
|
1737
|
+
// scope session when the caller passes --scope (agent/<name>).
|
|
1738
|
+
export function findSessionByScopeKey(scopeKey) {
|
|
1739
|
+
if (!scopeKey) return null;
|
|
1740
|
+
const sessions = listStoredSessions();
|
|
1741
|
+
// Exclude tombstoned sessions (`closed === true`) so callers never receive
|
|
1742
|
+
// a session whose controller was aborted by closeSession(). The `closed`
|
|
1743
|
+
// bit is the authoritative tombstone flag; `status === 'error'` is not,
|
|
1744
|
+
// since transient-error sessions remain resumable.
|
|
1745
|
+
return sessions.find(s => s.scopeKey === scopeKey && s.closed !== true) || null;
|
|
1746
|
+
}
|
|
1747
|
+
// --- resume (reload tools for a stored session) ---
|
|
1748
|
+
export async function resumeSession(sessionId, preset) {
|
|
1749
|
+
const session = loadSession(sessionId);
|
|
1750
|
+
if (!session)
|
|
1751
|
+
return null;
|
|
1752
|
+
// Resuming a closed session is a resurrection attempt — refuse. The guarded
|
|
1753
|
+
// save below would also block the write, but failing fast here is cleaner
|
|
1754
|
+
// than silently dropping the tool-refresh side effects.
|
|
1755
|
+
if (session.closed === true) return null;
|
|
1756
|
+
if (!session.owner) session.owner = 'user';
|
|
1757
|
+
// Refresh tools (MCP connections may have changed).
|
|
1758
|
+
// Re-resolve from profile.tools when the session stored a profileId —
|
|
1759
|
+
// otherwise fall back to preset.tools. Same resolution order as
|
|
1760
|
+
// createSession so resume and spawn produce identical BP_1 shapes.
|
|
1761
|
+
const oldTools = session.tools || [];
|
|
1762
|
+
const skills = collectSkillsCached(session.cwd);
|
|
1763
|
+
let toolSpec = preset || session.preset || 'full';
|
|
1764
|
+
if (session.profileId && _smartBridgeApi?.getProfile) {
|
|
1765
|
+
try {
|
|
1766
|
+
const profile = _smartBridgeApi.getProfile(session.profileId);
|
|
1767
|
+
if (Array.isArray(profile?.tools)) toolSpec = profile.tools;
|
|
1768
|
+
} catch { /* ignore lookup failures, keep preset fallback */ }
|
|
1769
|
+
}
|
|
1770
|
+
session.tools = resolveSessionTools(toolSpec, skills, { ownerIsBridge: session.owner === 'bridge' });
|
|
1771
|
+
const newTools = session.tools;
|
|
1772
|
+
const missing = oldTools.filter(t => !newTools.find(n => n.name === t.name));
|
|
1773
|
+
if (missing.length) {
|
|
1774
|
+
process.stderr.write(`[session] Warning: ${missing.length} tools no longer available: ${missing.map(t => t.name).join(', ')}\n`);
|
|
1775
|
+
}
|
|
1776
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1777
|
+
return session;
|
|
1778
|
+
}
|
|
1779
|
+
// --- CRUD ---
|
|
1780
|
+
export function getSession(id) {
|
|
1781
|
+
return loadSession(id);
|
|
1782
|
+
}
|
|
1783
|
+
export function listSessions() {
|
|
1784
|
+
const sessions = listStoredSessions();
|
|
1785
|
+
const hiddenIds = new Set([..._runtimeState.entries()].filter(([, e]) => e.listHidden).map(([id]) => id));
|
|
1786
|
+
// Always exclude tombstoned sessions (closed===true) — closeSession plants the tombstone.
|
|
1787
|
+
return sessions.filter(s => s.closed !== true && !hiddenIds.has(s.id));
|
|
1788
|
+
}
|
|
1789
|
+
// --- Clear messages (keep system prompt + provider/model/cwd) ---
|
|
1790
|
+
export async function clearSessionMessages(sessionId) {
|
|
1791
|
+
const session = loadSession(sessionId);
|
|
1792
|
+
if (!session)
|
|
1793
|
+
return false;
|
|
1794
|
+
// Don't resurrect a closed session just to clear its messages.
|
|
1795
|
+
if (session.closed === true) return false;
|
|
1796
|
+
session.messages = (session.messages || []).filter(m => m && m.role === 'system');
|
|
1797
|
+
session.totalInputTokens = 0;
|
|
1798
|
+
session.totalOutputTokens = 0;
|
|
1799
|
+
session.updatedAt = Date.now();
|
|
1800
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1801
|
+
return true;
|
|
1802
|
+
}
|
|
1803
|
+
export async function updateSessionStatus(id, status) {
|
|
1804
|
+
const session = loadSession(id);
|
|
1805
|
+
if (!session) return false;
|
|
1806
|
+
// Respect tombstones — don't resurrect a closed session just to update a
|
|
1807
|
+
// status label (bridge handler emits running→idle/error around askSession).
|
|
1808
|
+
if (session.closed === true) return false;
|
|
1809
|
+
session.status = status;
|
|
1810
|
+
session.updatedAt = Date.now();
|
|
1811
|
+
await saveSessionAsync(session, { expectedGeneration: session.generation });
|
|
1812
|
+
return true;
|
|
1813
|
+
}
|
|
1814
|
+
/**
|
|
1815
|
+
* Close a session. Plants a `closed=true` tombstone on disk with a bumped
|
|
1816
|
+
* generation (so any racing saveSession() drops its write), aborts the
|
|
1817
|
+
* in-flight controller if one exists, and clears the in-memory runtime entry.
|
|
1818
|
+
*
|
|
1819
|
+
* IMPORTANT: we deliberately do NOT unlink the session file here. The tombstone
|
|
1820
|
+
* on disk is the authoritative signal that blocks resurrection — a late
|
|
1821
|
+
* saveSession() re-reads disk via _shouldDrop() and will find the tombstone.
|
|
1822
|
+
* If we delete the file, a late save sees no file, decides nothing to drop,
|
|
1823
|
+
* and recreates the session in its pre-close state.
|
|
1824
|
+
*
|
|
1825
|
+
* Long-term cleanup: `sweepTombstones()` below unlinks tombstones older than
|
|
1826
|
+
* TOMBSTONE_MAX_AGE_MS (24h — vastly longer than any realistic in-flight race).
|
|
1827
|
+
*/
|
|
1828
|
+
export function closeSession(id, reason = 'manual') {
|
|
1829
|
+
if (!id) return false;
|
|
1830
|
+
// Prefer in-memory runtime session — allBashSessionIds may not be persisted
|
|
1831
|
+
// yet for shells opened in the current turn (BL-bash-disk-sync).
|
|
1832
|
+
const inMemory = _runtimeState.get(id)?.session;
|
|
1833
|
+
const persisted = inMemory || loadSession(id);
|
|
1834
|
+
const bashSessionId = persisted?.implicitBashSessionId || null;
|
|
1835
|
+
// Collect all persistent bash shells created during this session.
|
|
1836
|
+
const allBashIds = Array.isArray(persisted?.allBashSessionIds)
|
|
1837
|
+
? persisted.allBashSessionIds.filter(Boolean)
|
|
1838
|
+
: (bashSessionId ? [bashSessionId] : []);
|
|
1839
|
+
// Deduplicate: allBashIds already covers implicitBashSessionId, but guard
|
|
1840
|
+
// against old session records that only have implicitBashSessionId.
|
|
1841
|
+
if (bashSessionId && !allBashIds.includes(bashSessionId)) allBashIds.push(bashSessionId);
|
|
1842
|
+
// 1. Tombstone first — this wins the race against saveSession().
|
|
1843
|
+
const newGen = markSessionClosed(id, reason);
|
|
1844
|
+
// 2. Mark runtime as closed so post-await validation in askSession fires.
|
|
1845
|
+
const entry = _runtimeState.get(id);
|
|
1846
|
+
if (entry) {
|
|
1847
|
+
entry.closed = true;
|
|
1848
|
+
entry.closedReason = reason;
|
|
1849
|
+
if (typeof newGen === 'number') entry.generation = newGen;
|
|
1850
|
+
entry.stage = 'cancelling';
|
|
1851
|
+
entry.updatedAt = Date.now();
|
|
1852
|
+
// 3. Abort the in-flight controller. Providers that honour the signal
|
|
1853
|
+
// unwind immediately; providers that don't will still be caught by
|
|
1854
|
+
// the generation check after their await eventually returns.
|
|
1855
|
+
try { entry.controller?.abort(new SessionClosedError(id, `closeSession (reason=${reason})`, reason)); } catch { /* ignore */ }
|
|
1856
|
+
}
|
|
1857
|
+
// Diagnostic: one-line stderr so operators can distinguish the four close
|
|
1858
|
+
// pathways (request-abort / manual / idle-sweep / runner-crash). iterCount
|
|
1859
|
+
// is not currently tracked on runtime state; askStartedAt is — derive
|
|
1860
|
+
// duration from it when present.
|
|
1861
|
+
try {
|
|
1862
|
+
const askStartedAt = entry?.askStartedAt;
|
|
1863
|
+
const durationMs = (typeof askStartedAt === 'number') ? (Date.now() - askStartedAt) : null;
|
|
1864
|
+
const parts = [`session=${id}`, `reason=${reason}`];
|
|
1865
|
+
if (durationMs != null) parts.push(`duration=${durationMs}ms`);
|
|
1866
|
+
process.stderr.write(`[bridge-close] ${parts.join(' ')}\n`);
|
|
1867
|
+
} catch { /* best-effort */ }
|
|
1868
|
+
for (const bsid of allBashIds) {
|
|
1869
|
+
try { closeBashSession(bsid, `bridge-close:${id}`); } catch { /* ignore */ }
|
|
1870
|
+
}
|
|
1871
|
+
// Drop session-scoped read dedup cache so the Map doesn't accumulate
|
|
1872
|
+
// entries across mcp-server lifetime.
|
|
1873
|
+
try { clearReadDedupSession(id); } catch { /* ignore */ }
|
|
1874
|
+
// Drop offload sidecars + module-level counter for this session so a
|
|
1875
|
+
// long-running mcp-server doesn't leak disk (tool-results/<id>/*.txt)
|
|
1876
|
+
// or Map entries across session lifetime. Fire-and-forget — close path
|
|
1877
|
+
// should not await disk IO; errors are swallowed inside.
|
|
1878
|
+
try { clearOffloadSession(id); } catch { /* ignore */ }
|
|
1879
|
+
// 4. Defer runtime map clear to next tick so any settling askSession can
|
|
1880
|
+
// observe `closed=true` / bumped generation before we yank the entry.
|
|
1881
|
+
// Disk tombstone remains — that's what blocks resurrection.
|
|
1882
|
+
setImmediate(() => {
|
|
1883
|
+
_clearSessionRuntime(id);
|
|
1884
|
+
});
|
|
1885
|
+
return true;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// --- Periodic idle session cleanup ---
|
|
1889
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // check every 5 minutes
|
|
1890
|
+
const TOMBSTONE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h — far longer than any realistic ask race window
|
|
1891
|
+
let _cleanupTimer = null;
|
|
1892
|
+
|
|
1893
|
+
function sweepIdleSessions() {
|
|
1894
|
+
try {
|
|
1895
|
+
const { cleaned, remaining, details } = sweepStaleSessions();
|
|
1896
|
+
if (cleaned > 0) {
|
|
1897
|
+
for (const d of details) {
|
|
1898
|
+
// Skip entries with an active in-flight controller — aborting
|
|
1899
|
+
// them via closeSession() is the safe path; clearing the runtime
|
|
1900
|
+
// without signalling the controller leaves orphan provider work.
|
|
1901
|
+
const rtEntry = _runtimeState.get(d.id);
|
|
1902
|
+
if (rtEntry && rtEntry.controller && !rtEntry.controller.signal?.aborted) {
|
|
1903
|
+
try { closeSession(d.id, 'idle-sweep'); } catch { /* ignore */ }
|
|
1904
|
+
} else {
|
|
1905
|
+
_clearSessionRuntime(d.id);
|
|
1906
|
+
if (d.bashSessionId) {
|
|
1907
|
+
try { closeBashSession(d.bashSessionId, `idle-sweep:${d.id}`); } catch { /* ignore */ }
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
process.stderr.write(`[bridge-session] idle cleanup: closed ${d.id} (idle ${d.idleMinutes}m, owner=${d.owner})\n`);
|
|
1911
|
+
}
|
|
1912
|
+
process.stderr.write(`[bridge-session] idle sweep: cleaned ${cleaned} session(s), ${remaining} remaining\n`);
|
|
1913
|
+
}
|
|
1914
|
+
} catch (e) {
|
|
1915
|
+
process.stderr.write(`[bridge-session] idle sweep error: ${e && e.message || e}\n`);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Unlink tombstone session files (closed=true) older than TOMBSTONE_MAX_AGE_MS.
|
|
1921
|
+
*
|
|
1922
|
+
* Rationale: closeSession() leaves the tombstone on disk as the authoritative
|
|
1923
|
+
* resurrection-blocker for racing saveSession() calls. That race resolves in
|
|
1924
|
+
* microseconds (the window inside _doSave between temp write and rename), so
|
|
1925
|
+
* 24h is vastly safe. After the TTL expires we reclaim the disk slot.
|
|
1926
|
+
*
|
|
1927
|
+
* Uses `getStoredSessionsRaw()` rather than `listStoredSessions()` because the
|
|
1928
|
+
* latter's inline 30-min idle cleanup would race-unlink tombstones before we
|
|
1929
|
+
* get to log them — we want to own the unlink decision and stderr line here.
|
|
1930
|
+
*/
|
|
1931
|
+
export function sweepTombstones() {
|
|
1932
|
+
try {
|
|
1933
|
+
const now = Date.now();
|
|
1934
|
+
const sessions = getStoredSessionsRaw();
|
|
1935
|
+
let cleaned = 0;
|
|
1936
|
+
for (const s of sessions) {
|
|
1937
|
+
if (!s.closed) continue;
|
|
1938
|
+
const updated = Number(s.updatedAt);
|
|
1939
|
+
if (!Number.isFinite(updated)) continue;
|
|
1940
|
+
const age = now - updated;
|
|
1941
|
+
if (age < TOMBSTONE_MAX_AGE_MS) continue;
|
|
1942
|
+
try {
|
|
1943
|
+
deleteSession(s.id);
|
|
1944
|
+
_clearSessionRuntime(s.id);
|
|
1945
|
+
cleaned++;
|
|
1946
|
+
process.stderr.write(`[session-sweep] unlinked tombstone ${s.id} (age=${Math.floor(age / 1000)}s)\n`);
|
|
1947
|
+
} catch (e) {
|
|
1948
|
+
process.stderr.write(`[session-sweep] unlink failed ${s.id}: ${e && e.message || e}\n`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
return cleaned;
|
|
1952
|
+
} catch (e) {
|
|
1953
|
+
process.stderr.write(`[session-sweep] tombstone sweep error: ${e && e.message || e}\n`);
|
|
1954
|
+
return 0;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function _runCleanupCycle() {
|
|
1959
|
+
sweepIdleSessions();
|
|
1960
|
+
sweepTombstones();
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
export function startIdleCleanup() {
|
|
1964
|
+
if (_cleanupTimer) return;
|
|
1965
|
+
_runCleanupCycle();
|
|
1966
|
+
_cleanupTimer = setInterval(_runCleanupCycle, CLEANUP_INTERVAL_MS);
|
|
1967
|
+
if (_cleanupTimer.unref) _cleanupTimer.unref(); // don't block process exit
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
export function stopIdleCleanup() {
|
|
1971
|
+
if (_cleanupTimer) {
|
|
1972
|
+
clearInterval(_cleanupTimer);
|
|
1973
|
+
_cleanupTimer = null;
|
|
1974
|
+
}
|
|
1975
|
+
}
|