@reticlehq/server 2.3.0 → 2.5.0
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/SKILL.md +702 -0
- package/dist/bridge/auth-failure-reason.d.ts +19 -0
- package/dist/bridge/auth-failure-reason.js +29 -0
- package/dist/{bridge-security.d.ts → bridge/bridge-security.d.ts} +1 -1
- package/dist/{bridge-security.js → bridge/bridge-security.js} +1 -1
- package/dist/{bridge.d.ts → bridge/bridge.d.ts} +1 -1
- package/dist/{bridge.js → bridge/bridge.js} +105 -15
- package/dist/{bridge.test-harness.d.ts → bridge/bridge.test-harness.d.ts} +6 -11
- package/dist/{bridge.test-harness.js → bridge/bridge.test-harness.js} +21 -9
- package/dist/{pairing-token.js → bridge/pairing-token.js} +1 -1
- package/dist/capsule/capsule-store.d.ts +8 -3
- package/dist/capsule/capsule-store.js +7 -2
- package/dist/capsule/capsule.js +3 -3
- package/dist/capsule/causal-summary.d.ts +8 -0
- package/dist/capsule/causal-summary.js +22 -12
- package/dist/capsule/divergence.d.ts +9 -4
- package/dist/capsule/divergence.js +8 -8
- package/dist/capsule/predicate-to-links.js +11 -8
- package/dist/{cli-doctor.js → cli/cli-doctor.js} +9 -2
- package/dist/{cli-flow-commands.d.ts → cli/cli-flow-commands.d.ts} +2 -2
- package/dist/{cli-flow-commands.js → cli/cli-flow-commands.js} +15 -15
- package/dist/{cli-launch.d.ts → cli/cli-launch.d.ts} +32 -3
- package/dist/cli/cli-launch.js +171 -0
- package/dist/cli/cli-parse.d.ts +143 -0
- package/dist/{cli-parse.js → cli/cli-parse.js} +146 -65
- package/dist/{cli-port.d.ts → cli/cli-port.d.ts} +15 -0
- package/dist/{cli-port.js → cli/cli-port.js} +29 -8
- package/dist/cli/cli-update-commands.d.ts +4 -0
- package/dist/cli/cli-update-commands.js +93 -0
- package/dist/{cli-verify.js → cli/cli-verify.js} +14 -14
- package/dist/{cloud-cli.js → cli/cloud-cli.js} +29 -24
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +182 -93
- package/dist/cloud/cloud-config.d.ts +0 -1
- package/dist/cloud/cloud-config.js +9 -9
- package/dist/cloud/cloud-sync.d.ts +1 -7
- package/dist/cloud/cloud-sync.js +12 -12
- package/dist/crawl/crawl-empty.d.ts +16 -0
- package/dist/crawl/crawl-empty.js +24 -0
- package/dist/crawl/crawl-tools.js +4 -1
- package/dist/crawl/crawl.js +26 -7
- package/dist/daemon/daemon-resilience.d.ts +49 -0
- package/dist/daemon/daemon-resilience.js +191 -0
- package/dist/daemon/daemon-usefulness.d.ts +61 -0
- package/dist/daemon/daemon-usefulness.js +62 -0
- package/dist/{daemon.d.ts → daemon/daemon.d.ts} +33 -8
- package/dist/{daemon.js → daemon/daemon.js} +80 -48
- package/dist/daemon/idle-grace.d.ts +32 -0
- package/dist/daemon/idle-grace.js +41 -0
- package/dist/{idle-shutdown.d.ts → daemon/idle-shutdown.d.ts} +13 -0
- package/dist/{idle-shutdown.js → daemon/idle-shutdown.js} +27 -4
- package/dist/domain/domain-model.js +2 -2
- package/dist/events/body-failures.js +8 -8
- package/dist/events/contradictions.d.ts +1 -1
- package/dist/events/contradictions.js +60 -10
- package/dist/events/echo-mismatch.js +10 -10
- package/dist/events/event-filters.js +6 -6
- package/dist/events/json-salvage.d.ts +2 -1
- package/dist/events/json-salvage.js +6 -6
- package/dist/events/observed-in-window.d.ts +12 -0
- package/dist/events/observed-in-window.js +21 -0
- package/dist/events/predicate-eval.d.ts +43 -20
- package/dist/events/predicate-eval.js +159 -55
- package/dist/events/predicate.d.ts +2 -2
- package/dist/events/predicate.js +112 -35
- package/dist/events/reconcile.js +5 -5
- package/dist/events/ring-buffer.js +1 -1
- package/dist/events/stale-response.js +6 -6
- package/dist/events/testid-near-miss.d.ts +22 -0
- package/dist/events/testid-near-miss.js +33 -0
- package/dist/events/unit-mismatch.js +8 -8
- package/dist/flows/affected-tools.js +19 -4
- package/dist/flows/affected.js +1 -1
- package/dist/flows/annotate-target.d.ts +1 -0
- package/dist/flows/annotate-target.js +20 -0
- package/dist/flows/annotate-tools.js +25 -3
- package/dist/flows/annotate.js +17 -4
- package/dist/flows/assertion-integrity.d.ts +2 -1
- package/dist/flows/assertion-tiers-store.d.ts +3 -66
- package/dist/flows/assertion-tiers-store.js +2 -2
- package/dist/flows/attributed-failure.d.ts +17 -0
- package/dist/flows/attributed-failure.js +20 -0
- package/dist/flows/buddy-status.d.ts +2 -1
- package/dist/flows/buddy-status.js +2 -2
- package/dist/flows/coverage.d.ts +3 -2
- package/dist/flows/coverage.js +2 -2
- package/dist/flows/decision.d.ts +0 -6
- package/dist/flows/decision.js +64 -8
- package/dist/flows/empty-flow.d.ts +6 -0
- package/dist/flows/empty-flow.js +24 -0
- package/dist/flows/flake-store.d.ts +0 -1
- package/dist/flows/flake-store.js +1 -1
- package/dist/flows/flake.d.ts +2 -1
- package/dist/flows/flake.js +2 -2
- package/dist/flows/flow-classify.js +12 -0
- package/dist/flows/flow-replay-run.js +4 -4
- package/dist/flows/flow-replay.d.ts +5 -7
- package/dist/flows/flow-replay.js +114 -47
- package/dist/flows/flow-report.js +1 -1
- package/dist/flows/flow-scope.d.ts +1 -6
- package/dist/flows/flow-step-runners.d.ts +26 -6
- package/dist/flows/flow-step-runners.js +157 -6
- package/dist/flows/flow-success.js +20 -15
- package/dist/flows/flow-tools.js +67 -13
- package/dist/flows/flows.js +15 -11
- package/dist/flows/gate.d.ts +4 -3
- package/dist/flows/gate.js +1 -1
- package/dist/flows/heal-run.d.ts +0 -8
- package/dist/flows/heal-run.js +5 -5
- package/dist/flows/heal.js +3 -3
- package/dist/flows/predicate-to-expect.d.ts +38 -0
- package/dist/flows/predicate-to-expect.js +95 -0
- package/dist/flows/replay.d.ts +16 -1
- package/dist/flows/replay.js +58 -22
- package/dist/flows/role-anchor-nearest.d.ts +6 -0
- package/dist/flows/role-anchor-nearest.js +45 -0
- package/dist/flows/server-verify.d.ts +1 -1
- package/dist/flows/server-verify.js +5 -5
- package/dist/flows/verify-change-tools.js +32 -10
- package/dist/flows/verify-change-verdict.d.ts +15 -0
- package/dist/flows/verify-change-verdict.js +25 -0
- package/dist/flows/watch-batcher.d.ts +3 -2
- package/dist/honesty/already-true.d.ts +2 -0
- package/dist/honesty/already-true.js +31 -0
- package/dist/honesty/blind-spots.d.ts +16 -3
- package/dist/honesty/blind-spots.js +24 -12
- package/dist/honesty/honesty.d.ts +3 -2
- package/dist/honesty/honesty.js +4 -4
- package/dist/honesty/uncaptured-bodies.js +1 -1
- package/dist/honesty/unread-outcome.js +2 -2
- package/dist/honesty/verified.d.ts +22 -3
- package/dist/honesty/verified.js +48 -8
- package/dist/http-server.js +14 -7
- package/dist/hunt/hunt-report.d.ts +2 -1
- package/dist/hunt/hunt-report.js +2 -2
- package/dist/index.d.ts +8 -6
- package/dist/index.js +32 -25
- package/dist/init/agent-rules.d.ts +2 -1
- package/dist/init/agent-rules.js +45 -7
- package/dist/init/app-choice.d.ts +20 -0
- package/dist/init/app-choice.js +25 -0
- package/dist/init/astro-host.d.ts +21 -0
- package/dist/init/astro-host.js +27 -0
- package/dist/init/astro-patch.d.ts +16 -0
- package/dist/init/astro-patch.js +240 -0
- package/dist/init/capabilities.d.ts +17 -0
- package/dist/init/capabilities.js +54 -0
- package/dist/init/cra.d.ts +37 -0
- package/dist/init/cra.js +90 -0
- package/dist/init/cursor.js +39 -4
- package/dist/init/desktop-doctor.d.ts +3 -2
- package/dist/init/desktop-doctor.js +4 -4
- package/dist/init/detect.d.ts +26 -0
- package/dist/init/detect.js +78 -5
- package/dist/init/mcp-registered.d.ts +12 -0
- package/dist/init/mcp-registered.js +14 -0
- package/dist/init/mcp.d.ts +27 -2
- package/dist/init/mcp.js +40 -4
- package/dist/init/next-patch.d.ts +39 -0
- package/dist/init/next-patch.js +196 -0
- package/dist/init/node-io.js +48 -6
- package/dist/init/patch-kind.d.ts +20 -0
- package/dist/init/patch-kind.js +10 -0
- package/dist/init/plan-framework.d.ts +50 -0
- package/dist/init/plan-framework.js +341 -0
- package/dist/init/plan.d.ts +119 -6
- package/dist/init/plan.js +230 -131
- package/dist/init/project-id.d.ts +5 -9
- package/dist/init/project-id.js +10 -22
- package/dist/init/run.d.ts +22 -0
- package/dist/init/run.js +393 -28
- package/dist/init/slash-command.d.ts +27 -0
- package/dist/init/slash-command.js +61 -0
- package/dist/init/snippets.d.ts +41 -3
- package/dist/init/snippets.js +185 -10
- package/dist/init/vite-config.d.ts +4 -10
- package/dist/init/vite-config.js +15 -7
- package/dist/init/workspace-apps.d.ts +23 -0
- package/dist/init/workspace-apps.js +68 -0
- package/dist/input/driven-redaction.d.ts +15 -0
- package/dist/input/driven-redaction.js +76 -0
- package/dist/input/network-detail.d.ts +9 -3
- package/dist/input/network-detail.js +43 -23
- package/dist/input/network-mock-tools.js +2 -2
- package/dist/input/network-mock.js +4 -4
- package/dist/input/real-input.js +8 -3
- package/dist/input/scroll-find.js +2 -2
- package/dist/input/scroll-tools.d.ts +0 -5
- package/dist/input/scroll-tools.js +11 -4
- package/dist/input/viewport-tools.js +2 -2
- package/dist/journal/ambient-store.d.ts +0 -2
- package/dist/journal/ambient-store.js +1 -1
- package/dist/journal/ambient.js +1 -1
- package/dist/journal/attach-journal.d.ts +2 -1
- package/dist/journal/deviation-report.js +6 -6
- package/dist/journal/deviation-service.js +1 -1
- package/dist/journal/envelope-store.d.ts +0 -2
- package/dist/journal/envelope-store.js +1 -1
- package/dist/journal/envelope.d.ts +2 -3
- package/dist/journal/envelope.js +3 -3
- package/dist/journal/journal-query.js +1 -1
- package/dist/journal/journal-recorder.d.ts +2 -1
- package/dist/journal/journal-recorder.js +2 -2
- package/dist/journal/rollups.js +9 -9
- package/dist/journal/session-end.d.ts +2 -1
- package/dist/journal/session-journal.js +4 -4
- package/dist/license/license.d.ts +0 -14
- package/dist/license/license.js +8 -8
- package/dist/log.d.ts +10 -0
- package/dist/log.js +11 -1
- package/dist/mcp/mcp-is-error.d.ts +13 -0
- package/dist/mcp/mcp-is-error.js +18 -0
- package/dist/mcp/mcp-outage.d.ts +31 -0
- package/dist/mcp/mcp-outage.js +48 -0
- package/dist/mcp/mcp-proxy.d.ts +124 -0
- package/dist/mcp/mcp-proxy.js +697 -0
- package/dist/{mcp.d.ts → mcp/mcp.d.ts} +13 -10
- package/dist/{mcp.js → mcp/mcp.js} +110 -35
- package/dist/mcp/proxy-handshake.d.ts +3 -0
- package/dist/mcp/proxy-handshake.js +54 -0
- package/dist/mcp/proxy-lifecycle.d.ts +64 -0
- package/dist/mcp/proxy-lifecycle.js +70 -0
- package/dist/mcp/tool-catalog-cache.d.ts +29 -0
- package/dist/mcp/tool-catalog-cache.js +55 -0
- package/dist/oracles/propose-consequences.d.ts +2 -1
- package/dist/oracles/propose-consequences.js +16 -11
- package/dist/oracles/self-instrument.d.ts +2 -1
- package/dist/oracles/self-instrument.js +1 -1
- package/dist/phenomena/phenomena.js +3 -3
- package/dist/platform.d.ts +16 -0
- package/dist/platform.js +16 -0
- package/dist/pool/playwright-launcher.d.ts +15 -2
- package/dist/pool/playwright-launcher.js +21 -2
- package/dist/project/fs-port.js +1 -1
- package/dist/project/project-tools.js +2 -2
- package/dist/runs/build-verification-run.js +1 -1
- package/dist/runs/repair-prompt.d.ts +0 -6
- package/dist/runs/repair-prompt.js +3 -3
- package/dist/runs/risk-classify.js +1 -1
- package/dist/runs/run-diff.d.ts +0 -2
- package/dist/runs/run-diff.js +3 -3
- package/dist/runs/run-tools.js +2 -2
- package/dist/runs/verification-sync.js +2 -2
- package/dist/runs/verify-http.js +1 -1
- package/dist/runs/verify-server.js +1 -1
- package/dist/session/command-timeout.d.ts +11 -3
- package/dist/session/command-timeout.js +2 -2
- package/dist/session/control-envelope.d.ts +6 -0
- package/dist/session/control-envelope.js +7 -1
- package/dist/session/dev-server-probe.d.ts +13 -0
- package/dist/session/dev-server-probe.js +52 -0
- package/dist/session/last-act.d.ts +17 -3
- package/dist/session/last-act.js +16 -4
- package/dist/session/live-control-tools.js +7 -2
- package/dist/session/live-control.js +1 -1
- package/dist/session/looks-like-dev-server.d.ts +14 -0
- package/dist/session/looks-like-dev-server.js +25 -0
- package/dist/session/no-session-diagnosis.d.ts +38 -0
- package/dist/session/no-session-diagnosis.js +70 -0
- package/dist/session/no-session-watch.d.ts +19 -0
- package/dist/session/no-session-watch.js +61 -0
- package/dist/session/observed-state.d.ts +4 -0
- package/dist/session/observed-state.js +19 -1
- package/dist/session/output-budget.js +1 -1
- package/dist/session/session-health.d.ts +8 -1
- package/dist/session/session-health.js +8 -7
- package/dist/session/session-info.d.ts +26 -7
- package/dist/session/session-info.js +42 -1
- package/dist/session/session-lease.d.ts +0 -11
- package/dist/session/session-lease.js +1 -1
- package/dist/session/session-manager.d.ts +3 -0
- package/dist/session/session-manager.js +58 -9
- package/dist/session/session-readiness.d.ts +1 -1
- package/dist/session/session-readiness.js +6 -1
- package/dist/session/session-reaper.js +1 -1
- package/dist/session/session-reconnect.d.ts +32 -0
- package/dist/session/session-reconnect.js +42 -0
- package/dist/session/session.d.ts +20 -2
- package/dist/session/session.js +76 -26
- package/dist/session/state-select.d.ts +1 -1
- package/dist/session/state-select.js +1 -1
- package/dist/status-payload.d.ts +19 -0
- package/dist/status-payload.js +11 -0
- package/dist/telemetry/argument-shape.js +10 -1
- package/dist/telemetry/browser-mode.d.ts +27 -0
- package/dist/telemetry/browser-mode.js +33 -0
- package/dist/telemetry/bug-found.d.ts +17 -21
- package/dist/telemetry/bug-found.js +120 -8
- package/dist/telemetry/cli-telemetry.d.ts +2 -12
- package/dist/telemetry/cli-telemetry.js +27 -4
- package/dist/telemetry/daemon-telemetry.d.ts +19 -0
- package/dist/telemetry/daemon-telemetry.js +12 -2
- package/dist/telemetry/dev-repo.js +4 -4
- package/dist/telemetry/error-fingerprint.js +21 -2
- package/dist/telemetry/feedback-cli.d.ts +1 -1
- package/dist/telemetry/feedback-cli.js +4 -4
- package/dist/telemetry/feedback-context.d.ts +22 -0
- package/dist/telemetry/feedback-context.js +41 -1
- package/dist/telemetry/feedback-delivery.d.ts +20 -0
- package/dist/telemetry/feedback-delivery.js +30 -0
- package/dist/telemetry/feedback-outbox.d.ts +22 -0
- package/dist/telemetry/feedback-outbox.js +87 -0
- package/dist/telemetry/feedback.d.ts +22 -1
- package/dist/telemetry/feedback.js +67 -8
- package/dist/telemetry/git-facts.js +1 -1
- package/dist/telemetry/session-metrics.d.ts +2 -0
- package/dist/telemetry/session-metrics.js +68 -6
- package/dist/telemetry/telemetry.d.ts +27 -2
- package/dist/telemetry/telemetry.js +119 -29
- package/dist/telemetry/verification-of.d.ts +22 -0
- package/dist/telemetry/verification-of.js +84 -0
- package/dist/tools/act-capsule.js +2 -2
- package/dist/tools/act-danger.d.ts +12 -0
- package/dist/tools/act-danger.js +20 -3
- package/dist/tools/act-helpers.js +4 -4
- package/dist/tools/act-tools.d.ts +0 -8
- package/dist/tools/act-tools.js +84 -115
- package/dist/tools/act-view.js +7 -7
- package/dist/tools/alias-args.d.ts +17 -0
- package/dist/tools/alias-args.js +25 -0
- package/dist/tools/assert-grade.js +9 -9
- package/dist/tools/assert-verdict.d.ts +3 -1
- package/dist/tools/assert-verdict.js +8 -5
- package/dist/tools/browser-tools.js +30 -11
- package/dist/tools/contract-tools.js +1 -1
- package/dist/tools/coverage-identity.d.ts +34 -0
- package/dist/tools/coverage-identity.js +46 -0
- package/dist/tools/coverage-tools.js +10 -14
- package/dist/tools/dynamic-tools.d.ts +2 -1
- package/dist/tools/dynamic-tools.js +90 -18
- package/dist/tools/error-recovery.d.ts +20 -2
- package/dist/tools/error-recovery.js +179 -4
- package/dist/tools/feedback-tools.d.ts +12 -1
- package/dist/tools/feedback-tools.js +28 -5
- package/dist/tools/invoke-tool.d.ts +0 -6
- package/dist/tools/invoke-tool.js +57 -32
- package/dist/tools/lease-hint.d.ts +17 -0
- package/dist/tools/lease-hint.js +24 -0
- package/dist/tools/lease-tools.js +6 -5
- package/dist/tools/merge-tools.d.ts +10 -1
- package/dist/tools/merge-tools.js +9 -3
- package/dist/tools/merged-name-redirect.d.ts +13 -0
- package/dist/tools/merged-name-redirect.js +55 -0
- package/dist/tools/navigate-result.d.ts +17 -0
- package/dist/tools/navigate-result.js +31 -0
- package/dist/tools/observe-tools.js +54 -20
- package/dist/tools/observed-nothing.d.ts +25 -0
- package/dist/tools/observed-nothing.js +33 -0
- package/dist/tools/query-paginate.js +2 -2
- package/dist/tools/query-shape.d.ts +18 -0
- package/dist/tools/query-shape.js +39 -0
- package/dist/tools/read-tools.js +85 -20
- package/dist/tools/real-input-attempt.d.ts +28 -0
- package/dist/tools/real-input-attempt.js +88 -0
- package/dist/tools/reconcile-tools.js +7 -7
- package/dist/tools/reload-result.d.ts +14 -0
- package/dist/tools/reload-result.js +29 -0
- package/dist/tools/snapshot-delta.d.ts +4 -5
- package/dist/tools/snapshot-delta.js +0 -0
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +83 -0
- package/dist/tools/tool-surface.js +204 -0
- package/dist/tools/tools-helpers.js +6 -6
- package/dist/tools/tools.d.ts +15 -0
- package/dist/tools/tools.js +53 -17
- package/dist/tools/unadvertised-help.d.ts +5 -0
- package/dist/tools/unadvertised-help.js +39 -0
- package/dist/trace.d.ts +69 -0
- package/dist/trace.js +150 -0
- package/dist/update/nudge-credit.d.ts +4 -0
- package/dist/update/nudge-credit.js +53 -0
- package/dist/update/sdk-sync.d.ts +21 -0
- package/dist/update/sdk-sync.js +42 -0
- package/dist/update/update-checker.js +3 -3
- package/dist/update/update-nudge.d.ts +63 -6
- package/dist/update/update-nudge.js +128 -15
- package/dist/update/updater.d.ts +1 -9
- package/dist/update/updater.js +13 -8
- package/dist/version/peer-announce.d.ts +4 -0
- package/dist/version/peer-announce.js +30 -0
- package/dist/{server-version.js → version/server-version.js} +5 -1
- package/dist/version/version-nudge.d.ts +28 -0
- package/dist/version/version-nudge.js +40 -0
- package/dist/version/version-skew.d.ts +66 -0
- package/dist/version/version-skew.js +71 -0
- package/dist/visual/visual-diff.js +2 -2
- package/dist/visual/visual-tools.js +4 -4
- package/docs/agent-cheatsheet.md +85 -0
- package/docs/architecture.md +113 -0
- package/docs/benchmarks.md +255 -0
- package/docs/debugging.md +102 -0
- package/docs/deploy-checks.md +69 -0
- package/docs/desktop-apps.md +256 -0
- package/docs/enterprise.md +62 -0
- package/docs/fixtures.md +28 -0
- package/docs/flows.md +166 -0
- package/docs/getting-started.md +469 -0
- package/docs/human-control.md +63 -0
- package/docs/integration-patterns.md +171 -0
- package/docs/local-registry.md +126 -0
- package/docs/mint.json +61 -0
- package/docs/multi-agent-testing.md +55 -0
- package/docs/platform-integration.md +122 -0
- package/docs/telemetry-contract.md +214 -0
- package/docs/telemetry.md +184 -0
- package/docs/testing.md +82 -0
- package/docs/token-efficiency.md +80 -0
- package/docs/usage.md +1046 -0
- package/package.json +8 -5
- package/dist/cli-launch.js +0 -112
- package/dist/cli-parse.d.ts +0 -140
- package/dist/daemon-resilience.d.ts +0 -22
- package/dist/daemon-resilience.js +0 -85
- package/dist/mcp-proxy.d.ts +0 -35
- package/dist/mcp-proxy.js +0 -190
- package/dist/tools/profiles.d.ts +0 -37
- package/dist/tools/profiles.js +0 -139
- /package/dist/{pairing-token.d.ts → bridge/pairing-token.d.ts} +0 -0
- /package/dist/{token-auth.d.ts → bridge/token-auth.d.ts} +0 -0
- /package/dist/{token-auth.js → bridge/token-auth.js} +0 -0
- /package/dist/{cli-doctor.d.ts → cli/cli-doctor.d.ts} +0 -0
- /package/dist/{cli-verify.d.ts → cli/cli-verify.d.ts} +0 -0
- /package/dist/{cloud-cli.d.ts → cli/cloud-cli.d.ts} +0 -0
- /package/dist/{server-version.d.ts → version/server-version.d.ts} +0 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import { localInitializeResponse, isHandshakeLine } from './proxy-handshake.js';
|
|
3
|
+
import { ToolCatalogCache } from './tool-catalog-cache.js';
|
|
4
|
+
import * as net from 'node:net';
|
|
5
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { LOOPBACK_HOST, MCP_SSE_PATH, ReticleDir, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
9
|
+
import { SERVER_VERSION } from '../version/server-version.js';
|
|
10
|
+
import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
|
|
11
|
+
import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, OnDrop, OnRequest, } from './proxy-lifecycle.js';
|
|
12
|
+
import { log } from '../log.js';
|
|
13
|
+
import { OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
14
|
+
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* How long to wait for the spawned daemon's port to accept connections before giving up. The default
|
|
17
|
+
* suits a normal machine; a slow CI/VM (heavy headless-browser launch) can raise it via the
|
|
18
|
+
* RETICLE_DAEMON_READY_TIMEOUT_MS env var. Invalid/absent values fall back to the default.
|
|
19
|
+
*/
|
|
20
|
+
const envDaemonReadyTimeoutMs = Number(process.env['RETICLE_DAEMON_READY_TIMEOUT_MS']);
|
|
21
|
+
const DAEMON_READY_TIMEOUT_MS = Number.isFinite(envDaemonReadyTimeoutMs) && envDaemonReadyTimeoutMs > 0
|
|
22
|
+
? envDaemonReadyTimeoutMs
|
|
23
|
+
: DEFAULT_DAEMON_READY_TIMEOUT_MS;
|
|
24
|
+
const DAEMON_POLL_INTERVAL_MS = 100;
|
|
25
|
+
function delay(ms) {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
/** Reconnect backoff: linear, capped, so a briefly-restarting daemon is picked up fast. */
|
|
29
|
+
const RECONNECT_BASE_MS = 250;
|
|
30
|
+
const RECONNECT_CAP_MS = 5_000;
|
|
31
|
+
/**
|
|
32
|
+
* How many consecutive failed reconnects before the proxy stops RETRYING. It does not stop serving:
|
|
33
|
+
* the budget ends the retry loop and the proxy goes dormant, where the handshake and `tools/list`
|
|
34
|
+
* are still answered and the next client request starts a daemon. See onReconnectBudgetSpent.
|
|
35
|
+
*
|
|
36
|
+
* At the capped backoff this is a few minutes — long enough to ride out a daemon restart without
|
|
37
|
+
* spinning on a port that is wedged or held by a foreign process.
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULT_MAX_RECONNECT_ATTEMPTS = 60;
|
|
40
|
+
/**
|
|
41
|
+
* Overridable so the survival spec can reach exhaustion in seconds instead of minutes — the same
|
|
42
|
+
* reason the idle windows take an env override. A positive integer only; anything else falls back,
|
|
43
|
+
* so a typo cannot silently disable the retry loop.
|
|
44
|
+
*/
|
|
45
|
+
export const MAX_RECONNECT_ATTEMPTS = (() => {
|
|
46
|
+
const raw = Number(process.env[ReticleEnv.RECONNECT_ATTEMPTS]);
|
|
47
|
+
return Number.isInteger(raw) && raw > 0 ? raw : DEFAULT_MAX_RECONNECT_ATTEMPTS;
|
|
48
|
+
})();
|
|
49
|
+
export function reconnectDelayMs(attempt) {
|
|
50
|
+
return Math.min(RECONNECT_BASE_MS * attempt, RECONNECT_CAP_MS);
|
|
51
|
+
}
|
|
52
|
+
/** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
|
|
53
|
+
export const RECONNECT_INITIALIZE_ID = '__reticle_proxy_reinit';
|
|
54
|
+
/**
|
|
55
|
+
* Which port this proxy serves, for the log file name. Set once at startup.
|
|
56
|
+
*
|
|
57
|
+
* One file per port, matching `daemon-<port>.log`. A single shared file interleaved every proxy on
|
|
58
|
+
* the machine into one stream, so the first question anyone asks of it — "what happened to MY
|
|
59
|
+
* session?" — needed a filter before it could be answered.
|
|
60
|
+
*/
|
|
61
|
+
let logPort;
|
|
62
|
+
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
63
|
+
export function setProxyLogPort(port) {
|
|
64
|
+
logPort = port;
|
|
65
|
+
}
|
|
66
|
+
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
67
|
+
export function proxyLogPath(port = logPort) {
|
|
68
|
+
const name = port === undefined ? 'mcp-proxy.log' : `proxy-${String(port)}.log`;
|
|
69
|
+
return join(homedir(), ReticleDir.ROOT, name);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
|
|
73
|
+
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
74
|
+
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
75
|
+
*
|
|
76
|
+
* EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
|
|
77
|
+
* logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
|
|
78
|
+
* present to a human as "the MCP server disconnected" — were written to a stream the editor throws
|
|
79
|
+
* away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
|
|
80
|
+
* proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
|
|
81
|
+
* existed, and the one path that most needed it was not using it.
|
|
82
|
+
*/
|
|
83
|
+
export function proxyLog(event, fields = {}) {
|
|
84
|
+
log(event, fields);
|
|
85
|
+
try {
|
|
86
|
+
const dir = join(homedir(), ReticleDir.ROOT);
|
|
87
|
+
mkdirSync(dir, { recursive: true });
|
|
88
|
+
// WITH A TIMESTAMP. This file is the only record of an outage that survives the session, and
|
|
89
|
+
// without one it cannot answer the two questions anyone brings to it: when did this happen, and
|
|
90
|
+
// how often. A real 3,283-line log on a dev machine held a `gave_up` — the exact event that used
|
|
91
|
+
// to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
|
|
92
|
+
// impossible to tell whether it happened during that person's work or a test run hours earlier.
|
|
93
|
+
// Evidence you cannot place in time is an anecdote.
|
|
94
|
+
appendFileSync(proxyLogPath(), `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Logging must never be the thing that kills the proxy.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function parseJsonRpc(line) {
|
|
101
|
+
try {
|
|
102
|
+
const parsed = JSON.parse(line);
|
|
103
|
+
return 'object' === typeof parsed && parsed !== null ? parsed : null;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const INITIALIZE_METHOD = 'initialize';
|
|
110
|
+
/**
|
|
111
|
+
* How long the handshake waits on the daemon before the proxy answers it.
|
|
112
|
+
*
|
|
113
|
+
* Long enough for a cold daemon start (which is seconds), short enough that a client does not sit
|
|
114
|
+
* there. Measured failure: a listener that accepts and never serves SSE left `initialize` unanswered
|
|
115
|
+
* past 25s with nothing on stderr, and the reported client gave up at 60s having run no tools.
|
|
116
|
+
*/
|
|
117
|
+
const LOCAL_HANDSHAKE_MS = 12_000;
|
|
118
|
+
const INITIALIZED_METHOD = 'notifications/initialized';
|
|
119
|
+
/**
|
|
120
|
+
* JSON-RPC requests forwarded on behalf of the client that have not been answered yet.
|
|
121
|
+
*
|
|
122
|
+
* Only used at shutdown, where it is the difference between "the client closed stdin" and "the client
|
|
123
|
+
* closed stdin and we dropped its `tools/list` on the floor while reporting exit 0". A request has an
|
|
124
|
+
* id AND a method; a notification has no id and is owed nothing; a response has an id and no method.
|
|
125
|
+
*/
|
|
126
|
+
export class PendingRequests {
|
|
127
|
+
#ids = new Set();
|
|
128
|
+
observeOutbound(line) {
|
|
129
|
+
const msg = parseJsonRpc(line);
|
|
130
|
+
if (null === msg || msg.id === undefined || msg.method === undefined)
|
|
131
|
+
return;
|
|
132
|
+
this.#ids.add(JSON.stringify(msg.id));
|
|
133
|
+
}
|
|
134
|
+
observeInbound(line) {
|
|
135
|
+
const msg = parseJsonRpc(line);
|
|
136
|
+
if (null === msg || msg.id === undefined)
|
|
137
|
+
return;
|
|
138
|
+
this.#ids.delete(JSON.stringify(msg.id));
|
|
139
|
+
}
|
|
140
|
+
get unanswered() {
|
|
141
|
+
return [...this.#ids];
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Claim ONE unanswered id, so exactly one path may answer it. False means somebody already did —
|
|
145
|
+
* a duplicate response to one id is a protocol violation, not a belt-and-braces retry.
|
|
146
|
+
*/
|
|
147
|
+
take(id) {
|
|
148
|
+
return this.#ids.delete(JSON.stringify(id));
|
|
149
|
+
}
|
|
150
|
+
/** Take every unanswered id and forget them, so the same request is failed at most once. */
|
|
151
|
+
drain() {
|
|
152
|
+
const ids = [...this.#ids];
|
|
153
|
+
this.#ids.clear();
|
|
154
|
+
return ids;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* JSON-RPC errors for every request that was in flight when the stream died.
|
|
159
|
+
*
|
|
160
|
+
* These are unrecoverable BY CONSTRUCTION: the daemon builds a fresh MCP session per SSE
|
|
161
|
+
* connection, so the reconnected session has never seen them. Re-sending them is not an option
|
|
162
|
+
* either — a `reticle_act` that already clicked would click twice, and inventing a second action in
|
|
163
|
+
* somebody's app is worse than reporting a failure.
|
|
164
|
+
*
|
|
165
|
+
* So the proxy answers them itself, under each request's own id. Found by brute force: killing the
|
|
166
|
+
* daemon under a live client left 20 of 20 concurrent calls hanging until the client's own timeout,
|
|
167
|
+
* with the MCP server still perfectly alive. An agent cannot tell that apart from "still working".
|
|
168
|
+
*/
|
|
169
|
+
export function streamLossReplies(pending, reason) {
|
|
170
|
+
return pending.drain().map((id) => transportLossReply(JSON.parse(id), reason));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* -32001: a server-defined error. Not -32603 (internal): nothing went wrong INSIDE the call, the
|
|
174
|
+
* transport under it went away, and the distinction is what tells a caller the action may be safe
|
|
175
|
+
* to retry.
|
|
176
|
+
*/
|
|
177
|
+
const TRANSPORT_LOSS_CODE = -32001;
|
|
178
|
+
/** The one shape every "the transport ate your call" answer takes, whichever leg died. */
|
|
179
|
+
export function transportLossReply(id, reason) {
|
|
180
|
+
return JSON.stringify({
|
|
181
|
+
jsonrpc: '2.0',
|
|
182
|
+
id,
|
|
183
|
+
error: {
|
|
184
|
+
code: TRANSPORT_LOSS_CODE,
|
|
185
|
+
message: `the daemon connection dropped (${reason}) before this call was answered — it did NOT ` +
|
|
186
|
+
'complete. Reticle is recovering the connection; retry if the action is safe to repeat.',
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* How long stdin closing gives the daemon to answer what is still in flight.
|
|
192
|
+
*
|
|
193
|
+
* A client can write its whole conversation and close in one flush; the daemon is a socket away and
|
|
194
|
+
* answers in milliseconds, so this is generous. Overshooting only delays an exit that is already
|
|
195
|
+
* ending the process.
|
|
196
|
+
*/
|
|
197
|
+
const SHUTDOWN_DRAIN_MS = 5_000;
|
|
198
|
+
/**
|
|
199
|
+
* How long a request may sit in the queue, unsent, before the proxy answers it itself.
|
|
200
|
+
*
|
|
201
|
+
* The second population the stress test found. A request that was FORWARDED and lost is failed when
|
|
202
|
+
* the stream drops; one that never got forwarded — queued while dormant or reconnecting — was
|
|
203
|
+
* waiting for a session that, with a squatter holding the port, never arrives. It then hung until
|
|
204
|
+
* the client's own timeout, or forever if the client has none.
|
|
205
|
+
*
|
|
206
|
+
* Comfortably longer than a daemon restart (~1-2s) so a normal blip still flushes and succeeds, and
|
|
207
|
+
* well under the 60s an MCP client typically allows, so the answer comes from us with a reason
|
|
208
|
+
* rather than from their timer with none.
|
|
209
|
+
*/
|
|
210
|
+
const QUEUE_WAIT_MS = 20_000;
|
|
211
|
+
const SHUTDOWN_DRAIN_POLL_MS = 25;
|
|
212
|
+
/**
|
|
213
|
+
* Makes a reconnect invisible to the client.
|
|
214
|
+
*
|
|
215
|
+
* The daemon builds a FRESH `McpServer` per SSE connection, so a reconnected session has never seen
|
|
216
|
+
* the client's `initialize` — every subsequent `tools/call` would hit an uninitialized server. The
|
|
217
|
+
* proxy therefore remembers the handshake the client sent once and replays it into each new session.
|
|
218
|
+
* The replay is re-issued under a reserved id so the daemon's response to it can be dropped rather
|
|
219
|
+
* than reaching the client's stdout, where a duplicate JSON-RPC id would be a protocol violation.
|
|
220
|
+
*/
|
|
221
|
+
export class HandshakeReplay {
|
|
222
|
+
#initialize = null;
|
|
223
|
+
#initialized = null;
|
|
224
|
+
#pendingReplayId = null;
|
|
225
|
+
/** Record one outbound client line. Cheap enough to call for every stdin message. */
|
|
226
|
+
observeOutbound(line) {
|
|
227
|
+
const msg = parseJsonRpc(line);
|
|
228
|
+
if (null === msg)
|
|
229
|
+
return;
|
|
230
|
+
// The first initialize is the session's identity; a client re-issuing one does not change it.
|
|
231
|
+
if (msg.method === INITIALIZE_METHOD && null === this.#initialize)
|
|
232
|
+
this.#initialize = line;
|
|
233
|
+
else if (msg.method === INITIALIZED_METHOD)
|
|
234
|
+
this.#initialized = line;
|
|
235
|
+
}
|
|
236
|
+
/** Lines to POST into a freshly-established session, before any queued client traffic. */
|
|
237
|
+
replayLines() {
|
|
238
|
+
if (null === this.#initialize)
|
|
239
|
+
return [];
|
|
240
|
+
const msg = parseJsonRpc(this.#initialize);
|
|
241
|
+
if (null === msg)
|
|
242
|
+
return [];
|
|
243
|
+
this.#pendingReplayId = RECONNECT_INITIALIZE_ID;
|
|
244
|
+
const reinit = { ...msg, id: RECONNECT_INITIALIZE_ID };
|
|
245
|
+
return null === this.#initialized
|
|
246
|
+
? [JSON.stringify(reinit)]
|
|
247
|
+
: [JSON.stringify(reinit), this.#initialized];
|
|
248
|
+
}
|
|
249
|
+
/** True when this inbound daemon line is the echo of our replayed handshake and must not be forwarded. */
|
|
250
|
+
shouldSuppressInbound(line) {
|
|
251
|
+
if (null === this.#pendingReplayId)
|
|
252
|
+
return false;
|
|
253
|
+
const msg = parseJsonRpc(line);
|
|
254
|
+
if (null === msg || msg.id !== this.#pendingReplayId)
|
|
255
|
+
return false;
|
|
256
|
+
this.#pendingReplayId = null; // one response per replay — anything later is not ours to swallow
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Incremental SSE frame parser for the MCP front door.
|
|
262
|
+
*
|
|
263
|
+
* A single SSE field can split across TCP reads (`da` in one chunk, `ta: {...}` in the next), and a
|
|
264
|
+
* server may send CRLF or bare CR line endings — so the framing is stateful and edge-case-prone, yet it
|
|
265
|
+
* carried every MCP message the agent sends and was only ever exercised end-to-end. Pulled out of the
|
|
266
|
+
* socket handler as a pure, chunk-fed parser so those boundaries are unit-testable: feed raw chunks,
|
|
267
|
+
* get back each complete frame (a blank line terminates a frame; `event:` names it, `data:` lines
|
|
268
|
+
* accumulate newline-joined; `id:`/`retry:`/comments are ignored — not needed for the bridge).
|
|
269
|
+
*/
|
|
270
|
+
export class SseFrameParser {
|
|
271
|
+
#buffer = '';
|
|
272
|
+
#event = '';
|
|
273
|
+
#data = '';
|
|
274
|
+
push(chunk) {
|
|
275
|
+
this.#buffer += chunk;
|
|
276
|
+
// Normalise CRLF/CR so the splitter only handles \n, then hold the trailing partial line for the
|
|
277
|
+
// next chunk (it may complete later).
|
|
278
|
+
const normalised = this.#buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
279
|
+
const lines = normalised.split('\n');
|
|
280
|
+
this.#buffer = lines.pop() ?? '';
|
|
281
|
+
const frames = [];
|
|
282
|
+
for (const line of lines) {
|
|
283
|
+
if ('' === line) {
|
|
284
|
+
if (this.#data !== '') {
|
|
285
|
+
frames.push({ event: this.#event !== '' ? this.#event : 'message', data: this.#data });
|
|
286
|
+
}
|
|
287
|
+
this.#event = '';
|
|
288
|
+
this.#data = '';
|
|
289
|
+
}
|
|
290
|
+
else if (line.startsWith('event:')) {
|
|
291
|
+
this.#event = line.slice(6).trim();
|
|
292
|
+
}
|
|
293
|
+
else if (line.startsWith('data:')) {
|
|
294
|
+
const val = line.slice(5).trim();
|
|
295
|
+
this.#data = this.#data !== '' ? `${this.#data}\n${val}` : val;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return frames;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Returns true if something is already listening on the reticle port.
|
|
303
|
+
* Uses a plain TCP probe so we don't create a side-effectful SSE session
|
|
304
|
+
* inside the daemon just to check reachability.
|
|
305
|
+
*/
|
|
306
|
+
export function probeDaemon(port) {
|
|
307
|
+
return new Promise((resolve) => {
|
|
308
|
+
const socket = new net.Socket();
|
|
309
|
+
socket.setTimeout(500);
|
|
310
|
+
socket.on('connect', () => {
|
|
311
|
+
socket.destroy();
|
|
312
|
+
resolve(true);
|
|
313
|
+
});
|
|
314
|
+
socket.on('error', () => resolve(false));
|
|
315
|
+
socket.on('timeout', () => {
|
|
316
|
+
socket.destroy();
|
|
317
|
+
resolve(false);
|
|
318
|
+
});
|
|
319
|
+
socket.connect(port, LOOPBACK_HOST);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** Poll until the daemon's HTTP port accepts connections or the deadline is reached. */
|
|
323
|
+
export async function waitForDaemon(port) {
|
|
324
|
+
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
|
|
325
|
+
while (Date.now() < deadline) {
|
|
326
|
+
const reachable = await probeDaemon(port);
|
|
327
|
+
if (reachable)
|
|
328
|
+
return;
|
|
329
|
+
await delay(DAEMON_POLL_INTERVAL_MS);
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`reticle daemon did not become ready on port ${port} within ${DAEMON_READY_TIMEOUT_MS}ms`);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* POST one JSON-RPC line into the daemon's session. Resolves null on success, or a short reason
|
|
335
|
+
* when the request never reached a server that will answer it.
|
|
336
|
+
*
|
|
337
|
+
* It used to resolve `void` in every case, logging the failure and moving on. That is the THIRD way
|
|
338
|
+
* a call goes unanswered, and the only one neither `streamLossReplies` nor the queue timer can see:
|
|
339
|
+
* the SSE stream is healthy (so nothing drops) and the request was forwarded (so nothing is queued),
|
|
340
|
+
* but the POST leg is its own TCP connection and an ECONNRESET on it means the daemon never received
|
|
341
|
+
* the call. Nobody was ever going to reply, and the caller waited for its own timeout.
|
|
342
|
+
*/
|
|
343
|
+
function postToSession(url, body) {
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
const parsed = new URL(url);
|
|
346
|
+
const bodyBuf = Buffer.from(body, 'utf8');
|
|
347
|
+
const options = {
|
|
348
|
+
host: parsed.hostname,
|
|
349
|
+
port: parsed.port !== '' ? parseInt(parsed.port, 10) : 80,
|
|
350
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
351
|
+
method: 'POST',
|
|
352
|
+
headers: {
|
|
353
|
+
'Content-Type': 'application/json',
|
|
354
|
+
'Content-Length': bodyBuf.byteLength,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
const req = http.request(options, (res) => {
|
|
358
|
+
const status = res.statusCode ?? 0;
|
|
359
|
+
const rejected = status < 200 || status >= 300;
|
|
360
|
+
if (rejected) {
|
|
361
|
+
// A non-2xx from the daemon MCP endpoint used to be swallowed, hanging the JSON-RPC call
|
|
362
|
+
// client-side with no diagnostic. It is a refusal: no response is coming over the stream.
|
|
363
|
+
log('reticle_mcp_proxy_post_non2xx', { status, path: options.path });
|
|
364
|
+
}
|
|
365
|
+
res.resume(); // drain so the socket is reused
|
|
366
|
+
resolve(rejected ? `daemon rejected the call with HTTP ${String(status)}` : null);
|
|
367
|
+
});
|
|
368
|
+
req.on('error', (err) => {
|
|
369
|
+
log('reticle_mcp_proxy_post_error', { error: err.message });
|
|
370
|
+
resolve(`post failed: ${err.message}`);
|
|
371
|
+
});
|
|
372
|
+
req.write(bodyBuf);
|
|
373
|
+
req.end();
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
export function buildSessionUrl(rawData, port) {
|
|
377
|
+
return rawData.startsWith('/') ? `http://${LOOPBACK_HOST}:${port}${rawData}` : rawData;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
|
|
381
|
+
* Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
|
|
382
|
+
*
|
|
383
|
+
* A dropped SSE stream used to exit the process, which is what made the agent's `reticle_*` tools
|
|
384
|
+
* vanish mid-session with no message and no way for the agent to get them back — only a human running
|
|
385
|
+
* `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
|
|
386
|
+
* transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
|
|
387
|
+
*/
|
|
388
|
+
export function startMcpProxy(port,
|
|
389
|
+
/**
|
|
390
|
+
* Bring a daemon back before reconnecting. Without it the proxy retried a DEAD port until the
|
|
391
|
+
* budget ran out and then killed itself, taking the agent's whole Reticle surface with it — the
|
|
392
|
+
* failure mode for a daemon that crashed, was stopped, or shut itself down as idle.
|
|
393
|
+
*/
|
|
394
|
+
ensureDaemon) {
|
|
395
|
+
return new Promise((_resolve, reject) => {
|
|
396
|
+
// Declared before the SSE handler that fills it: the handler is a closure created below but
|
|
397
|
+
// invoked on the first daemon frame, and a `const` referenced before its declaration executes is
|
|
398
|
+
// a runtime throw, not a type error.
|
|
399
|
+
const catalog = new ToolCatalogCache();
|
|
400
|
+
let postUrl = null;
|
|
401
|
+
/**
|
|
402
|
+
* No daemon is listening and the proxy has stopped chasing one.
|
|
403
|
+
*
|
|
404
|
+
* The alternative — respawning the moment the stream drops — turns an idle daemon's own
|
|
405
|
+
* self-shutdown into a permanent spawn loop, because the daemon it brings back is just as idle.
|
|
406
|
+
* Dormant means the next thing the CLIENT asks for is what brings Reticle back.
|
|
407
|
+
*/
|
|
408
|
+
let dormant = false;
|
|
409
|
+
const stdinQueue = [];
|
|
410
|
+
/**
|
|
411
|
+
* Fail anything still queued after QUEUE_WAIT_MS. Re-armed on every push and cleared on flush,
|
|
412
|
+
* so a queue that drains normally never fires it.
|
|
413
|
+
*/
|
|
414
|
+
let queueTimer;
|
|
415
|
+
const clearQueueTimer = () => {
|
|
416
|
+
if (queueTimer !== undefined)
|
|
417
|
+
clearTimeout(queueTimer);
|
|
418
|
+
queueTimer = undefined;
|
|
419
|
+
};
|
|
420
|
+
const armQueueTimer = () => {
|
|
421
|
+
if (queueTimer !== undefined)
|
|
422
|
+
return; // the oldest entry owns the deadline
|
|
423
|
+
queueTimer = setTimeout(() => {
|
|
424
|
+
queueTimer = undefined;
|
|
425
|
+
if (stopped || 0 === stdinQueue.length)
|
|
426
|
+
return;
|
|
427
|
+
// Drop what we could never send, and answer it, so the caller is never left guessing.
|
|
428
|
+
stdinQueue.splice(0);
|
|
429
|
+
for (const reply of streamLossReplies(pending, 'no daemon could be reached'))
|
|
430
|
+
emit(reply);
|
|
431
|
+
proxyLog('reticle_mcp_proxy_queue_expired', {
|
|
432
|
+
port,
|
|
433
|
+
note: 'answered queued requests instead of holding them for a daemon that never arrived',
|
|
434
|
+
});
|
|
435
|
+
}, QUEUE_WAIT_MS);
|
|
436
|
+
queueTimer.unref();
|
|
437
|
+
};
|
|
438
|
+
const replay = new HandshakeReplay();
|
|
439
|
+
const pending = new PendingRequests();
|
|
440
|
+
/** The ONE way a line reaches the client — so nothing can be answered without clearing the debt. */
|
|
441
|
+
const emit = (line) => {
|
|
442
|
+
pending.observeInbound(line);
|
|
443
|
+
process.stdout.write(`${line}\n`);
|
|
444
|
+
};
|
|
445
|
+
let stopped = false;
|
|
446
|
+
let attempts = 0;
|
|
447
|
+
/**
|
|
448
|
+
* The ONE way a line leaves for the daemon — so a POST that never lands still owes an answer.
|
|
449
|
+
*
|
|
450
|
+
* Only requests we are holding in `pending` get one: a notification is owed nothing, and the
|
|
451
|
+
* proxy's own replayed handshake carries a reserved id the client must never see a reply for.
|
|
452
|
+
*/
|
|
453
|
+
const forward = (url, line) => {
|
|
454
|
+
void postToSession(url, line).then((failure) => {
|
|
455
|
+
if (null === failure)
|
|
456
|
+
return;
|
|
457
|
+
const msg = parseJsonRpc(line);
|
|
458
|
+
if (null === msg || msg.id === undefined || !pending.take(msg.id))
|
|
459
|
+
return;
|
|
460
|
+
proxyLog('reticle_mcp_proxy_post_unanswered', {
|
|
461
|
+
port,
|
|
462
|
+
method: String(msg.method),
|
|
463
|
+
reason: failure,
|
|
464
|
+
note: 'the stream is still up, so nothing else would ever have answered this call',
|
|
465
|
+
});
|
|
466
|
+
emit(transportLossReply(msg.id, failure));
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
function onSseEvent(event, data, p) {
|
|
470
|
+
if ('endpoint' === event) {
|
|
471
|
+
const url = buildSessionUrl(data, p);
|
|
472
|
+
postUrl = url;
|
|
473
|
+
// A stream that reached `endpoint` is a USABLE session — that, not a set of response
|
|
474
|
+
// headers, is what earns a fresh retry budget. Resetting on headers let a daemon that
|
|
475
|
+
// accepts SSE and drops it spin the reconnect loop at the 250ms floor forever, never backing
|
|
476
|
+
// off and never reaching the budget that puts the proxy dormant. Measured: 32 retries in 8s.
|
|
477
|
+
attempts = 0;
|
|
478
|
+
// The new session's McpServer has never seen the client's initialize — replay it first, then
|
|
479
|
+
// flush whatever the client sent while we were reconnecting.
|
|
480
|
+
for (const line of replay.replayLines())
|
|
481
|
+
forward(url, line);
|
|
482
|
+
clearQueueTimer();
|
|
483
|
+
for (const queued of stdinQueue.splice(0))
|
|
484
|
+
forward(url, queued);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if ('message' === event && !replay.shouldSuppressInbound(data)) {
|
|
488
|
+
// Remember the tool catalog as it goes past: it is what makes a locally-answered handshake
|
|
489
|
+
// useful rather than toolless. See tool-catalog-cache.
|
|
490
|
+
catalog.observe(data);
|
|
491
|
+
emit(data);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function scheduleReconnect(reason, detail) {
|
|
495
|
+
if (stopped)
|
|
496
|
+
return;
|
|
497
|
+
postUrl = null;
|
|
498
|
+
attempts++;
|
|
499
|
+
// Once per process: this session has now lost its tools at least once, which is the number
|
|
500
|
+
// that says whether the transport work actually landed for real users.
|
|
501
|
+
reportMcpOutage(OutageStage.FIRST, { reason, attempts });
|
|
502
|
+
// The severe stage: we are about to stop retrying. Reported so the share of sessions where
|
|
503
|
+
// MCP never came back on its own is a number rather than an anecdote.
|
|
504
|
+
if (attempts > MAX_RECONNECT_ATTEMPTS) {
|
|
505
|
+
reportMcpOutage(OutageStage.BUDGET_SPENT, { reason, attempts });
|
|
506
|
+
// Stop RETRYING, never stop SERVING. See onReconnectBudgetSpent: exiting here made the
|
|
507
|
+
// client mark the MCP server disconnected and left a human to reconnect it by hand.
|
|
508
|
+
dormant = onReconnectBudgetSpent() === OnDrop.DORMANT;
|
|
509
|
+
proxyLog('reticle_mcp_proxy_dormant_after_budget', {
|
|
510
|
+
port,
|
|
511
|
+
reason,
|
|
512
|
+
attempts,
|
|
513
|
+
note: 'stopped retrying; the next client request will start a daemon',
|
|
514
|
+
...(detail !== undefined ? { detail } : {}),
|
|
515
|
+
});
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const wait = reconnectDelayMs(attempts);
|
|
519
|
+
proxyLog('reticle_mcp_proxy_reconnecting', {
|
|
520
|
+
port,
|
|
521
|
+
reason,
|
|
522
|
+
attempt: attempts,
|
|
523
|
+
retryInMs: wait,
|
|
524
|
+
...(detail !== undefined ? { detail } : {}),
|
|
525
|
+
});
|
|
526
|
+
setTimeout(() => {
|
|
527
|
+
// Reattach to a daemon that is ALREADY there. Deliberately does not spawn one: a daemon that
|
|
528
|
+
// shut itself down as idle would be respawned instantly, sit unused, shut down again, and
|
|
529
|
+
// loop forever — measured at four processes in 200s before this. If nothing is listening the
|
|
530
|
+
// proxy goes DORMANT and the next client request brings a daemon back (see the stdin reader).
|
|
531
|
+
void probeDaemon(port).then((listening) => {
|
|
532
|
+
if (onStreamDrop(listening) === OnDrop.REATTACH) {
|
|
533
|
+
connect(false);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
dormant = true;
|
|
537
|
+
proxyLog('reticle_mcp_proxy_dormant', {
|
|
538
|
+
port,
|
|
539
|
+
note: 'no daemon listening; will start one when the client next sends a request',
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
}, wait).unref();
|
|
543
|
+
}
|
|
544
|
+
function connect(first) {
|
|
545
|
+
// Announce this process to the daemon on the connect we already make — it is the single judge
|
|
546
|
+
// of version skew, and this is the only moment it learns the agent's MCP server exists.
|
|
547
|
+
const announce = `${MCP_SSE_PATH}?${PEER_VERSION_PARAM}=${encodeURIComponent(SERVER_VERSION)}&${PEER_CONTRACT_PARAM}=${encodeURIComponent(CONTRACT_FINGERPRINT)}`;
|
|
548
|
+
const req = http.get({ host: LOOPBACK_HOST, port, path: announce }, (res) => {
|
|
549
|
+
if (!first)
|
|
550
|
+
proxyLog('reticle_mcp_proxy_reconnected', { port });
|
|
551
|
+
res.setEncoding('utf8');
|
|
552
|
+
// `end` and `error` can both fire on one response; only the first should drive a reconnect.
|
|
553
|
+
let settled = false;
|
|
554
|
+
const drop = (reason, detail) => {
|
|
555
|
+
if (settled)
|
|
556
|
+
return;
|
|
557
|
+
settled = true;
|
|
558
|
+
// Answer anything the dead session owed BEFORE reconnecting. The new session cannot know
|
|
559
|
+
// about it, so silence here is permanent.
|
|
560
|
+
for (const reply of streamLossReplies(pending, reason))
|
|
561
|
+
emit(reply);
|
|
562
|
+
scheduleReconnect(reason, detail);
|
|
563
|
+
};
|
|
564
|
+
const sse = new SseFrameParser();
|
|
565
|
+
res.on('data', (chunk) => {
|
|
566
|
+
for (const frame of sse.push(chunk))
|
|
567
|
+
onSseEvent(frame.event, frame.data, port);
|
|
568
|
+
});
|
|
569
|
+
res.on('end', () => drop('sse_ended'));
|
|
570
|
+
res.on('error', (err) => drop('sse_error', err.message));
|
|
571
|
+
// A socket dying under us (daemon killed, network stack reset) emits NEITHER `end` nor
|
|
572
|
+
// `error` — only `aborted`/`close`. Listening for just the first two is why the proxy sat
|
|
573
|
+
// there afterwards holding a dead stream. `settled` keeps the clean case single-fire.
|
|
574
|
+
res.on('aborted', () => drop('sse_aborted'));
|
|
575
|
+
res.on('close', () => drop('sse_closed'));
|
|
576
|
+
});
|
|
577
|
+
// The very first connect failing means there is no daemon to talk to — that is a startup
|
|
578
|
+
// failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
|
|
579
|
+
req.on('error', (err) => first ? reject(err) : scheduleReconnect('connect_error', err.message));
|
|
580
|
+
}
|
|
581
|
+
connect(true);
|
|
582
|
+
// ── stdin reader ─────────────────────────────────────────────────────────
|
|
583
|
+
process.stdin.setEncoding('utf8');
|
|
584
|
+
let handshakeAnswered = false;
|
|
585
|
+
/**
|
|
586
|
+
* Answer a queued `initialize` locally if the daemon has not produced an endpoint in time.
|
|
587
|
+
*
|
|
588
|
+
* Bounded because a hang is the worst outcome available here: no tools, no diagnosis, nothing to
|
|
589
|
+
* retry. Cancelled implicitly — once `postUrl` exists the queue flushes and the daemon answers,
|
|
590
|
+
* and `handshakeAnswered` keeps us from answering twice.
|
|
591
|
+
*/
|
|
592
|
+
const armLocalHandshake = (line) => {
|
|
593
|
+
if (handshakeAnswered)
|
|
594
|
+
return;
|
|
595
|
+
const response = localInitializeResponse(line);
|
|
596
|
+
if (null === response)
|
|
597
|
+
return;
|
|
598
|
+
setTimeout(() => {
|
|
599
|
+
if (handshakeAnswered || postUrl !== null)
|
|
600
|
+
return;
|
|
601
|
+
handshakeAnswered = true;
|
|
602
|
+
// Drop the handshake from the queue. Otherwise the daemon, whenever it finally arrives,
|
|
603
|
+
// answers the SAME id a second time and the client sees two responses to one request — a
|
|
604
|
+
// corrupted stream, which is worse than the hang this replaces. The daemon still gets its own
|
|
605
|
+
// handshake: `replayLines` re-issues it under a distinct id and suppresses the echo.
|
|
606
|
+
for (let i = stdinQueue.length - 1; i >= 0; i--) {
|
|
607
|
+
const queued = stdinQueue[i];
|
|
608
|
+
if (queued !== undefined && isHandshakeLine(queued))
|
|
609
|
+
stdinQueue.splice(i, 1);
|
|
610
|
+
}
|
|
611
|
+
proxyLog('reticle_mcp_local_handshake', { port, reason: 'daemon did not answer in time' });
|
|
612
|
+
emit(response);
|
|
613
|
+
}, LOCAL_HANDSHAKE_MS).unref();
|
|
614
|
+
};
|
|
615
|
+
let stdinBuffer = '';
|
|
616
|
+
process.stdin.on('data', (chunk) => {
|
|
617
|
+
stdinBuffer += chunk;
|
|
618
|
+
const lines = stdinBuffer.split('\n');
|
|
619
|
+
stdinBuffer = lines.pop() ?? '';
|
|
620
|
+
for (const line of lines) {
|
|
621
|
+
const trimmed = line.trim();
|
|
622
|
+
if ('' === trimmed)
|
|
623
|
+
continue;
|
|
624
|
+
replay.observeOutbound(trimmed);
|
|
625
|
+
pending.observeOutbound(trimmed);
|
|
626
|
+
const action = onClientRequest(postUrl !== null, dormant);
|
|
627
|
+
if (action === OnRequest.SEND && postUrl !== null) {
|
|
628
|
+
forward(postUrl, trimmed);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
// A queued `tools/list` with no daemon is the state that leaves an agent "connected with no
|
|
632
|
+
// tools" — the one a human has to fix by hand. If the catalog has been seen, answer it.
|
|
633
|
+
if (handshakeAnswered) {
|
|
634
|
+
const cached = catalog.answer(trimmed);
|
|
635
|
+
if (cached !== null) {
|
|
636
|
+
emit(cached);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
stdinQueue.push(trimmed);
|
|
641
|
+
armQueueTimer();
|
|
642
|
+
// A queued `initialize` is the one message that must not wait indefinitely. If the daemon
|
|
643
|
+
// port is held by something that never serves SSE — wedged, foreign, or leaked by another
|
|
644
|
+
// project — the handshake never completes and the agent gets NO tools and no diagnosis.
|
|
645
|
+
// Answer it ourselves after a bounded wait; the daemon still receives the client's own
|
|
646
|
+
// initialize when a session is finally established (replayLines).
|
|
647
|
+
armLocalHandshake(trimmed);
|
|
648
|
+
// WAKE is the ONLY path allowed to start a daemon — see proxy-lifecycle.ts. The queue is
|
|
649
|
+
// flushed once the new session's `endpoint` frame arrives.
|
|
650
|
+
if (action === OnRequest.WAKE) {
|
|
651
|
+
dormant = false;
|
|
652
|
+
attempts = 0;
|
|
653
|
+
void (ensureDaemon?.() ?? Promise.resolve())
|
|
654
|
+
.then(() => connect(false))
|
|
655
|
+
.catch((err) => {
|
|
656
|
+
dormant = true;
|
|
657
|
+
proxyLog('reticle_mcp_proxy_wake_failed', {
|
|
658
|
+
port,
|
|
659
|
+
error: err instanceof Error ? err.message : String(err),
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
// The client going away is the one clean shutdown — stop reconnecting and exit.
|
|
666
|
+
//
|
|
667
|
+
// Exiting the INSTANT stdin ends drops whatever is still in flight and reports success for it: a
|
|
668
|
+
// client that wrote `initialize` + `tools/list` and closed in the same flush got the handshake
|
|
669
|
+
// answered, `tools/list` never, and exit 0 — so nothing supervising it could tell. A 4-second gap
|
|
670
|
+
// between the writes made it "work", which is the signature of a teardown race, not of a client
|
|
671
|
+
// that asked for too much. Drain first, and let the exit status carry the truth if it fails.
|
|
672
|
+
process.stdin.on('end', () => {
|
|
673
|
+
const quit = (code) => {
|
|
674
|
+
stopped = true;
|
|
675
|
+
process.exit(code);
|
|
676
|
+
};
|
|
677
|
+
if (0 === pending.unanswered.length) {
|
|
678
|
+
quit(0);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
const deadline = Date.now() + SHUTDOWN_DRAIN_MS;
|
|
682
|
+
const poll = setInterval(() => {
|
|
683
|
+
const stuck = pending.unanswered;
|
|
684
|
+
if (0 === stuck.length) {
|
|
685
|
+
clearInterval(poll);
|
|
686
|
+
quit(0);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (Date.now() < deadline)
|
|
690
|
+
return;
|
|
691
|
+
clearInterval(poll);
|
|
692
|
+
proxyLog('reticle_mcp_proxy_unanswered_at_exit', { port, ids: stuck });
|
|
693
|
+
quit(1);
|
|
694
|
+
}, SHUTDOWN_DRAIN_POLL_MS);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
}
|