@reticlehq/server 2.12.0 → 2.13.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 +67 -109
- package/dist/bridge/auth-failure-reason.d.ts +6 -2
- package/dist/bridge/auth-failure-reason.js +15 -8
- package/dist/bridge/bridge.d.ts +18 -1
- package/dist/bridge/bridge.js +247 -164
- package/dist/bridge/pairing-token.d.ts +5 -0
- package/dist/bridge/pairing-token.js +28 -0
- package/dist/bridge/protocol-skew.d.ts +37 -0
- package/dist/bridge/protocol-skew.js +45 -0
- package/dist/capsule/capsule-store.d.ts +6 -6
- package/dist/capsule/causal-summary.js +7 -2
- package/dist/cli/cli-doctor.js +31 -1
- package/dist/cli/cli-launch.d.ts +11 -1
- package/dist/cli/cli-launch.js +59 -10
- package/dist/cli/cli-parse.d.ts +15 -1
- package/dist/cli/cli-parse.js +212 -8
- package/dist/cli/cli-verify.d.ts +1 -0
- package/dist/cli/cli-verify.js +3 -2
- package/dist/cli/cloud-cli.js +428 -259
- package/dist/cli/cloud-keystore.d.ts +37 -0
- package/dist/cli/cloud-keystore.js +97 -0
- package/dist/cli/cloud-kit.d.ts +37 -0
- package/dist/cli/cloud-kit.js +121 -0
- package/dist/cli/cloud-login.d.ts +17 -0
- package/dist/cli/cloud-login.js +183 -0
- package/dist/cli/cloud-session.d.ts +48 -0
- package/dist/cli/cloud-session.js +53 -0
- package/dist/cli/config-discovery.js +1 -0
- package/dist/cli/daemon-start-options.d.ts +32 -0
- package/dist/cli/daemon-start-options.js +38 -0
- package/dist/cli/device-flow.d.ts +76 -0
- package/dist/cli/device-flow.js +56 -0
- package/dist/cli/doctor-rows.d.ts +9 -0
- package/dist/cli/doctor-rows.js +9 -0
- package/dist/cli/drive-attach.d.ts +13 -0
- package/dist/cli/drive-attach.js +18 -0
- package/dist/cli/drive-command.js +7 -3
- package/dist/cli/mcp-command.js +51 -20
- package/dist/cli/project-name.d.ts +30 -0
- package/dist/cli/project-name.js +44 -0
- package/dist/cli/status-next-action.d.ts +14 -0
- package/dist/cli/status-next-action.js +4 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +134 -80
- package/dist/cloud/cloud-config.d.ts +22 -1
- package/dist/cloud/cloud-config.js +64 -5
- package/dist/cloud/cloud-sync.d.ts +5 -5
- package/dist/cloud/cloud-sync.js +33 -2
- package/dist/cloud/sync-cycle.d.ts +110 -0
- package/dist/cloud/sync-cycle.js +270 -0
- package/dist/cloud/sync-daemon.d.ts +69 -0
- package/dist/cloud/sync-daemon.js +260 -0
- package/dist/cloud/sync-disk.d.ts +9 -0
- package/dist/cloud/sync-disk.js +191 -0
- package/dist/cloud/sync-hash.d.ts +2 -0
- package/dist/cloud/sync-hash.js +35 -0
- package/dist/crawl/crawl-tools.js +5 -3
- package/dist/crawl/crawl.d.ts +3 -0
- package/dist/crawl/crawl.js +5 -0
- package/dist/daemon/daemon-resolve.d.ts +43 -0
- package/dist/daemon/daemon-resolve.js +69 -0
- package/dist/daemon/dev-servers.d.ts +15 -0
- package/dist/daemon/dev-servers.js +68 -0
- package/dist/daemon/wake-decision.d.ts +32 -0
- package/dist/daemon/wake-decision.js +38 -0
- package/dist/domain/domain-tools.js +2 -2
- package/dist/events/contradictions.d.ts +18 -0
- package/dist/events/contradictions.js +134 -21
- package/dist/events/declared.d.ts +7 -4
- package/dist/events/declared.js +15 -6
- package/dist/events/echo-mismatch.js +64 -14
- package/dist/events/event-filters.d.ts +8 -0
- package/dist/events/event-filters.js +19 -2
- package/dist/events/predicate-eval.js +53 -5
- package/dist/events/predicate-precheck.d.ts +8 -0
- package/dist/events/predicate-precheck.js +41 -0
- package/dist/events/predicate-route.d.ts +14 -0
- package/dist/events/predicate-route.js +36 -8
- package/dist/events/predicate.d.ts +5 -0
- package/dist/events/predicate.js +37 -5
- package/dist/events/split-text-miss.d.ts +30 -0
- package/dist/events/split-text-miss.js +44 -0
- package/dist/flows/flake.d.ts +6 -6
- package/dist/flows/flow-memory-consult.d.ts +55 -0
- package/dist/flows/flow-memory-consult.js +38 -0
- package/dist/flows/flow-replay-run.d.ts +18 -7
- package/dist/flows/flow-replay-run.js +239 -22
- package/dist/flows/flow-replay.js +11 -2
- package/dist/flows/flow-step-runners.d.ts +8 -0
- package/dist/flows/flow-step-runners.js +8 -2
- package/dist/flows/flow-store-for-session.d.ts +31 -0
- package/dist/flows/flow-store-for-session.js +33 -0
- package/dist/flows/flow-tools.js +58 -32
- package/dist/flows/flows.d.ts +8 -0
- package/dist/flows/flows.js +468 -2
- package/dist/flows/heal-run.js +5 -2
- package/dist/flows/predicate-to-expect.d.ts +17 -12
- package/dist/flows/predicate-to-expect.js +30 -12
- package/dist/flows/recordings.d.ts +13 -1
- package/dist/flows/recordings.js +17 -2
- package/dist/flows/replay.d.ts +22 -2
- package/dist/flows/replay.js +28 -2
- package/dist/flows/verify-change-tools.js +5 -2
- package/dist/honesty/blind-spots.d.ts +55 -2
- package/dist/honesty/blind-spots.js +63 -3
- package/dist/honesty/honesty.d.ts +24 -0
- package/dist/honesty/honesty.js +25 -1
- package/dist/honesty/instrumentation-gaps.d.ts +9 -0
- package/dist/honesty/instrumentation-gaps.js +29 -2
- package/dist/honesty/verified.d.ts +2 -1
- package/dist/honesty/verified.js +16 -1
- package/dist/http-server.js +12 -0
- package/dist/impact/impact-recorder.d.ts +31 -8
- package/dist/impact/impact-recorder.js +104 -21
- package/dist/impact/impact-store.d.ts +15 -1
- package/dist/impact/impact-store.js +43 -2
- package/dist/index.js +113 -7
- package/dist/init/agent-rules.d.ts +13 -6
- package/dist/init/agent-rules.js +23 -12
- package/dist/init/astro-patch.d.ts +16 -0
- package/dist/init/astro-patch.js +28 -0
- package/dist/init/capabilities.d.ts +4 -1
- package/dist/init/capabilities.js +22 -2
- package/dist/init/confirm.d.ts +31 -7
- package/dist/init/confirm.js +48 -8
- package/dist/init/cra.d.ts +20 -4
- package/dist/init/cra.js +57 -17
- package/dist/init/csp-check.js +17 -2
- package/dist/init/csp-doctor.d.ts +8 -1
- package/dist/init/csp-doctor.js +8 -3
- package/dist/init/dev-script.d.ts +3 -1
- package/dist/init/dev-script.js +8 -5
- package/dist/init/format-generated.d.ts +18 -0
- package/dist/init/format-generated.js +39 -0
- package/dist/init/install-hint.d.ts +9 -0
- package/dist/init/install-hint.js +29 -0
- package/dist/init/mcp-clients.d.ts +7 -0
- package/dist/init/mcp-clients.js +7 -0
- package/dist/init/mcp.d.ts +1 -1
- package/dist/init/mcp.js +1 -1
- package/dist/init/node-io.js +11 -1
- package/dist/init/plan-framework.js +81 -45
- package/dist/init/plan.d.ts +20 -7
- package/dist/init/plan.js +20 -45
- package/dist/init/preflight.d.ts +28 -0
- package/dist/init/preflight.js +38 -0
- package/dist/init/run.d.ts +32 -9
- package/dist/init/run.js +141 -119
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/snippets.js +41 -25
- package/dist/init/vite-config.d.ts +1 -1
- package/dist/init/vite-config.js +39 -25
- package/dist/init/workspace-apps.d.ts +17 -10
- package/dist/init/workspace-apps.js +110 -0
- package/dist/input/network-mock-tools.js +3 -6
- package/dist/input/real-input.js +25 -5
- package/dist/input/scroll-tools.js +4 -6
- package/dist/input/viewport-tools.js +5 -4
- package/dist/intent/inline-intent.d.ts +10 -19
- package/dist/intent/inline-intent.js +91 -1
- package/dist/intent/intent-shard-store.d.ts +72 -0
- package/dist/intent/intent-shard-store.js +203 -0
- package/dist/intent/intent-shard.d.ts +411 -0
- package/dist/intent/intent-shard.js +176 -0
- package/dist/intent/intent-store.d.ts +12 -0
- package/dist/intent/intent-store.js +21 -0
- package/dist/intent/intent-subject.d.ts +45 -0
- package/dist/intent/intent-subject.js +177 -0
- package/dist/intent/intent-tools.js +65 -2
- package/dist/intent/open-intents.d.ts +21 -0
- package/dist/intent/open-intents.js +15 -0
- package/dist/license/license-env.d.ts +29 -0
- package/dist/license/license-env.js +148 -0
- package/dist/loopback-agent.d.ts +31 -0
- package/dist/loopback-agent.js +57 -0
- package/dist/mcp/attach-memory.d.ts +18 -2
- package/dist/mcp/attach-memory.js +50 -7
- package/dist/mcp/client-identity.d.ts +31 -0
- package/dist/mcp/client-identity.js +51 -0
- package/dist/mcp/mcp-post-transport.d.ts +26 -0
- package/dist/mcp/mcp-post-transport.js +109 -0
- package/dist/mcp/mcp-proxy.d.ts +2 -1
- package/dist/mcp/mcp-proxy.js +52 -62
- package/dist/mcp/mcp.js +7 -1
- package/dist/mcp/proxy-backoff.d.ts +11 -0
- package/dist/mcp/proxy-backoff.js +13 -0
- package/dist/mcp/proxy-daemon-probe.d.ts +1 -0
- package/dist/mcp/proxy-daemon-probe.js +7 -1
- package/dist/mcp/proxy-handshake.d.ts +23 -0
- package/dist/mcp/proxy-handshake.js +31 -0
- package/dist/mcp/tool-catalog-cache.d.ts +8 -0
- package/dist/mcp/tool-catalog-cache.js +12 -0
- package/dist/memory/memory-tools.d.ts +2 -0
- package/dist/memory/memory-tools.js +82 -0
- package/dist/memory/project-memory.d.ts +34 -0
- package/dist/memory/project-memory.js +96 -0
- package/dist/oracles/propose-consequences.js +12 -3
- package/dist/pool/browser-pool.d.ts +63 -4
- package/dist/pool/browser-pool.js +113 -4
- package/dist/pool/playwright-launcher.js +9 -0
- package/dist/project/project-tools.js +3 -3
- package/dist/project/reticle-dir.d.ts +15 -2
- package/dist/project/reticle-dir.js +21 -4
- package/dist/runs/run-store.d.ts +10 -0
- package/dist/runs/run-store.js +10 -0
- package/dist/runs/verification-sync.d.ts +0 -11
- package/dist/runs/verification-sync.js +22 -1
- package/dist/runs/verify-server.d.ts +8 -1
- package/dist/runs/verify-server.js +14 -2
- package/dist/session/command-timeout.d.ts +5 -2
- package/dist/session/command-timeout.js +5 -4
- package/dist/session/lease-visibility.d.ts +67 -0
- package/dist/session/lease-visibility.js +76 -0
- package/dist/session/no-session-diagnosis.d.ts +23 -0
- package/dist/session/no-session-diagnosis.js +58 -17
- package/dist/session/no-session-next-action.d.ts +7 -0
- package/dist/session/no-session-next-action.js +42 -3
- package/dist/session/no-session-watch.d.ts +10 -6
- package/dist/session/no-session-watch.js +51 -19
- package/dist/session/pending-commands.d.ts +11 -1
- package/dist/session/pending-commands.js +12 -2
- package/dist/session/resolve-within.d.ts +34 -0
- package/dist/session/resolve-within.js +53 -0
- package/dist/session/session-info.d.ts +18 -0
- package/dist/session/session-info.js +11 -0
- package/dist/session/session-manager.d.ts +14 -0
- package/dist/session/session-manager.js +61 -3
- package/dist/session/session-mirror.d.ts +16 -0
- package/dist/session/session-mirror.js +22 -0
- package/dist/session/session-recommendation.d.ts +19 -5
- package/dist/session/session-recommendation.js +25 -9
- package/dist/session/session-successor.js +36 -1
- package/dist/session/session-tools.js +2 -2
- package/dist/session/session.d.ts +22 -2
- package/dist/session/session.js +87 -7
- package/dist/session/wall-clock.d.ts +12 -0
- package/dist/session/wall-clock.js +14 -0
- package/dist/setup/agent-configs.d.ts +96 -0
- package/dist/setup/agent-configs.js +333 -0
- package/dist/setup/agent-io.d.ts +9 -0
- package/dist/setup/agent-io.js +16 -0
- package/dist/setup/agent-writer.d.ts +51 -0
- package/dist/setup/agent-writer.js +115 -0
- package/dist/setup/approval-migration.d.ts +47 -0
- package/dist/setup/approval-migration.js +68 -0
- package/dist/setup/auto-approve.d.ts +85 -0
- package/dist/setup/auto-approve.js +150 -0
- package/dist/setup/bridge-port.d.ts +15 -0
- package/dist/setup/bridge-port.js +24 -0
- package/dist/setup/desktop-shape.d.ts +57 -0
- package/dist/setup/desktop-shape.js +63 -0
- package/dist/setup/dev-server-wait.d.ts +60 -0
- package/dist/setup/dev-server-wait.js +65 -0
- package/dist/setup/drive-agent.d.ts +71 -0
- package/dist/setup/drive-agent.js +171 -0
- package/dist/setup/drive-plan.d.ts +68 -0
- package/dist/setup/drive-plan.js +130 -0
- package/dist/setup/ensure-daemon.d.ts +29 -0
- package/dist/setup/ensure-daemon.js +58 -0
- package/dist/setup/init-runtime.d.ts +47 -0
- package/dist/setup/init-runtime.js +160 -0
- package/dist/setup/interrupt.d.ts +25 -0
- package/dist/setup/interrupt.js +40 -0
- package/dist/setup/license-key.d.ts +39 -0
- package/dist/setup/license-key.js +72 -0
- package/dist/setup/listeners.d.ts +37 -0
- package/dist/setup/listeners.js +85 -0
- package/dist/setup/node-effects.d.ts +60 -0
- package/dist/setup/node-effects.js +210 -0
- package/dist/setup/page-probe.d.ts +39 -0
- package/dist/setup/page-probe.js +49 -0
- package/dist/setup/relaunch.d.ts +36 -0
- package/dist/setup/relaunch.js +57 -0
- package/dist/setup/remaining-steps.d.ts +19 -0
- package/dist/setup/remaining-steps.js +31 -0
- package/dist/setup/run-setup.d.ts +99 -0
- package/dist/setup/run-setup.js +187 -0
- package/dist/setup/session-pick.d.ts +47 -0
- package/dist/setup/session-pick.js +51 -0
- package/dist/setup/setup-command.d.ts +45 -0
- package/dist/setup/setup-command.js +199 -0
- package/dist/setup/setup-options.d.ts +74 -0
- package/dist/setup/setup-options.js +67 -0
- package/dist/setup/transcripts.d.ts +16 -0
- package/dist/setup/transcripts.js +43 -0
- package/dist/status-payload.d.ts +14 -1
- package/dist/status-payload.js +23 -1
- package/dist/telemetry/daemon-telemetry.d.ts +8 -1
- package/dist/telemetry/daemon-telemetry.js +52 -5
- package/dist/telemetry/error-class.js +1 -1
- package/dist/telemetry/feedback-context.d.ts +9 -1
- package/dist/telemetry/feedback-context.js +60 -29
- package/dist/telemetry/feedback-local.d.ts +15 -0
- package/dist/telemetry/feedback-local.js +56 -0
- package/dist/telemetry/feedback.d.ts +11 -0
- package/dist/telemetry/feedback.js +19 -6
- package/dist/telemetry/init-telemetry.d.ts +2 -0
- package/dist/telemetry/init-telemetry.js +2 -0
- package/dist/telemetry/install-source.d.ts +14 -0
- package/dist/telemetry/install-source.js +30 -0
- package/dist/telemetry/license-activation.d.ts +7 -1
- package/dist/telemetry/license-activation.js +9 -5
- package/dist/telemetry/mcp-connection.js +7 -0
- package/dist/telemetry/project-profile.d.ts +12 -1
- package/dist/telemetry/project-profile.js +11 -6
- package/dist/telemetry/proxy-telemetry.d.ts +3 -0
- package/dist/telemetry/proxy-telemetry.js +21 -0
- package/dist/telemetry/session-metrics.d.ts +12 -0
- package/dist/telemetry/session-metrics.js +35 -1
- package/dist/telemetry/telemetry.js +17 -2
- package/dist/telemetry/tool-refused.d.ts +3 -1
- package/dist/telemetry/tool-refused.js +34 -2
- package/dist/tools/act-capsule.d.ts +10 -0
- package/dist/tools/act-capsule.js +8 -2
- package/dist/tools/act-danger.d.ts +2 -1
- package/dist/tools/act-danger.js +11 -3
- package/dist/tools/act-observation.d.ts +41 -0
- package/dist/tools/act-observation.js +36 -0
- package/dist/tools/act-preflight.d.ts +20 -0
- package/dist/tools/act-preflight.js +98 -0
- package/dist/tools/act-sequence-retry.d.ts +45 -0
- package/dist/tools/act-sequence-retry.js +58 -0
- package/dist/tools/act-sequence-tool.d.ts +13 -0
- package/dist/tools/act-sequence-tool.js +135 -0
- package/dist/tools/act-target.d.ts +14 -0
- package/dist/tools/act-target.js +47 -0
- package/dist/tools/act-tools.d.ts +21 -1
- package/dist/tools/act-tools.js +100 -213
- package/dist/tools/assert-verdict.js +22 -16
- package/dist/tools/browser-tools.js +6 -2
- package/dist/tools/dynamic-tools.js +24 -5
- package/dist/tools/error-recovery.d.ts +1 -0
- package/dist/tools/error-recovery.js +8 -0
- package/dist/tools/invoke-tool.js +59 -9
- package/dist/tools/lease-tools.d.ts +30 -4
- package/dist/tools/lease-tools.js +169 -5
- package/dist/tools/merged-name-redirect.d.ts +13 -0
- package/dist/tools/merged-name-redirect.js +22 -0
- package/dist/tools/numeric-bounds.d.ts +100 -0
- package/dist/tools/numeric-bounds.js +125 -0
- package/dist/tools/observe-tools.js +47 -40
- package/dist/tools/read-tools.js +28 -7
- package/dist/tools/real-input-attempt.d.ts +10 -0
- package/dist/tools/real-input-attempt.js +59 -1
- package/dist/tools/reconcile-tools.js +4 -4
- package/dist/tools/tool-kit.d.ts +12 -0
- package/dist/tools/tool-kit.js +6 -1
- package/dist/tools/tool-names.d.ts +2 -0
- package/dist/tools/tool-names.js +2 -0
- package/dist/tools/tools-helpers.d.ts +19 -0
- package/dist/tools/tools-helpers.js +46 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +56 -8
- package/dist/update/reticle-deps.d.ts +9 -0
- package/dist/update/reticle-deps.js +27 -0
- package/dist/update/sdk-sync.d.ts +1 -2
- package/dist/update/sdk-sync.js +1 -21
- package/dist/update/update-nudge.d.ts +18 -0
- package/dist/update/update-nudge.js +20 -0
- package/dist/version/peer-announce.js +4 -2
- package/dist/version/sdk-fix.d.ts +47 -0
- package/dist/version/sdk-fix.js +127 -0
- package/dist/version/version-nudge.d.ts +14 -0
- package/dist/version/version-nudge.js +21 -0
- package/dist/version/version-skew.d.ts +38 -3
- package/dist/version/version-skew.js +83 -7
- package/dist/visual/visual-store.d.ts +6 -6
- package/dist/visual/visual-store.js +18 -16
- package/dist/visual/visual-tools.js +57 -12
- package/docs/cli/doctor.mdx +1 -0
- package/docs/cli/serve.mdx +4 -2
- package/docs/cli.mdx +7 -3
- package/docs/desktop.mdx +1 -1
- package/docs/docs.json +1 -1
- package/docs/getting-started.md +19 -3
- package/docs/index.mdx +9 -6
- package/docs/install-agentic.mdx +38 -12
- package/docs/install-manual.mdx +1 -1
- package/docs/packages.mdx +1 -1
- package/docs/skill-file.mdx +3 -3
- package/docs/telemetry-contract.md +108 -4
- package/docs/tools-act-sequence.mdx +16 -0
- package/docs/usage.md +1 -1
- package/package.json +4 -3
- package/dist/init/cursor.d.ts +0 -24
- package/dist/init/cursor.js +0 -80
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find an enterprise licence key the customer actually placed, wherever they reasonably placed it.
|
|
3
|
+
*
|
|
4
|
+
* The daemon folded in `<cwd>/.env` and nothing else. It is spawned without an explicit `cwd`, so it
|
|
5
|
+
* inherits whatever directory the editor launched the MCP server from — in a monorepo that is the
|
|
6
|
+
* workspace root while the key sits in the app's own `.env`, and under some editors it is the user's
|
|
7
|
+
* home. The key was never read, `describeLicense` reported `missing`, and every event that customer
|
|
8
|
+
* produced said they had no licence, which is indistinguishable from a customer who has none.
|
|
9
|
+
*
|
|
10
|
+
* That is the worst shape a licensing bug can take. A key that fails LOUDLY gets reported by the
|
|
11
|
+
* customer within the hour. A key that silently fails to register leaves them believing they are
|
|
12
|
+
* licensed and us believing they are not, and neither side finds out.
|
|
13
|
+
*
|
|
14
|
+
* Two directions, because the field hits both: DOWN one level into app directories (the daemon
|
|
15
|
+
* starts at the workspace root) and UP to the project root (the editor starts inside the app).
|
|
16
|
+
*
|
|
17
|
+
* ONLY the licence key is ever taken out of a file found this way, and that restriction is the point
|
|
18
|
+
* rather than an optimisation. Bulk-importing a `.env` discovered by walking would let a directory
|
|
19
|
+
* the caller never named rebind the daemon's port, its telemetry gate or its allowed origins — a far
|
|
20
|
+
* worse bug than the one this fixes. `loadDotEnv` still owns the local file, where importing
|
|
21
|
+
* everything is what the caller asked for.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { dirname, join } from 'node:path';
|
|
25
|
+
import { LICENSE_KEY_ENV } from './license.js';
|
|
26
|
+
import { discoverProjectConfigs } from '../cli/config-discovery.js';
|
|
27
|
+
/** The files a framework tells people to put secrets in, most specific first. */
|
|
28
|
+
const ENV_FILES = ['.env.local', '.env'];
|
|
29
|
+
/** Stop the upward walk here: a licence belongs to a project, not to a machine. */
|
|
30
|
+
const ROOT_MARKERS = ['.git', 'package.json', 'pnpm-workspace.yaml'];
|
|
31
|
+
/** A bound on the upward walk, so a pathological path cannot spin. */
|
|
32
|
+
const MAX_DEPTH = 12;
|
|
33
|
+
function readKey(file) {
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(file))
|
|
36
|
+
return undefined;
|
|
37
|
+
for (const raw of readFileSync(file, 'utf8').split('\n')) {
|
|
38
|
+
const line = raw.trim();
|
|
39
|
+
if (0 === line.length || line.startsWith('#'))
|
|
40
|
+
continue;
|
|
41
|
+
const eq = line.indexOf('=');
|
|
42
|
+
if (eq <= 0)
|
|
43
|
+
continue;
|
|
44
|
+
if (line.slice(0, eq).trim() !== LICENSE_KEY_ENV)
|
|
45
|
+
continue;
|
|
46
|
+
const value = line.slice(eq + 1).trim();
|
|
47
|
+
const first = value[0];
|
|
48
|
+
const unquoted = ('"' === first || "'" === first) && value.endsWith(first) && value.length >= 2
|
|
49
|
+
? value.slice(1, -1)
|
|
50
|
+
: value;
|
|
51
|
+
if (unquoted.length > 0)
|
|
52
|
+
return unquoted;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A licence probe may never take the daemon down, and an unreadable file is not an error here.
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
function inDir(dir) {
|
|
61
|
+
for (const name of ENV_FILES) {
|
|
62
|
+
const found = readKey(join(dir, name));
|
|
63
|
+
if (found !== undefined)
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
function isRoot(dir) {
|
|
69
|
+
return ROOT_MARKERS.some((marker) => existsSync(join(dir, marker)));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The directories that hold a `.reticle.json`: the apps that were actually instrumented.
|
|
73
|
+
*
|
|
74
|
+
* This is the closest thing the daemon has to "the session's project directory", and it is the same
|
|
75
|
+
* discovery the no-session diagnosis uses to tell an agent where the app really is. Reusing it means
|
|
76
|
+
* the licence search and that diagnosis can never disagree about where the project lives.
|
|
77
|
+
*
|
|
78
|
+
* It replaced a guess at `apps/*` and `packages/*`, which missed exactly the repo shape
|
|
79
|
+
* `findWorkspaceApps` was written for: three apps at `web/`, `admin/` and `space/`.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* The conventional layout, kept as a fallback beside the declaration-driven search above.
|
|
83
|
+
*
|
|
84
|
+
* `discoverProjectConfigs` descends only into DECLARED workspaces, which is right and is what a real
|
|
85
|
+
* monorepo has. But `apps/web` with no `workspaces` entry is a shape people genuinely ship, and for
|
|
86
|
+
* a licence key the cost of one extra `readdir` is nothing against reporting a paying customer as
|
|
87
|
+
* unlicensed. Strictly widens coverage; it can never contradict the discovery, only add to it.
|
|
88
|
+
*/
|
|
89
|
+
const CONVENTIONAL_DIRS = ['apps', 'packages'];
|
|
90
|
+
function inConventionalChildren(dir) {
|
|
91
|
+
for (const group of CONVENTIONAL_DIRS) {
|
|
92
|
+
const base = join(dir, group);
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
if (!existsSync(base) || !statSync(base).isDirectory())
|
|
96
|
+
continue;
|
|
97
|
+
entries = readdirSync(base);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const found = inDir(join(base, entry));
|
|
104
|
+
if (found !== undefined)
|
|
105
|
+
return found;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
function inDiscoveredProjects(dir) {
|
|
111
|
+
let found;
|
|
112
|
+
try {
|
|
113
|
+
found = discoverProjectConfigs(dir).found;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
for (const config of found) {
|
|
119
|
+
const key = inDir(config.directory);
|
|
120
|
+
if (key !== undefined)
|
|
121
|
+
return key;
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The key, or undefined. Never throws, never mutates anything the caller did not ask it to.
|
|
127
|
+
*
|
|
128
|
+
* A key already present in `env` always wins: that is the operator stating it explicitly, and a file
|
|
129
|
+
* silently overriding it is the surprise `loadDotEnv` already refuses to cause.
|
|
130
|
+
*/
|
|
131
|
+
export function licenseKeyFromEnvFiles(startDir, env = process.env) {
|
|
132
|
+
const existing = env[LICENSE_KEY_ENV];
|
|
133
|
+
if (existing !== undefined && existing.length > 0)
|
|
134
|
+
return existing;
|
|
135
|
+
let dir = startDir;
|
|
136
|
+
for (let depth = 0; depth < MAX_DEPTH; depth++) {
|
|
137
|
+
const here = inDir(dir) ?? inDiscoveredProjects(dir) ?? inConventionalChildren(dir);
|
|
138
|
+
if (here !== undefined)
|
|
139
|
+
return here;
|
|
140
|
+
if (isRoot(dir))
|
|
141
|
+
break;
|
|
142
|
+
const parent = dirname(dir);
|
|
143
|
+
if (parent === dir)
|
|
144
|
+
break;
|
|
145
|
+
dir = parent;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* The one HTTP agent every loopback call in this process goes through.
|
|
4
|
+
*
|
|
5
|
+
* Without an `agent:`, Node hands the request to `http.globalAgent`, whose idle timeout is 5
|
|
6
|
+
* seconds. The MCP proxy's POST leg is one request per JSON-RPC message and an agent thinks for
|
|
7
|
+
* longer than five seconds between tool calls essentially always — so every single tool call opened
|
|
8
|
+
* a fresh TCP socket. On Windows that is the ephemeral-port / non-paged-pool exhaustion pattern, and
|
|
9
|
+
* the `ENOBUFS` it eventually raises killed the call outright.
|
|
10
|
+
*
|
|
11
|
+
* The numbers, each one a decision and not a taste:
|
|
12
|
+
*
|
|
13
|
+
* - `keepAlive: true` — the whole point: hold the socket open between calls so there is no churn to
|
|
14
|
+
* exhaust anything with.
|
|
15
|
+
* - `timeout: LOOPBACK_IDLE_MS` (45s) — how long an idle socket is kept for reuse. It has to outlast
|
|
16
|
+
* a model's think-time between two tool calls (which is what 5s failed to do) and it has to stay
|
|
17
|
+
* BELOW the daemon's own keep-alive timeout, so that the client is always the side that retires a
|
|
18
|
+
* socket. If the server closed first we would keep picking up sockets it had already closed, and
|
|
19
|
+
* that failure looks exactly like the one case we refuse to retry (see `isRetryableConnectError`).
|
|
20
|
+
* The daemon's side of that pact is `HTTP_KEEP_ALIVE_TIMEOUT_MS` in `http-server.ts`.
|
|
21
|
+
* - `keepAliveMsecs: 10_000` — TCP-level keep-alive probes on an idle socket, so a daemon that died
|
|
22
|
+
* without a FIN is noticed rather than handed back out as a live connection.
|
|
23
|
+
* - `maxSockets: 8` — the proxy multiplexes ONE MCP client, whose calls are near enough serial; 8 is
|
|
24
|
+
* generous headroom for overlapping notifications while capping the worst case far below anything
|
|
25
|
+
* a port table would notice. Exceeding it queues rather than fails.
|
|
26
|
+
*/
|
|
27
|
+
export declare const LOOPBACK_IDLE_MS = 45000;
|
|
28
|
+
/** Named separately because `http.Agent` does not expose its options back to a caller (or a test). */
|
|
29
|
+
export declare const LOOPBACK_AGENT_OPTIONS: http.AgentOptions;
|
|
30
|
+
export declare const loopbackAgent: http.Agent;
|
|
31
|
+
export declare function isRetryableConnectError(err: unknown): boolean;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
/**
|
|
3
|
+
* The one HTTP agent every loopback call in this process goes through.
|
|
4
|
+
*
|
|
5
|
+
* Without an `agent:`, Node hands the request to `http.globalAgent`, whose idle timeout is 5
|
|
6
|
+
* seconds. The MCP proxy's POST leg is one request per JSON-RPC message and an agent thinks for
|
|
7
|
+
* longer than five seconds between tool calls essentially always — so every single tool call opened
|
|
8
|
+
* a fresh TCP socket. On Windows that is the ephemeral-port / non-paged-pool exhaustion pattern, and
|
|
9
|
+
* the `ENOBUFS` it eventually raises killed the call outright.
|
|
10
|
+
*
|
|
11
|
+
* The numbers, each one a decision and not a taste:
|
|
12
|
+
*
|
|
13
|
+
* - `keepAlive: true` — the whole point: hold the socket open between calls so there is no churn to
|
|
14
|
+
* exhaust anything with.
|
|
15
|
+
* - `timeout: LOOPBACK_IDLE_MS` (45s) — how long an idle socket is kept for reuse. It has to outlast
|
|
16
|
+
* a model's think-time between two tool calls (which is what 5s failed to do) and it has to stay
|
|
17
|
+
* BELOW the daemon's own keep-alive timeout, so that the client is always the side that retires a
|
|
18
|
+
* socket. If the server closed first we would keep picking up sockets it had already closed, and
|
|
19
|
+
* that failure looks exactly like the one case we refuse to retry (see `isRetryableConnectError`).
|
|
20
|
+
* The daemon's side of that pact is `HTTP_KEEP_ALIVE_TIMEOUT_MS` in `http-server.ts`.
|
|
21
|
+
* - `keepAliveMsecs: 10_000` — TCP-level keep-alive probes on an idle socket, so a daemon that died
|
|
22
|
+
* without a FIN is noticed rather than handed back out as a live connection.
|
|
23
|
+
* - `maxSockets: 8` — the proxy multiplexes ONE MCP client, whose calls are near enough serial; 8 is
|
|
24
|
+
* generous headroom for overlapping notifications while capping the worst case far below anything
|
|
25
|
+
* a port table would notice. Exceeding it queues rather than fails.
|
|
26
|
+
*/
|
|
27
|
+
export const LOOPBACK_IDLE_MS = 45_000;
|
|
28
|
+
/** Named separately because `http.Agent` does not expose its options back to a caller (or a test). */
|
|
29
|
+
export const LOOPBACK_AGENT_OPTIONS = {
|
|
30
|
+
keepAlive: true,
|
|
31
|
+
keepAliveMsecs: 10_000,
|
|
32
|
+
timeout: LOOPBACK_IDLE_MS,
|
|
33
|
+
maxSockets: 8,
|
|
34
|
+
};
|
|
35
|
+
export const loopbackAgent = new http.Agent(LOOPBACK_AGENT_OPTIONS);
|
|
36
|
+
/**
|
|
37
|
+
* Socket-level failures worth another attempt — every one of them a statement about this machine's
|
|
38
|
+
* networking stack, not about the request.
|
|
39
|
+
*
|
|
40
|
+
* The list is deliberately narrow. It is not the retry decision on its own: the caller must ALSO
|
|
41
|
+
* know the request never reached the daemon. What the code check buys is that a programming error
|
|
42
|
+
* (`ERR_INVALID_URL`, a bad argument) cannot be retried — those fail before a socket too, and would
|
|
43
|
+
* otherwise burn the whole attempt budget on a bug that will never succeed.
|
|
44
|
+
*/
|
|
45
|
+
const RETRYABLE_CODES = new Set([
|
|
46
|
+
'ENOBUFS', // Windows: non-paged pool / ephemeral port exhaustion — the reported failure
|
|
47
|
+
'EADDRNOTAVAIL', // no ephemeral port left to bind
|
|
48
|
+
'EMFILE', // per-process fd limit
|
|
49
|
+
'ENFILE', // system-wide fd limit
|
|
50
|
+
'ECONNREFUSED', // daemon restarting, listener not back yet
|
|
51
|
+
'ECONNRESET', // peer closed the connection
|
|
52
|
+
'ETIMEDOUT', // connect never completed
|
|
53
|
+
]);
|
|
54
|
+
export function isRetryableConnectError(err) {
|
|
55
|
+
const code = err?.code;
|
|
56
|
+
return 'string' === typeof code && RETRYABLE_CODES.has(code);
|
|
57
|
+
}
|
|
@@ -29,12 +29,28 @@ export declare const AttachState: {
|
|
|
29
29
|
readonly NEVER_ATTACHED: "never-attached";
|
|
30
30
|
/** A client started it and never asked for the tool list — the state that needs a restart. */
|
|
31
31
|
readonly NEVER_ENUMERATED: "never-enumerated";
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The catalogue was read and no tool was ever called: both ends healthy, nothing crossing.
|
|
34
|
+
*
|
|
35
|
+
* Reported from the field as the hardest state to diagnose, and it is the one every component
|
|
36
|
+
* check passes. The browser SDK is injected, the daemon has a live session, the client lists all
|
|
37
|
+
* the tools, and the agent still reports Reticle as absent. `enumerated` used to cover this, which
|
|
38
|
+
* meant the most-broken install we know of read identically to a perfect one.
|
|
39
|
+
*/
|
|
40
|
+
readonly NEVER_CALLED: "never-called";
|
|
41
|
+
/** Started, enumerated, and actually used. Nothing to say. */
|
|
33
42
|
readonly ENUMERATED: "enumerated";
|
|
34
43
|
};
|
|
35
44
|
export type AttachState = (typeof AttachState)[keyof typeof AttachState];
|
|
36
45
|
/** Record that an MCP client started the proxy on this port. */
|
|
37
46
|
export declare function rememberProxyStarted(stateDir: string, port: number): void;
|
|
47
|
+
/**
|
|
48
|
+
* Record that a client actually CALLED a tool here, not merely listed them.
|
|
49
|
+
*
|
|
50
|
+
* Written once and then skipped, like the other two: this runs on the hot path of every tool call,
|
|
51
|
+
* and a diagnostic that writes a file per call would cost more than it tells anybody.
|
|
52
|
+
*/
|
|
53
|
+
export declare function rememberToolCalled(stateDir: string, port: number): void;
|
|
38
54
|
/**
|
|
39
55
|
* Record that a client asked for the tool list on this port.
|
|
40
56
|
*
|
|
@@ -43,7 +59,7 @@ export declare function rememberProxyStarted(stateDir: string, port: number): vo
|
|
|
43
59
|
* correctly.
|
|
44
60
|
*/
|
|
45
61
|
export declare function rememberEnumerated(stateDir: string, port: number): void;
|
|
46
|
-
/** Which of the
|
|
62
|
+
/** Which of the four client-side states this port is in. */
|
|
47
63
|
export declare function attachState(stateDir: string, port: number): AttachState;
|
|
48
64
|
/**
|
|
49
65
|
* The sentence to print for a state, or `undefined` when there is nothing to fix.
|
|
@@ -31,7 +31,16 @@ export const AttachState = {
|
|
|
31
31
|
NEVER_ATTACHED: 'never-attached',
|
|
32
32
|
/** A client started it and never asked for the tool list — the state that needs a restart. */
|
|
33
33
|
NEVER_ENUMERATED: 'never-enumerated',
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* The catalogue was read and no tool was ever called: both ends healthy, nothing crossing.
|
|
36
|
+
*
|
|
37
|
+
* Reported from the field as the hardest state to diagnose, and it is the one every component
|
|
38
|
+
* check passes. The browser SDK is injected, the daemon has a live session, the client lists all
|
|
39
|
+
* the tools, and the agent still reports Reticle as absent. `enumerated` used to cover this, which
|
|
40
|
+
* meant the most-broken install we know of read identically to a perfect one.
|
|
41
|
+
*/
|
|
42
|
+
NEVER_CALLED: 'never-called',
|
|
43
|
+
/** Started, enumerated, and actually used. Nothing to say. */
|
|
35
44
|
ENUMERATED: 'enumerated',
|
|
36
45
|
};
|
|
37
46
|
/**
|
|
@@ -53,10 +62,25 @@ const NEVER_ENUMERATED_ACTION = 'an MCP client started Reticle on this port and
|
|
|
53
62
|
* client next starts Reticle — and telling that user their server is unregistered would send them
|
|
54
63
|
* to re-run `init` over a working setup.
|
|
55
64
|
*/
|
|
65
|
+
/**
|
|
66
|
+
* The link that is broken, named as a link rather than as a component.
|
|
67
|
+
*
|
|
68
|
+
* Every other check in this product is about a PIECE: is the daemon up, is the SDK injected, is a
|
|
69
|
+
* session live, are the tools registered. This state is the one where every piece passes and the
|
|
70
|
+
* chain still does not carry anything, so the message has to say which hop failed and what to do
|
|
71
|
+
* about it, not repeat that the pieces are fine.
|
|
72
|
+
*/
|
|
73
|
+
const NEVER_CALLED_ACTION = "an MCP client listed Reticle's tools on this port and has never called one, so the tools are " +
|
|
74
|
+
'registered and no request has reached the daemon. That is the agent-to-daemon hop, not the ' +
|
|
75
|
+
'browser or the daemon: both of those can look healthy while this one is broken. Ask your agent ' +
|
|
76
|
+
'to run `reticle_sessions` explicitly. If it answers, the link is fine and the agent simply had ' +
|
|
77
|
+
'no reason to call it; if it says Reticle is unavailable, the client is talking to a different ' +
|
|
78
|
+
'server than the one this record belongs to, so re-check which command and port it launches. ' +
|
|
79
|
+
'This record is per port, not per client.';
|
|
56
80
|
const NEVER_ATTACHED_ACTION = 'no MCP client has started Reticle on this port, so either the server is not registered with your ' +
|
|
57
81
|
'client or nothing has attached since this record began — register it with `npx ' +
|
|
58
82
|
'@reticlehq/server init`, then restart your MCP client and run status again.';
|
|
59
|
-
const EMPTY = { attached: false, enumerated: false };
|
|
83
|
+
const EMPTY = { attached: false, enumerated: false, called: false };
|
|
60
84
|
function memoryPath(stateDir, port) {
|
|
61
85
|
return join(stateDir, `attach-${String(port)}.json`);
|
|
62
86
|
}
|
|
@@ -70,6 +94,7 @@ function read(stateDir, port) {
|
|
|
70
94
|
return {
|
|
71
95
|
attached: true === record.attached,
|
|
72
96
|
enumerated: true === record.enumerated,
|
|
97
|
+
called: true === record.called,
|
|
73
98
|
};
|
|
74
99
|
}
|
|
75
100
|
catch {
|
|
@@ -93,6 +118,18 @@ export function rememberProxyStarted(stateDir, port) {
|
|
|
93
118
|
return;
|
|
94
119
|
write(stateDir, port, { ...known, attached: true });
|
|
95
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Record that a client actually CALLED a tool here, not merely listed them.
|
|
123
|
+
*
|
|
124
|
+
* Written once and then skipped, like the other two: this runs on the hot path of every tool call,
|
|
125
|
+
* and a diagnostic that writes a file per call would cost more than it tells anybody.
|
|
126
|
+
*/
|
|
127
|
+
export function rememberToolCalled(stateDir, port) {
|
|
128
|
+
const known = read(stateDir, port);
|
|
129
|
+
if (known.called)
|
|
130
|
+
return;
|
|
131
|
+
write(stateDir, port, { ...known, called: true });
|
|
132
|
+
}
|
|
96
133
|
/**
|
|
97
134
|
* Record that a client asked for the tool list on this port.
|
|
98
135
|
*
|
|
@@ -104,14 +141,18 @@ export function rememberEnumerated(stateDir, port) {
|
|
|
104
141
|
const known = read(stateDir, port);
|
|
105
142
|
if (known.enumerated)
|
|
106
143
|
return;
|
|
107
|
-
write(stateDir, port, { attached: true, enumerated: true });
|
|
144
|
+
write(stateDir, port, { ...known, attached: true, enumerated: true });
|
|
108
145
|
}
|
|
109
|
-
/** Which of the
|
|
146
|
+
/** Which of the four client-side states this port is in. */
|
|
110
147
|
export function attachState(stateDir, port) {
|
|
111
148
|
const known = read(stateDir, port);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
149
|
+
// Ordered outward along the chain: attached, then listed, then actually used. The last hop is
|
|
150
|
+
// the one every component check misses, so it is asked last and answered explicitly.
|
|
151
|
+
if (!known.attached)
|
|
152
|
+
return AttachState.NEVER_ATTACHED;
|
|
153
|
+
if (!known.enumerated)
|
|
154
|
+
return AttachState.NEVER_ENUMERATED;
|
|
155
|
+
return known.called ? AttachState.ENUMERATED : AttachState.NEVER_CALLED;
|
|
115
156
|
}
|
|
116
157
|
/**
|
|
117
158
|
* The sentence to print for a state, or `undefined` when there is nothing to fix.
|
|
@@ -124,6 +165,8 @@ export function describeAttachState(state) {
|
|
|
124
165
|
return NEVER_ENUMERATED_ACTION;
|
|
125
166
|
if (state === AttachState.NEVER_ATTACHED)
|
|
126
167
|
return NEVER_ATTACHED_ACTION;
|
|
168
|
+
if (state === AttachState.NEVER_CALLED)
|
|
169
|
+
return NEVER_CALLED_ACTION;
|
|
127
170
|
return undefined;
|
|
128
171
|
}
|
|
129
172
|
/**
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH agent is on the other end of this daemon.
|
|
3
|
+
*
|
|
4
|
+
* MCP clients announce themselves in the `initialize` handshake — `claude-code`, `cursor-vscode`,
|
|
5
|
+
* `windsurf`, and so on. That claim is the ONLY honest source for this: an env-var table
|
|
6
|
+
* (`CLAUDECODE`, `CURSOR_TRACE_ID`, …) is a guess that silently rots every time a vendor renames a
|
|
7
|
+
* variable, and it cannot see a client nobody has added to the table yet.
|
|
8
|
+
*
|
|
9
|
+
* Read through a hook rather than a stored value because the handshake happens after the server is
|
|
10
|
+
* constructed, and a session can reconnect under a different client entirely.
|
|
11
|
+
*
|
|
12
|
+
* Lives here, not in telemetry, because it stopped being a telemetry detail the moment a
|
|
13
|
+
* verification RUN needed to record which agent produced it — and `runs/` must not import
|
|
14
|
+
* `telemetry/` to find out.
|
|
15
|
+
*
|
|
16
|
+
* Note what is NOT available and cannot be: the MODEL. `clientInfo` carries a name, a title and a
|
|
17
|
+
* version; MCP has no concept of a model, so the transport genuinely cannot tell us. Anything
|
|
18
|
+
* claiming to know it would be inventing it.
|
|
19
|
+
*/
|
|
20
|
+
export interface McpClientIdentity {
|
|
21
|
+
name?: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function setMcpClientIdentityHook(next: () => McpClientIdentity | undefined): void;
|
|
25
|
+
/** For tests, and for a daemon that is reused across transports. */
|
|
26
|
+
export declare function clearMcpClientIdentityHook(): void;
|
|
27
|
+
/**
|
|
28
|
+
* The client's own name and version, trimmed and bounded. Empty object when no MCP peer has
|
|
29
|
+
* introduced itself — a plain CLI run has no client, and reporting one would be a fabrication.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mcpClientIdentity(): McpClientIdentity;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH agent is on the other end of this daemon.
|
|
3
|
+
*
|
|
4
|
+
* MCP clients announce themselves in the `initialize` handshake — `claude-code`, `cursor-vscode`,
|
|
5
|
+
* `windsurf`, and so on. That claim is the ONLY honest source for this: an env-var table
|
|
6
|
+
* (`CLAUDECODE`, `CURSOR_TRACE_ID`, …) is a guess that silently rots every time a vendor renames a
|
|
7
|
+
* variable, and it cannot see a client nobody has added to the table yet.
|
|
8
|
+
*
|
|
9
|
+
* Read through a hook rather than a stored value because the handshake happens after the server is
|
|
10
|
+
* constructed, and a session can reconnect under a different client entirely.
|
|
11
|
+
*
|
|
12
|
+
* Lives here, not in telemetry, because it stopped being a telemetry detail the moment a
|
|
13
|
+
* verification RUN needed to record which agent produced it — and `runs/` must not import
|
|
14
|
+
* `telemetry/` to find out.
|
|
15
|
+
*
|
|
16
|
+
* Note what is NOT available and cannot be: the MODEL. `clientInfo` carries a name, a title and a
|
|
17
|
+
* version; MCP has no concept of a model, so the transport genuinely cannot tell us. Anything
|
|
18
|
+
* claiming to know it would be inventing it.
|
|
19
|
+
*/
|
|
20
|
+
/** Caps, so a hostile or broken client cannot write unbounded strings into an artifact. */
|
|
21
|
+
const MAX_NAME = 64;
|
|
22
|
+
const MAX_VERSION = 32;
|
|
23
|
+
let hook;
|
|
24
|
+
export function setMcpClientIdentityHook(next) {
|
|
25
|
+
hook = next;
|
|
26
|
+
}
|
|
27
|
+
/** For tests, and for a daemon that is reused across transports. */
|
|
28
|
+
export function clearMcpClientIdentityHook() {
|
|
29
|
+
hook = undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The client's own name and version, trimmed and bounded. Empty object when no MCP peer has
|
|
33
|
+
* introduced itself — a plain CLI run has no client, and reporting one would be a fabrication.
|
|
34
|
+
*/
|
|
35
|
+
export function mcpClientIdentity() {
|
|
36
|
+
try {
|
|
37
|
+
const info = hook?.();
|
|
38
|
+
const name = info?.name;
|
|
39
|
+
const version = info?.version;
|
|
40
|
+
return {
|
|
41
|
+
...(name !== undefined && '' !== name ? { name: name.slice(0, MAX_NAME) } : {}),
|
|
42
|
+
...(version !== undefined && '' !== version
|
|
43
|
+
? { version: version.slice(0, MAX_VERSION) }
|
|
44
|
+
: {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// A hook that throws must never fail the thing that asked — this is descriptive, not load-bearing.
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP proxy's POST leg: its own socket pool, and the one retry that is safe to make.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `mcp-proxy.ts` when that file passed the 1000-line backstop after two independent
|
|
5
|
+
* hardenings of this path met in a merge. It earns its own module rather than being an arbitrary
|
|
6
|
+
* slice: everything here is about ONE thing — getting a single JSON-RPC line into the daemon, or
|
|
7
|
+
* saying honestly that it never arrived — and the file it came from is about the SSE stream.
|
|
8
|
+
*/
|
|
9
|
+
import * as http from 'node:http';
|
|
10
|
+
/** One bounded pool for the short-lived POST side of each MCP SSE session. */
|
|
11
|
+
export declare const MCP_PROXY_HTTP_AGENT_OPTIONS: {
|
|
12
|
+
readonly keepAlive: true;
|
|
13
|
+
readonly keepAliveMsecs: 30000;
|
|
14
|
+
readonly timeout: 60000;
|
|
15
|
+
readonly maxSockets: 8;
|
|
16
|
+
readonly scheduling: "lifo";
|
|
17
|
+
};
|
|
18
|
+
export declare function shouldRetryUnsentPost(error: unknown, bytesWritten: number, retryCount: number): boolean;
|
|
19
|
+
export interface PostFailure {
|
|
20
|
+
reason: string;
|
|
21
|
+
transport: boolean;
|
|
22
|
+
attempts: number;
|
|
23
|
+
}
|
|
24
|
+
type HttpRequest = (options: http.RequestOptions, callback: (response: http.IncomingMessage) => void) => http.ClientRequest;
|
|
25
|
+
export declare function postToSession(url: string, body: string, request?: HttpRequest): Promise<PostFailure | null>;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP proxy's POST leg: its own socket pool, and the one retry that is safe to make.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `mcp-proxy.ts` when that file passed the 1000-line backstop after two independent
|
|
5
|
+
* hardenings of this path met in a merge. It earns its own module rather than being an arbitrary
|
|
6
|
+
* slice: everything here is about ONE thing — getting a single JSON-RPC line into the daemon, or
|
|
7
|
+
* saying honestly that it never arrived — and the file it came from is about the SSE stream.
|
|
8
|
+
*/
|
|
9
|
+
import * as http from 'node:http';
|
|
10
|
+
import { log } from '../log.js';
|
|
11
|
+
import { getSessionMetrics } from '../telemetry/session-metrics.js';
|
|
12
|
+
import { reconnectDelayMs } from './proxy-backoff.js';
|
|
13
|
+
/** One bounded pool for the short-lived POST side of each MCP SSE session. */
|
|
14
|
+
export const MCP_PROXY_HTTP_AGENT_OPTIONS = {
|
|
15
|
+
keepAlive: true,
|
|
16
|
+
keepAliveMsecs: 30_000,
|
|
17
|
+
timeout: 60_000,
|
|
18
|
+
maxSockets: 8,
|
|
19
|
+
scheduling: 'lifo',
|
|
20
|
+
};
|
|
21
|
+
const MCP_PROXY_HTTP_AGENT = new http.Agent(MCP_PROXY_HTTP_AGENT_OPTIONS);
|
|
22
|
+
/**
|
|
23
|
+
* POST one JSON-RPC line into the daemon's session. Resolves null on success, or a structured
|
|
24
|
+
* failure when the request never reached a server that will answer it.
|
|
25
|
+
*
|
|
26
|
+
* It used to resolve `void` in every case, logging the failure and moving on. That is the THIRD way
|
|
27
|
+
* a call goes unanswered, and the only one neither `streamLossReplies` nor the queue timer can see:
|
|
28
|
+
* the SSE stream is healthy (so nothing drops) and the request was forwarded (so nothing is queued),
|
|
29
|
+
* but the POST leg is its own TCP connection and an ECONNRESET on it means the daemon never received
|
|
30
|
+
* the call. Nobody was ever going to reply, and the caller waited for its own timeout.
|
|
31
|
+
*/
|
|
32
|
+
const RETRYABLE_UNSENT_POST_ERRORS = new Set(['ENOBUFS', 'ERR_NO_BUFFER_SPACE', 'EADDRNOTAVAIL']);
|
|
33
|
+
export function shouldRetryUnsentPost(error, bytesWritten, retryCount) {
|
|
34
|
+
const code = error?.code;
|
|
35
|
+
return 0 === retryCount && 0 === bytesWritten && RETRYABLE_UNSENT_POST_ERRORS.has(String(code));
|
|
36
|
+
}
|
|
37
|
+
export function postToSession(url, body, request = (options, callback) => http.request(options, callback)) {
|
|
38
|
+
const send = (retryCount) => new Promise((resolve) => {
|
|
39
|
+
const parsed = new URL(url);
|
|
40
|
+
const bodyBuf = Buffer.from(body, 'utf8');
|
|
41
|
+
const options = {
|
|
42
|
+
host: parsed.hostname,
|
|
43
|
+
port: parsed.port !== '' ? parseInt(parsed.port, 10) : 80,
|
|
44
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
45
|
+
method: 'POST',
|
|
46
|
+
agent: MCP_PROXY_HTTP_AGENT,
|
|
47
|
+
headers: {
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
'Content-Length': bodyBuf.byteLength,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
// A keep-alive socket carries historical bytes; retry safety depends on this request's delta.
|
|
53
|
+
let socket;
|
|
54
|
+
let socketBytesBeforeRequest = 0;
|
|
55
|
+
let settled = false;
|
|
56
|
+
const req = request(options, (res) => {
|
|
57
|
+
if (settled)
|
|
58
|
+
return;
|
|
59
|
+
settled = true;
|
|
60
|
+
const status = res.statusCode ?? 0;
|
|
61
|
+
const rejected = status < 200 || status >= 300;
|
|
62
|
+
if (rejected) {
|
|
63
|
+
// A non-2xx from the daemon MCP endpoint used to be swallowed, hanging the JSON-RPC call
|
|
64
|
+
// client-side with no diagnostic. It is a refusal: no response is coming over the stream.
|
|
65
|
+
log('reticle_mcp_proxy_post_non2xx', { status, path: options.path });
|
|
66
|
+
}
|
|
67
|
+
else if (retryCount > 0) {
|
|
68
|
+
getSessionMetrics().recordPostRetrySaved();
|
|
69
|
+
}
|
|
70
|
+
res.resume(); // drain so the socket is reused
|
|
71
|
+
resolve(rejected
|
|
72
|
+
? {
|
|
73
|
+
reason: `daemon rejected the call with HTTP ${String(status)}`,
|
|
74
|
+
transport: false,
|
|
75
|
+
attempts: retryCount + 1,
|
|
76
|
+
}
|
|
77
|
+
: null);
|
|
78
|
+
});
|
|
79
|
+
req.once('socket', (assigned) => {
|
|
80
|
+
socket = assigned;
|
|
81
|
+
socketBytesBeforeRequest = assigned.bytesWritten;
|
|
82
|
+
});
|
|
83
|
+
req.on('error', (err) => {
|
|
84
|
+
if (settled)
|
|
85
|
+
return;
|
|
86
|
+
settled = true;
|
|
87
|
+
// Invisible to mcp_connection_lost (SSE is up) and to tool_refused (handler never ran).
|
|
88
|
+
getSessionMetrics().recordPostSocketFailure();
|
|
89
|
+
const bytesWritten = socket === undefined ? 0 : Math.max(0, socket.bytesWritten - socketBytesBeforeRequest);
|
|
90
|
+
if (shouldRetryUnsentPost(err, bytesWritten, retryCount)) {
|
|
91
|
+
const retryInMs = reconnectDelayMs(retryCount + 1);
|
|
92
|
+
log('reticle_mcp_proxy_post_retry', {
|
|
93
|
+
code: err.code,
|
|
94
|
+
retryInMs,
|
|
95
|
+
});
|
|
96
|
+
setTimeout(() => void send(retryCount + 1).then(resolve), retryInMs);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
log('reticle_mcp_proxy_post_error', { error: err.message });
|
|
100
|
+
resolve({
|
|
101
|
+
reason: `post failed: ${err.message}`,
|
|
102
|
+
transport: true,
|
|
103
|
+
attempts: retryCount + 1,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
req.end(bodyBuf);
|
|
107
|
+
});
|
|
108
|
+
return send(0);
|
|
109
|
+
}
|
package/dist/mcp/mcp-proxy.d.ts
CHANGED
|
@@ -5,13 +5,14 @@ export { probeDaemon, waitForDaemon } from './proxy-daemon-probe.js';
|
|
|
5
5
|
* proxy. Where the code lives is a file-size decision; where it is imported from is an API.
|
|
6
6
|
*/
|
|
7
7
|
export { accountProxyLogWrite, proxyLog, proxyLogPath, PROXY_LOG_CHECK_BYTES, recoverOversizedProxyLog, setProxyLogPort, } from './proxy-log.js';
|
|
8
|
+
export { reconnectDelayMs, RECONNECT_BASE_MS, RECONNECT_CAP_MS } from './proxy-backoff.js';
|
|
9
|
+
export { MCP_PROXY_HTTP_AGENT_OPTIONS, postToSession, shouldRetryUnsentPost, type PostFailure, } from './mcp-post-transport.js';
|
|
8
10
|
/**
|
|
9
11
|
* Overridable so the survival spec can reach exhaustion in seconds instead of minutes — the same
|
|
10
12
|
* reason the idle windows take an env override. A positive integer only; anything else falls back,
|
|
11
13
|
* so a typo cannot silently disable the retry loop.
|
|
12
14
|
*/
|
|
13
15
|
export declare const MAX_RECONNECT_ATTEMPTS: number;
|
|
14
|
-
export declare function reconnectDelayMs(attempt: number): number;
|
|
15
16
|
/** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
|
|
16
17
|
export declare const RECONNECT_INITIALIZE_ID = "__reticle_proxy_reinit";
|
|
17
18
|
/**
|