@reticlehq/server 2.7.0 → 2.9.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/README.md +14 -5
- package/SKILL.md +130 -778
- package/dist/bridge/bridge.d.ts +23 -0
- package/dist/bridge/bridge.js +80 -2
- package/dist/bridge/bridge.test-harness.d.ts +5 -0
- package/dist/bridge/bridge.test-harness.js +6 -4
- package/dist/capsule/causal-summary.d.ts +36 -1
- package/dist/capsule/causal-summary.js +33 -7
- package/dist/capsule/divergence.js +7 -1
- package/dist/cli/chromium-hint.d.ts +33 -5
- package/dist/cli/chromium-hint.js +82 -12
- package/dist/cli/cli-doctor.js +65 -34
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-kill.d.ts +58 -0
- package/dist/cli/cli-kill.js +162 -0
- package/dist/cli/cli-parse.d.ts +16 -1
- package/dist/cli/cli-parse.js +32 -9
- package/dist/cli/cli-port.d.ts +32 -0
- package/dist/cli/cli-port.js +82 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +47 -3
- package/dist/cli/config-discovery.d.ts +35 -0
- package/dist/cli/config-discovery.js +163 -0
- package/dist/cli/doctor-daemon-line.js +4 -1
- package/dist/cli/doctor-project-line.d.ts +37 -0
- package/dist/cli/doctor-project-line.js +35 -0
- package/dist/cli/doctor-rows.d.ts +51 -0
- package/dist/cli/doctor-rows.js +54 -0
- package/dist/cli/doctor-sessions-line.d.ts +12 -0
- package/dist/cli/doctor-sessions-line.js +69 -0
- package/dist/cli/drive-attach.d.ts +99 -0
- package/dist/cli/drive-attach.js +203 -0
- package/dist/cli/drive-command.d.ts +13 -0
- package/dist/cli/drive-command.js +103 -0
- package/dist/cli/open-note.d.ts +20 -0
- package/dist/cli/open-note.js +37 -0
- package/dist/cli/port-holder.d.ts +10 -0
- package/dist/cli/port-holder.js +26 -2
- package/dist/cli/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +119 -39
- package/dist/crawl/crawl-tools.js +12 -2
- package/dist/crawl/crawl.d.ts +13 -0
- package/dist/crawl/crawl.js +50 -2
- package/dist/events/contradictions.d.ts +46 -0
- package/dist/events/contradictions.js +35 -22
- package/dist/events/declared.d.ts +45 -0
- package/dist/events/declared.js +73 -0
- package/dist/events/echo-mismatch.d.ts +8 -2
- package/dist/events/echo-mismatch.js +33 -5
- package/dist/events/event-filters.d.ts +1 -1
- package/dist/events/event-filters.js +7 -4
- package/dist/events/predicate-eval.d.ts +17 -72
- package/dist/events/predicate-eval.js +117 -236
- package/dist/events/predicate-parse.js +31 -3
- package/dist/events/predicate-route.d.ts +32 -0
- package/dist/events/predicate-route.js +102 -0
- package/dist/events/predicate-schema.d.ts +99 -0
- package/dist/events/predicate-schema.js +309 -0
- package/dist/events/predicate.d.ts +8 -0
- package/dist/events/predicate.js +296 -22
- package/dist/flows/annotate-tools.js +3 -0
- package/dist/flows/annotate.js +18 -4
- package/dist/flows/coverage.d.ts +38 -5
- package/dist/flows/coverage.js +30 -4
- package/dist/flows/decision.d.ts +15 -0
- package/dist/flows/decision.js +1 -1
- package/dist/flows/flow-classify.js +1 -1
- package/dist/flows/flow-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +21 -5
- package/dist/flows/heal-run.js +1 -1
- package/dist/flows/replay.js +3 -0
- package/dist/honesty/accepted-write.d.ts +3 -0
- package/dist/honesty/accepted-write.js +8 -3
- package/dist/honesty/already-true.js +15 -0
- package/dist/honesty/blind-spots.js +10 -2
- package/dist/honesty/honesty.d.ts +11 -0
- package/dist/honesty/honesty.js +1 -0
- package/dist/honesty/uncaptured-bodies.d.ts +4 -2
- package/dist/honesty/uncaptured-bodies.js +9 -2
- package/dist/honesty/unread-outcome.d.ts +8 -0
- package/dist/honesty/unread-outcome.js +36 -9
- package/dist/honesty/unsettled.d.ts +49 -0
- package/dist/honesty/unsettled.js +82 -0
- package/dist/honesty/verified.d.ts +36 -3
- package/dist/honesty/verified.js +54 -11
- package/dist/http-server.d.ts +19 -0
- package/dist/http-server.js +202 -4
- package/dist/impact/impact-recorder.d.ts +23 -0
- package/dist/impact/impact-recorder.js +76 -0
- package/dist/impact/impact-store.d.ts +47 -0
- package/dist/impact/impact-store.js +171 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +47 -10
- package/dist/init/agent-rules.d.ts +36 -0
- package/dist/init/agent-rules.js +132 -22
- package/dist/init/astro-patch.d.ts +2 -1
- package/dist/init/astro-patch.js +38 -6
- package/dist/init/capabilities.d.ts +22 -0
- package/dist/init/capabilities.js +76 -0
- package/dist/init/confirm.d.ts +114 -0
- package/dist/init/confirm.js +165 -0
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- package/dist/init/cra.js +3 -2
- package/dist/init/csp-check.d.ts +38 -0
- package/dist/init/csp-check.js +92 -0
- package/dist/init/csp-doctor.d.ts +25 -0
- package/dist/init/csp-doctor.js +49 -0
- package/dist/init/cursor.js +6 -9
- package/dist/init/detect.d.ts +8 -0
- package/dist/init/detect.js +33 -3
- package/dist/init/mcp-clients.d.ts +1 -0
- package/dist/init/mcp-clients.js +51 -7
- package/dist/init/mcp.d.ts +15 -0
- package/dist/init/mcp.js +19 -0
- package/dist/init/non-js-project.js +29 -5
- package/dist/init/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +78 -16
- package/dist/init/plan.d.ts +47 -6
- package/dist/init/plan.js +228 -69
- package/dist/init/run.d.ts +24 -2
- package/dist/init/run.js +157 -45
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +54 -6
- package/dist/init/snippets.d.ts +45 -5
- package/dist/init/snippets.js +194 -42
- package/dist/init/vite-config.js +53 -5
- package/dist/input/network-mock-tools.d.ts +19 -0
- package/dist/input/network-mock-tools.js +37 -5
- package/dist/input/scroll-find.d.ts +6 -0
- package/dist/input/scroll-find.js +17 -2
- package/dist/input/scroll-tools.js +4 -0
- package/dist/journal/attach-journal.d.ts +9 -1
- package/dist/journal/attach-journal.js +13 -2
- package/dist/journal/journal-query.d.ts +13 -0
- package/dist/journal/journal-query.js +28 -3
- package/dist/journal/workspace-gitignore.d.ts +10 -0
- package/dist/journal/workspace-gitignore.js +59 -0
- package/dist/license/license.js +1 -1
- package/dist/mcp/mcp-proxy.d.ts +35 -30
- package/dist/mcp/mcp-proxy.js +306 -104
- package/dist/mcp/mcp.d.ts +10 -1
- package/dist/mcp/mcp.js +70 -34
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +109 -2
- package/dist/mcp/proxy-lifecycle.d.ts +11 -2
- package/dist/mcp/proxy-lifecycle.js +12 -3
- package/dist/mcp/proxy-log.d.ts +63 -0
- package/dist/mcp/proxy-log.js +132 -0
- package/dist/mcp/server-instructions.d.ts +17 -0
- package/dist/mcp/server-instructions.js +56 -0
- package/dist/mcp/shared-params.d.ts +27 -0
- package/dist/mcp/shared-params.js +36 -0
- package/dist/pool/browser-pool.d.ts +22 -2
- package/dist/pool/browser-pool.js +44 -4
- package/dist/project/learned-routes.d.ts +24 -0
- package/dist/project/learned-routes.js +63 -0
- package/dist/project/project-store.d.ts +6 -0
- package/dist/project/project-store.js +25 -1
- package/dist/project/reticle-dir.d.ts +2 -0
- package/dist/project/reticle-dir.js +1 -0
- package/dist/session/connection-memory.d.ts +59 -0
- package/dist/session/connection-memory.js +108 -0
- package/dist/session/dev-command.d.ts +26 -0
- package/dist/session/dev-command.js +89 -0
- package/dist/session/dev-server-probe.d.ts +68 -0
- package/dist/session/dev-server-probe.js +102 -7
- package/dist/session/last-act.d.ts +26 -0
- package/dist/session/last-act.js +35 -1
- package/dist/session/live-control-tools.js +42 -5
- package/dist/session/no-session-diagnosis.d.ts +67 -2
- package/dist/session/no-session-diagnosis.js +236 -27
- package/dist/session/no-session-next-action.d.ts +46 -0
- package/dist/session/no-session-next-action.js +85 -0
- package/dist/session/no-session-watch.d.ts +40 -1
- package/dist/session/no-session-watch.js +174 -8
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -1
- package/dist/session/ref-provenance.d.ts +54 -0
- package/dist/session/ref-provenance.js +61 -0
- package/dist/session/session-lease.js +1 -1
- package/dist/session/session-manager.d.ts +4 -0
- package/dist/session/session-manager.js +35 -5
- package/dist/session/session.d.ts +10 -1
- package/dist/session/session.js +146 -5
- package/dist/telemetry/app-instrumented.d.ts +8 -0
- package/dist/telemetry/app-instrumented.js +10 -0
- package/dist/telemetry/automation-hint.d.ts +17 -0
- package/dist/telemetry/automation-hint.js +71 -0
- package/dist/telemetry/bug-found.d.ts +15 -17
- package/dist/telemetry/bug-found.js +52 -21
- package/dist/telemetry/cli-telemetry.js +7 -1
- package/dist/telemetry/daemon-telemetry.js +24 -0
- package/dist/telemetry/feedback.js +1 -1
- package/dist/telemetry/init-telemetry.js +9 -1
- package/dist/telemetry/install-source.d.ts +56 -0
- package/dist/telemetry/install-source.js +74 -0
- package/dist/telemetry/instrumentation-stall.d.ts +50 -0
- package/dist/telemetry/instrumentation-stall.js +90 -0
- package/dist/telemetry/run-telemetry.js +4 -1
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +15 -0
- package/dist/telemetry/telemetry.d.ts +7 -1
- package/dist/telemetry/telemetry.js +26 -2
- package/dist/telemetry/tool-refused.d.ts +19 -0
- package/dist/telemetry/tool-refused.js +55 -0
- package/dist/telemetry/verification-of.js +46 -7
- package/dist/temp-dir.d.ts +27 -0
- package/dist/temp-dir.js +46 -0
- package/dist/tools/act-tools.js +209 -28
- package/dist/tools/assert-grade.d.ts +1 -1
- package/dist/tools/assert-grade.js +27 -5
- package/dist/tools/assert-verdict.js +49 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/contract-tools.js +4 -0
- package/dist/tools/dynamic-tools.js +16 -7
- package/dist/tools/error-recovery.d.ts +10 -0
- package/dist/tools/error-recovery.js +73 -9
- package/dist/tools/feedback-invite.js +1 -1
- package/dist/tools/invoke-tool.js +159 -26
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +40 -1
- package/dist/tools/lease-tools.js +181 -83
- package/dist/tools/navigate-result.js +1 -1
- package/dist/tools/observe-tools.js +10 -4
- package/dist/tools/observed-nothing.js +1 -1
- package/dist/tools/react-grace.d.ts +52 -0
- package/dist/tools/react-grace.js +90 -0
- package/dist/tools/read-tools.js +67 -5
- package/dist/tools/resolve-target.d.ts +33 -0
- package/dist/tools/resolve-target.js +53 -0
- package/dist/tools/settle-in-flight.d.ts +94 -0
- package/dist/tools/settle-in-flight.js +146 -0
- package/dist/tools/snapshot-delta.d.ts +10 -0
- package/dist/tools/snapshot-delta.js +194 -9
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +86 -1
- package/dist/tools/tools.js +61 -8
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +55 -11
- package/docs/actions.mdx +120 -0
- package/docs/agent-cheatsheet.md +95 -29
- package/docs/architecture.md +52 -35
- package/docs/benchmarks.md +127 -75
- package/docs/best-practices.mdx +216 -0
- package/docs/capabilities.mdx +534 -0
- package/docs/cli/affected.mdx +53 -0
- package/docs/cli/capsules.mdx +70 -0
- package/docs/cli/cloud.mdx +153 -0
- package/docs/cli/doctor.mdx +109 -0
- package/docs/cli/drive.mdx +74 -0
- package/docs/cli/feedback.mdx +67 -0
- package/docs/cli/gate.mdx +64 -0
- package/docs/cli/help.mdx +94 -0
- package/docs/cli/hunt.mdx +73 -0
- package/docs/cli/identify.mdx +76 -0
- package/docs/cli/init.mdx +121 -0
- package/docs/cli/kill.mdx +91 -0
- package/docs/cli/license.mdx +48 -0
- package/docs/cli/mcp.mdx +61 -0
- package/docs/cli/open.mdx +74 -0
- package/docs/cli/restart.mdx +60 -0
- package/docs/cli/rollback.mdx +35 -0
- package/docs/cli/serve.mdx +69 -0
- package/docs/cli/status.mdx +88 -0
- package/docs/cli/stop.mdx +64 -0
- package/docs/cli/telemetry.mdx +57 -0
- package/docs/cli/update.mdx +52 -0
- package/docs/cli/verify.mdx +76 -0
- package/docs/cli/version.mdx +39 -0
- package/docs/cli/watch.mdx +62 -0
- package/docs/cli.mdx +241 -0
- package/docs/debugging.md +16 -10
- package/docs/deploy-checks.md +45 -12
- package/docs/desktop.mdx +384 -0
- package/docs/docs.json +364 -0
- package/docs/enterprise.md +22 -16
- package/docs/faq.mdx +102 -0
- package/docs/fixtures.md +13 -7
- package/docs/flows.md +29 -23
- package/docs/for-agents.md +71 -0
- package/docs/frameworks.mdx +238 -0
- package/docs/gate-plan.md +48 -42
- package/docs/gates.md +28 -22
- package/docs/getting-started.md +129 -77
- package/docs/human-control.md +24 -18
- package/docs/index.mdx +143 -0
- package/docs/install-agentic.mdx +224 -0
- package/docs/install-manual.mdx +196 -0
- package/docs/instrumentation.mdx +238 -0
- package/docs/integration-patterns.md +41 -35
- package/docs/local-registry.md +19 -13
- package/docs/multi-agent-testing.md +19 -13
- package/docs/packages/babel-plugin.mdx +79 -0
- package/docs/packages/browser.mdx +111 -0
- package/docs/packages/core.mdx +116 -0
- package/docs/packages/electron.mdx +85 -0
- package/docs/packages/eslint-plugin.mdx +86 -0
- package/docs/packages/next.mdx +89 -0
- package/docs/packages/react.mdx +91 -0
- package/docs/packages/server.mdx +98 -0
- package/docs/packages/tauri.mdx +63 -0
- package/docs/packages/test.mdx +119 -0
- package/docs/packages/vite-plugin.mdx +94 -0
- package/docs/packages.mdx +218 -0
- package/docs/platform-integration.md +35 -26
- package/docs/predicates.mdx +248 -0
- package/docs/quickstart.mdx +257 -0
- package/docs/recipes.mdx +282 -0
- package/docs/skill-file.mdx +107 -0
- package/docs/state-management.mdx +199 -0
- package/docs/style.css +14 -0
- package/docs/system-map.md +50 -33
- package/docs/telemetry-contract.md +106 -52
- package/docs/telemetry-events.mdx +475 -0
- package/docs/telemetry.md +63 -55
- package/docs/testing.md +44 -6
- package/docs/token-efficiency.md +37 -27
- package/docs/tools-act-and-wait.mdx +173 -0
- package/docs/tools-act-sequence.mdx +137 -0
- package/docs/tools-act.mdx +95 -0
- package/docs/tools-assert.mdx +129 -0
- package/docs/tools-console.mdx +63 -0
- package/docs/tools-inspect.mdx +88 -0
- package/docs/tools-navigate.mdx +99 -0
- package/docs/tools-network.mdx +119 -0
- package/docs/tools-observe.mdx +145 -0
- package/docs/tools-overview.mdx +160 -0
- package/docs/tools-query.mdx +126 -0
- package/docs/tools-session-and-feedback.mdx +112 -0
- package/docs/tools-sessions.mdx +94 -0
- package/docs/tools-snapshot.mdx +129 -0
- package/docs/tools-state.mdx +104 -0
- package/docs/tools-tools-and-run.mdx +156 -0
- package/docs/tools-wait-for.mdx +85 -0
- package/docs/troubleshooting.mdx +263 -0
- package/docs/usage.md +209 -165
- package/docs/vs-chrome-devtools-mcp.mdx +123 -0
- package/docs/vs-playwright-mcp.mdx +125 -0
- package/docs/vs-screenshots.mdx +137 -0
- package/docs/what-is-reticle.mdx +63 -0
- package/docs/why-reticle.mdx +109 -0
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -256
- package/docs/matrix/2.5.0/claude-code-machine.json +0 -19
- package/docs/matrix/2.5.0/codex-machine.json +0 -20
- package/docs/matrix/2.5.0/cursor-machine.json +0 -22
- package/docs/matrix/2.5.0/gemini-machine.json +0 -22
- package/docs/matrix/2.5.0/opencode-machine.json +0 -22
- package/docs/matrix/2.5.0/vscode-machine.json +0 -22
- package/docs/matrix/2.5.0/windsurf-machine.json +0 -22
- package/docs/matrix/MATRIX.md +0 -26
- package/docs/matrix/README.md +0 -59
- package/docs/mint.json +0 -61
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
|
-
import { localInitializeResponse, isHandshakeLine } from './proxy-handshake.js';
|
|
2
|
+
import { localInitializeResponse, isHandshakeLine, drainLines, MAX_STDIN_LINE_BYTES, } from './proxy-handshake.js';
|
|
3
3
|
import { ToolCatalogCache } from './tool-catalog-cache.js';
|
|
4
|
+
import { toolsChangedNotification } from './proxy-handshake.js';
|
|
4
5
|
import * as net from 'node:net';
|
|
5
|
-
import {
|
|
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';
|
|
6
|
+
import { LOOPBACK_HOST, MCP_SSE_PATH, MCP_SHUTDOWN_EVENT, ReticleEnv, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
|
|
9
7
|
import { SERVER_VERSION } from '../version/server-version.js';
|
|
8
|
+
import { buildServerInstructions } from './server-instructions.js';
|
|
9
|
+
import { hasProjectConnectedBefore } from '../session/connection-memory.js';
|
|
10
|
+
import { reticleStateHome } from '../daemon/daemon.js';
|
|
11
|
+
import { readProjectId } from '../cli/cli-port.js';
|
|
10
12
|
import { PEER_VERSION_PARAM, PEER_CONTRACT_PARAM } from '../version/peer-announce.js';
|
|
11
13
|
import { onStreamDrop, onClientRequest, onReconnectBudgetSpent, onQueueExpired, OnDrop, OnRequest, } from './proxy-lifecycle.js';
|
|
14
|
+
import { describePresence, probePresence } from '../daemon/port-presence.js';
|
|
15
|
+
/**
|
|
16
|
+
* The same `/status` probe `doctor`, `status` and `kill` ask with. Reused rather than re-written:
|
|
17
|
+
* the whole defect this import closes was the proxy answering a DIFFERENT question from every other
|
|
18
|
+
* surface, and a second copy of the probe is how that comes back.
|
|
19
|
+
*/
|
|
20
|
+
import { fetchStatus as fetchDaemonStatus } from '../cli/cli-launch.js';
|
|
21
|
+
/**
|
|
22
|
+
* Re-exported so every existing caller (and every spec) keeps importing the proxy's log from the
|
|
23
|
+
* proxy. Where the code lives is a file-size decision; where it is imported from is an API.
|
|
24
|
+
*/
|
|
25
|
+
export { accountProxyLogWrite, proxyLog, proxyLogPath, PROXY_LOG_CHECK_BYTES, recoverOversizedProxyLog, setProxyLogPort, } from './proxy-log.js';
|
|
26
|
+
import { proxyLog } from './proxy-log.js';
|
|
12
27
|
import { log } from '../log.js';
|
|
13
|
-
import { OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
28
|
+
import { OutageReason, OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
14
29
|
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
15
30
|
/**
|
|
16
31
|
* How long to wait for the spawned daemon's port to accept connections before giving up. The default
|
|
@@ -51,52 +66,6 @@ export function reconnectDelayMs(attempt) {
|
|
|
51
66
|
}
|
|
52
67
|
/** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
|
|
53
68
|
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
69
|
function parseJsonRpc(line) {
|
|
101
70
|
try {
|
|
102
71
|
const parsed = JSON.parse(line);
|
|
@@ -166,8 +135,10 @@ export class PendingRequests {
|
|
|
166
135
|
* daemon under a live client left 20 of 20 concurrent calls hanging until the client's own timeout,
|
|
167
136
|
* with the MCP server still perfectly alive. An agent cannot tell that apart from "still working".
|
|
168
137
|
*/
|
|
169
|
-
export function streamLossReplies(pending, reason) {
|
|
170
|
-
return pending
|
|
138
|
+
export function streamLossReplies(pending, reason, nextStep) {
|
|
139
|
+
return pending
|
|
140
|
+
.drain()
|
|
141
|
+
.map((id) => transportLossReply(JSON.parse(id), reason, nextStep));
|
|
171
142
|
}
|
|
172
143
|
/**
|
|
173
144
|
* -32001: a server-defined error. Not -32603 (internal): nothing went wrong INSIDE the call, the
|
|
@@ -175,15 +146,21 @@ export function streamLossReplies(pending, reason) {
|
|
|
175
146
|
* to retry.
|
|
176
147
|
*/
|
|
177
148
|
const TRANSPORT_LOSS_CODE = -32001;
|
|
178
|
-
/**
|
|
179
|
-
|
|
149
|
+
/**
|
|
150
|
+
* The one shape every "the transport ate your call" answer takes, whichever leg died.
|
|
151
|
+
*
|
|
152
|
+
* `nextStep` is appended when the caller knows one. Without it this reply describes a condition and
|
|
153
|
+
* stops there, which is all a first-run user got out of their first ever tool call.
|
|
154
|
+
*/
|
|
155
|
+
export function transportLossReply(id, reason, nextStep) {
|
|
180
156
|
return JSON.stringify({
|
|
181
157
|
jsonrpc: '2.0',
|
|
182
158
|
id,
|
|
183
159
|
error: {
|
|
184
160
|
code: TRANSPORT_LOSS_CODE,
|
|
185
161
|
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.'
|
|
162
|
+
'complete. Reticle is recovering the connection; retry if the action is safe to repeat.' +
|
|
163
|
+
(nextStep === undefined ? '' : ` ${nextStep}`),
|
|
187
164
|
},
|
|
188
165
|
});
|
|
189
166
|
}
|
|
@@ -207,8 +184,38 @@ const SHUTDOWN_DRAIN_MS = 5_000;
|
|
|
207
184
|
* well under the 60s an MCP client typically allows, so the answer comes from us with a reason
|
|
208
185
|
* rather than from their timer with none.
|
|
209
186
|
*/
|
|
210
|
-
const QUEUE_WAIT_MS = 20_000;
|
|
187
|
+
export const QUEUE_WAIT_MS = 20_000;
|
|
211
188
|
const SHUTDOWN_DRAIN_POLL_MS = 25;
|
|
189
|
+
/** A port that refuses is a port nothing is listening on YET — not a port that answered badly. */
|
|
190
|
+
const CONNECTION_REFUSED = 'ECONNREFUSED';
|
|
191
|
+
/**
|
|
192
|
+
* How long the FIRST connect keeps chasing a refused port before calling it a startup failure.
|
|
193
|
+
*
|
|
194
|
+
* It used to get exactly one attempt. Reported from a Windows first bootstrap: the daemon was still
|
|
195
|
+
* booting, the single connect was refused, and nothing tried again — only a fresh CLIENT request
|
|
196
|
+
* re-probes the port, and the client was already blocked on the call that then expired with -32001.
|
|
197
|
+
* The first tool call of somebody's first ever session is the worst place available to lose one.
|
|
198
|
+
*
|
|
199
|
+
* Deliberately longer than QUEUE_WAIT_MS: a retry that stops before the queue does cannot rescue the
|
|
200
|
+
* request it exists for, and a daemon found afterwards at least restores service for the next call.
|
|
201
|
+
*/
|
|
202
|
+
const FIRST_CONNECT_WINDOW_MS = QUEUE_WAIT_MS + 15_000;
|
|
203
|
+
/** Retry a first connect, or report it? Pure, so the window can be held against the queue's. */
|
|
204
|
+
export function shouldRetryFirstConnect(error, elapsedMs) {
|
|
205
|
+
const code = error?.code;
|
|
206
|
+
return CONNECTION_REFUSED === code && FIRST_CONNECT_WINDOW_MS > elapsedMs;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* What to do about it, carried on the reply the queue expiry sends.
|
|
210
|
+
*
|
|
211
|
+
* A bare -32001 on the first call after an install says nothing a user can act on: not whether to
|
|
212
|
+
* wait, not whether to retry, not whether the install itself is broken.
|
|
213
|
+
*/
|
|
214
|
+
export function queueExpiryNextStep(port) {
|
|
215
|
+
return (`No Reticle daemon answered on port ${String(port)}. Retry the call — the next request starts ` +
|
|
216
|
+
'one. If it keeps failing, run `reticle doctor` in a terminal: it reports what is holding the ' +
|
|
217
|
+
'port and whether the daemon can start at all.');
|
|
218
|
+
}
|
|
212
219
|
/**
|
|
213
220
|
* Makes a reconnect invisible to the client.
|
|
214
221
|
*
|
|
@@ -398,6 +405,24 @@ export function buildSessionUrl(rawData, port) {
|
|
|
398
405
|
* `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
|
|
399
406
|
* transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
|
|
400
407
|
*/
|
|
408
|
+
/**
|
|
409
|
+
* The instructions this proxy should advertise when it answers the handshake itself.
|
|
410
|
+
*
|
|
411
|
+
* Same inputs the daemon uses, read from the same durable memory, so a client that happens to meet
|
|
412
|
+
* the proxy first is not told something different from one that meets the daemon first. Best-effort:
|
|
413
|
+
* an unreadable state home must never be the reason a handshake fails, so it degrades to the
|
|
414
|
+
* fresh-install wording rather than throwing.
|
|
415
|
+
*/
|
|
416
|
+
function proxyInstructions(port) {
|
|
417
|
+
try {
|
|
418
|
+
return buildServerInstructions({
|
|
419
|
+
previouslyConnected: hasProjectConnectedBefore(reticleStateHome(), port, readProjectId(process.cwd())),
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return buildServerInstructions({ previouslyConnected: false });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
401
426
|
export function startMcpProxy(port,
|
|
402
427
|
/**
|
|
403
428
|
* Bring a daemon back before reconnecting. Without it the proxy retried a DEAD port until the
|
|
@@ -419,6 +444,16 @@ ensureDaemon) {
|
|
|
419
444
|
* Dormant means the next thing the CLIENT asks for is what brings Reticle back.
|
|
420
445
|
*/
|
|
421
446
|
let dormant = false;
|
|
447
|
+
/**
|
|
448
|
+
* Deliver the proxy's own answer to a queued `initialize`, now, for a stated reason.
|
|
449
|
+
*
|
|
450
|
+
* Set by `armLocalHandshake` while the client's handshake is unanswered, and cleared once it is.
|
|
451
|
+
* It exists because the 12-second timer down there is a timeout for a question the proxy could
|
|
452
|
+
* not answer — "is a daemon coming?" — and the drop path CAN answer it: a port a stranger holds
|
|
453
|
+
* will never serve SSE, however long anybody waits. Declared up here with the other closure
|
|
454
|
+
* state so the drop path can reach it without a temporal-dead-zone hazard.
|
|
455
|
+
*/
|
|
456
|
+
let deliverHandshakeLocally;
|
|
422
457
|
const stdinQueue = [];
|
|
423
458
|
/**
|
|
424
459
|
* Fail anything still queued after QUEUE_WAIT_MS. Re-armed on every push and cleared on flush,
|
|
@@ -430,28 +465,42 @@ ensureDaemon) {
|
|
|
430
465
|
clearTimeout(queueTimer);
|
|
431
466
|
queueTimer = undefined;
|
|
432
467
|
};
|
|
468
|
+
/**
|
|
469
|
+
* Answer everything queued, drop it, and go dormant.
|
|
470
|
+
*
|
|
471
|
+
* `reason` is what goes on the wire. The timer below passes the only thing a timeout can know —
|
|
472
|
+
* that nothing arrived — but a wake that FAILED knows something much better: exactly why it
|
|
473
|
+
* failed. Both used to end here after the same twenty seconds of silence, because only the timer
|
|
474
|
+
* could reach this code.
|
|
475
|
+
*/
|
|
476
|
+
const expireQueue = (reason) => {
|
|
477
|
+
clearQueueTimer();
|
|
478
|
+
if (stopped || 0 === stdinQueue.length)
|
|
479
|
+
return;
|
|
480
|
+
// Drop what we could never send, and answer it, so the caller is never left guessing.
|
|
481
|
+
stdinQueue.splice(0);
|
|
482
|
+
for (const reply of streamLossReplies(pending, reason, queueExpiryNextStep(port))) {
|
|
483
|
+
emit(reply);
|
|
484
|
+
}
|
|
485
|
+
// The expiry is the proof that the reconnect we believed was in flight is not coming — a
|
|
486
|
+
// wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
|
|
487
|
+
// rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
|
|
488
|
+
// NEXT request re-probe the port instead of queueing behind a reconnect that will never
|
|
489
|
+
// land, and it is the only path by which a daemon started AFTER we gave up is ever found.
|
|
490
|
+
dormant = onQueueExpired() === OnDrop.DORMANT;
|
|
491
|
+
proxyLog('reticle_mcp_proxy_queue_expired', {
|
|
492
|
+
port,
|
|
493
|
+
dormant,
|
|
494
|
+
reason,
|
|
495
|
+
note: 'answered queued requests and went dormant; the next client request re-probes the port',
|
|
496
|
+
});
|
|
497
|
+
};
|
|
433
498
|
const armQueueTimer = () => {
|
|
434
499
|
if (queueTimer !== undefined)
|
|
435
500
|
return; // the oldest entry owns the deadline
|
|
436
501
|
queueTimer = setTimeout(() => {
|
|
437
502
|
queueTimer = undefined;
|
|
438
|
-
|
|
439
|
-
return;
|
|
440
|
-
// Drop what we could never send, and answer it, so the caller is never left guessing.
|
|
441
|
-
stdinQueue.splice(0);
|
|
442
|
-
for (const reply of streamLossReplies(pending, 'no daemon could be reached'))
|
|
443
|
-
emit(reply);
|
|
444
|
-
// The expiry is the proof that the reconnect we believed was in flight is not coming — a
|
|
445
|
-
// wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
|
|
446
|
-
// rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
|
|
447
|
-
// NEXT request re-probe the port instead of queueing behind a reconnect that will never
|
|
448
|
-
// land, and it is the only path by which a daemon started AFTER we gave up is ever found.
|
|
449
|
-
dormant = onQueueExpired() === OnDrop.DORMANT;
|
|
450
|
-
proxyLog('reticle_mcp_proxy_queue_expired', {
|
|
451
|
-
port,
|
|
452
|
-
dormant,
|
|
453
|
-
note: 'answered queued requests and went dormant; the next client request re-probes the port',
|
|
454
|
-
});
|
|
503
|
+
expireQueue('no daemon could be reached');
|
|
455
504
|
}, QUEUE_WAIT_MS);
|
|
456
505
|
queueTimer.unref();
|
|
457
506
|
};
|
|
@@ -464,6 +513,19 @@ ensureDaemon) {
|
|
|
464
513
|
};
|
|
465
514
|
let stopped = false;
|
|
466
515
|
let attempts = 0;
|
|
516
|
+
/** The daemon announced a planned shutdown; the next drop is that, not a fault. */
|
|
517
|
+
let daemonRetiring = false;
|
|
518
|
+
/** Why the stream we are currently trying to replace went away — carried onto the recovery. */
|
|
519
|
+
let lastDropReason = OutageReason.OTHER;
|
|
520
|
+
/**
|
|
521
|
+
* When the proxy started, and how many times the first connect has been refused since.
|
|
522
|
+
*
|
|
523
|
+
* The clock is read at this boundary rather than injected, matching the shutdown drain below:
|
|
524
|
+
* it measures how long a real daemon has had to boot, which is a wall-clock fact about the
|
|
525
|
+
* machine and not a decision the caller could supply.
|
|
526
|
+
*/
|
|
527
|
+
const startedAt = Date.now();
|
|
528
|
+
let firstConnectAttempts = 0;
|
|
467
529
|
/**
|
|
468
530
|
* The ONE way a line leaves for the daemon — so a POST that never lands still owes an answer.
|
|
469
531
|
*
|
|
@@ -501,6 +563,10 @@ ensureDaemon) {
|
|
|
501
563
|
// headers, is what earns a fresh retry budget. Resetting on headers let a daemon that
|
|
502
564
|
// accepts SSE and drops it spin the reconnect loop at the 250ms floor forever, never backing
|
|
503
565
|
// off and never reaching the budget that puts the proxy dormant. Measured: 32 retries in 8s.
|
|
566
|
+
// Report the recovery BEFORE the counter is cleared: `attempts` is what coming back actually
|
|
567
|
+
// cost, and it is the only number that can falsify the drop event. See OutageStage.RECOVERED.
|
|
568
|
+
if (attempts > 0)
|
|
569
|
+
reportMcpOutage(OutageStage.RECOVERED, { reason: lastDropReason, attempts });
|
|
504
570
|
attempts = 0;
|
|
505
571
|
// The new session's McpServer has never seen the client's initialize — replay it first, then
|
|
506
572
|
// flush whatever the client sent while we were reconnecting.
|
|
@@ -509,6 +575,24 @@ ensureDaemon) {
|
|
|
509
575
|
clearQueueTimer();
|
|
510
576
|
for (const queued of stdinQueue.splice(0))
|
|
511
577
|
forward(url, queued);
|
|
578
|
+
// The client may be holding a tool list we invented. See toolsChangedNotification.
|
|
579
|
+
const relist = toolsChangedNotification(toolsAnnouncedLocally);
|
|
580
|
+
if (relist !== null) {
|
|
581
|
+
toolsAnnouncedLocally = false;
|
|
582
|
+
emit(relist);
|
|
583
|
+
proxyLog('reticle_mcp_proxy_tools_relisted', { port });
|
|
584
|
+
}
|
|
585
|
+
handshakeAnswered = true;
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
// The daemon telling us it is retiring. Not forwarded: it is addressed to this proxy, and the
|
|
589
|
+
// client behind it has no use for a frame it cannot parse.
|
|
590
|
+
if (MCP_SHUTDOWN_EVENT === event) {
|
|
591
|
+
daemonRetiring = true;
|
|
592
|
+
proxyLog('reticle_mcp_proxy_daemon_retiring', {
|
|
593
|
+
port,
|
|
594
|
+
note: 'the daemon announced a planned shutdown; the drop that follows is not an outage',
|
|
595
|
+
});
|
|
512
596
|
return;
|
|
513
597
|
}
|
|
514
598
|
if ('message' === event && !replay.shouldSuppressInbound(data)) {
|
|
@@ -518,11 +602,33 @@ ensureDaemon) {
|
|
|
518
602
|
emit(data);
|
|
519
603
|
}
|
|
520
604
|
}
|
|
521
|
-
function scheduleReconnect(
|
|
605
|
+
function scheduleReconnect(rawReason, detail) {
|
|
522
606
|
if (stopped)
|
|
523
607
|
return;
|
|
524
608
|
postUrl = null;
|
|
609
|
+
// A stream the daemon warned us about is a planned shutdown, not a fault. The socket looks
|
|
610
|
+
// identical either way from here, so this flag is the only thing that can tell them apart —
|
|
611
|
+
// and without it every scheduled retirement was counted as an outage the agent suffered.
|
|
612
|
+
const reason = daemonRetiring ? OutageReason.DAEMON_SHUTDOWN : rawReason;
|
|
613
|
+
daemonRetiring = false;
|
|
614
|
+
lastDropReason = reason;
|
|
525
615
|
attempts++;
|
|
616
|
+
// Everything the dead session owed is settled HERE, in the one place every drop funnels
|
|
617
|
+
// through, and not in the response callback that first noticed the socket die. A reset emits
|
|
618
|
+
// on BOTH halves of the request, so whichever handler wins decides what gets paid — the same
|
|
619
|
+
// shape as the reconnect fan-out, and it left the other half paying nothing.
|
|
620
|
+
//
|
|
621
|
+
// Order matters between the two. Drain the queue FIRST: a queued request is tracked in
|
|
622
|
+
// `pending` as well, so answering without draining leaves the next `endpoint` frame free to
|
|
623
|
+
// flush it into the fresh session, where the daemon answers the same id a second time and the
|
|
624
|
+
// client sees two responses for one request. Then answer, because the new session cannot know
|
|
625
|
+
// what the old one owed and silence here is permanent.
|
|
626
|
+
stdinQueue.splice(0);
|
|
627
|
+
const lost = streamLossReplies(pending, reason);
|
|
628
|
+
for (const reply of lost)
|
|
629
|
+
emit(reply);
|
|
630
|
+
// How many calls this drop actually killed. Zero means the agent could not feel it.
|
|
631
|
+
const pendingLost = lost.length;
|
|
526
632
|
// Once per process: this session has now lost its tools at least once, which is the number
|
|
527
633
|
// that says whether the transport work actually landed for real users. `pendingLost` is the
|
|
528
634
|
// part an agent can FEEL — almost every measured drop killed nothing in flight.
|
|
@@ -556,16 +662,22 @@ ensureDaemon) {
|
|
|
556
662
|
// shut itself down as idle would be respawned instantly, sit unused, shut down again, and
|
|
557
663
|
// loop forever — measured at four processes in 200s before this. If nothing is listening the
|
|
558
664
|
// proxy goes DORMANT and the next client request brings a daemon back (see the stdin reader).
|
|
559
|
-
void
|
|
560
|
-
if (onStreamDrop(
|
|
665
|
+
void probePresence(port, { tcpOpen: probeDaemon, status: fetchDaemonStatus }).then((presence) => {
|
|
666
|
+
if (onStreamDrop(presence) === OnDrop.REATTACH) {
|
|
561
667
|
connect(false);
|
|
562
668
|
return;
|
|
563
669
|
}
|
|
564
670
|
dormant = true;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
671
|
+
// The sentence `doctor` has always been able to say, in the log the agent's own process
|
|
672
|
+
// writes. A FOREIGN holder used to be indistinguishable from a free port here, and the
|
|
673
|
+
// proxy spun against it instead of saying which one it was.
|
|
674
|
+
const sentence = describePresence(presence, port);
|
|
675
|
+
proxyLog('reticle_mcp_proxy_dormant', { port, presence, note: sentence });
|
|
676
|
+
// Nothing is coming that could answer the client's handshake through this port, and the
|
|
677
|
+
// proxy now knows that rather than merely suspecting it. Waiting out LOCAL_HANDSHAKE_MS
|
|
678
|
+
// would be waiting for information already in hand — and #125 measured what that costs:
|
|
679
|
+
// a client whose initialize budget is under ~12s loses Reticle for the whole session.
|
|
680
|
+
deliverHandshakeLocally?.(sentence);
|
|
569
681
|
});
|
|
570
682
|
}, wait).unref();
|
|
571
683
|
}
|
|
@@ -573,23 +685,45 @@ ensureDaemon) {
|
|
|
573
685
|
// Announce this process to the daemon on the connect we already make — it is the single judge
|
|
574
686
|
// of version skew, and this is the only moment it learns the agent's MCP server exists.
|
|
575
687
|
const announce = `${MCP_SSE_PATH}?${PEER_VERSION_PARAM}=${encodeURIComponent(SERVER_VERSION)}&${PEER_CONTRACT_PARAM}=${encodeURIComponent(CONTRACT_FINGERPRINT)}`;
|
|
688
|
+
/**
|
|
689
|
+
* ONE reconnect per connect attempt, whoever notices the failure first.
|
|
690
|
+
*
|
|
691
|
+
* A dead socket emits on BOTH halves — `req`'s `error` and the response's `aborted`/`close`
|
|
692
|
+
* for the same TCP death — and this latch used to live inside the response callback, so
|
|
693
|
+
* `req.on('error')` scheduled a SECOND reconnect that the drop path could not see. Two chains
|
|
694
|
+
* came back, both died the same way, and both split again. The retry budget cannot stop that
|
|
695
|
+
* either: any one chain reaching an `endpoint` frame resets `attempts` to zero for all of
|
|
696
|
+
* them. It is the runaway behind a proxy log large enough to fill a disk, and the doubling is
|
|
697
|
+
* visible in the field logs as roughly two `reconnecting` lines per `reconnected` one.
|
|
698
|
+
*/
|
|
699
|
+
let settled = false;
|
|
700
|
+
/**
|
|
701
|
+
* Whether this attempt ever got a response, as opposed to never reaching the daemon.
|
|
702
|
+
*
|
|
703
|
+
* `first` alone conflates two different situations, and the `req.on('error')` branch below
|
|
704
|
+
* treats both as a startup failure the caller handles. Before any stream exists that is right:
|
|
705
|
+
* there is no daemon, so rejecting is the honest answer. Once the FIRST stream has connected
|
|
706
|
+
* and is serving, a socket reset is a DROP, and rejecting it kills the proxy outright instead
|
|
707
|
+
* of reconnecting. The agent loses Reticle for the rest of the session, on its first stream,
|
|
708
|
+
* with nothing to retry it.
|
|
709
|
+
*
|
|
710
|
+
* It is also a race rather than a certainty, which is worse: a reset emits on both halves, so
|
|
711
|
+
* whether the proxy reconnects or dies depended on whether `res`'s `close` or `req`'s `error`
|
|
712
|
+
* won. Every later stream recovers correctly; you simply have to survive the first one.
|
|
713
|
+
*/
|
|
714
|
+
let responded = false;
|
|
576
715
|
const req = http.get({ host: LOOPBACK_HOST, port, path: announce }, (res) => {
|
|
716
|
+
responded = true;
|
|
577
717
|
if (!first)
|
|
578
718
|
proxyLog('reticle_mcp_proxy_reconnected', { port });
|
|
579
719
|
res.setEncoding('utf8');
|
|
580
|
-
// `end` and `error` can both fire on one response; only the first should drive a reconnect.
|
|
581
|
-
let settled = false;
|
|
582
720
|
const drop = (reason, detail) => {
|
|
583
721
|
if (settled)
|
|
584
722
|
return;
|
|
585
723
|
settled = true;
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
|
|
589
|
-
for (const reply of lost)
|
|
590
|
-
emit(reply);
|
|
591
|
-
// How many calls this drop actually killed. Zero means the agent could not feel it.
|
|
592
|
-
scheduleReconnect(reason, detail, lost.length);
|
|
724
|
+
// What the dead session owed is settled inside scheduleReconnect, so the other half of the
|
|
725
|
+
// socket's death pays exactly the same debt when it is the one that notices first.
|
|
726
|
+
scheduleReconnect(reason, detail);
|
|
593
727
|
};
|
|
594
728
|
const sse = new SseFrameParser();
|
|
595
729
|
res.on('data', (chunk) => {
|
|
@@ -606,12 +740,44 @@ ensureDaemon) {
|
|
|
606
740
|
});
|
|
607
741
|
// The very first connect failing means there is no daemon to talk to — that is a startup
|
|
608
742
|
// failure the caller handles. Later ones are just the daemon bouncing: keep retrying.
|
|
609
|
-
req.on('error', (err) =>
|
|
743
|
+
req.on('error', (err) => {
|
|
744
|
+
if (settled)
|
|
745
|
+
return;
|
|
746
|
+
settled = true;
|
|
747
|
+
if (first && !responded) {
|
|
748
|
+
// A refused port on the very first connect is a daemon still booting, not a failure. Keep
|
|
749
|
+
// chasing it: nothing else will, because only a fresh client request re-probes the port
|
|
750
|
+
// and the client is already blocked on the call this would rescue.
|
|
751
|
+
if (shouldRetryFirstConnect(err, Date.now() - startedAt)) {
|
|
752
|
+
firstConnectAttempts++;
|
|
753
|
+
const retryInMs = reconnectDelayMs(firstConnectAttempts);
|
|
754
|
+
proxyLog('reticle_mcp_proxy_first_connect_retry', {
|
|
755
|
+
port,
|
|
756
|
+
attempt: firstConnectAttempts,
|
|
757
|
+
retryInMs,
|
|
758
|
+
note: 'the daemon has not finished booting; still waiting for it to accept',
|
|
759
|
+
});
|
|
760
|
+
setTimeout(() => connect(true), retryInMs).unref();
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
reject(err);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
scheduleReconnect('connect_error', err.message);
|
|
767
|
+
});
|
|
610
768
|
}
|
|
611
769
|
connect(true);
|
|
612
770
|
// ── stdin reader ─────────────────────────────────────────────────────────
|
|
613
771
|
process.stdin.setEncoding('utf8');
|
|
614
772
|
let handshakeAnswered = false;
|
|
773
|
+
/**
|
|
774
|
+
* True while the client is holding a tool list WE made up rather than one the daemon sent.
|
|
775
|
+
*
|
|
776
|
+
* Set when we answer `initialize` locally, cleared the moment we tell the client to re-list. It
|
|
777
|
+
* is not the same question as `handshakeAnswered`, which is also true after a real daemon
|
|
778
|
+
* handshake — and a client that already has the daemon's catalog must not be sent chasing it.
|
|
779
|
+
*/
|
|
780
|
+
let toolsAnnouncedLocally = false;
|
|
615
781
|
/**
|
|
616
782
|
* Answer a queued `initialize` locally if the daemon has not produced an endpoint in time.
|
|
617
783
|
*
|
|
@@ -622,13 +788,33 @@ ensureDaemon) {
|
|
|
622
788
|
const armLocalHandshake = (line) => {
|
|
623
789
|
if (handshakeAnswered)
|
|
624
790
|
return;
|
|
625
|
-
|
|
791
|
+
// Ask the same question the daemon would, from the same durable memory, so the client gets
|
|
792
|
+
// the same answer it would have got had a daemon been up to give it.
|
|
793
|
+
const response = localInitializeResponse(line, proxyInstructions(port));
|
|
626
794
|
if (null === response)
|
|
627
795
|
return;
|
|
628
|
-
|
|
796
|
+
// Take the debt NOW, not when the timer fires.
|
|
797
|
+
//
|
|
798
|
+
// Three paths can answer this id — the daemon, the stream-loss drain, and the timer below —
|
|
799
|
+
// and `take` is the coordination between them. This was the one answerer that never claimed,
|
|
800
|
+
// so a drop before the timer fired paid the handshake a -32001 and the timer then paid it a
|
|
801
|
+
// result, under the same id. Both halves are fatal: a client that gets an error for
|
|
802
|
+
// `initialize` marks the whole MCP server failed, and one that survives that sees two
|
|
803
|
+
// responses for one request. Claiming it here says out loud what has always been true — the
|
|
804
|
+
// proxy owns this answer and will deliver it — so the drain stops owing it. If a session is
|
|
805
|
+
// established first, the `endpoint` handler sets `handshakeAnswered` before the queue is
|
|
806
|
+
// flushed, which disarms the timer and leaves the daemon's own reply the only one.
|
|
807
|
+
const claimed = parseJsonRpc(line);
|
|
808
|
+
if (claimed?.id !== undefined)
|
|
809
|
+
pending.take(claimed.id);
|
|
810
|
+
deliverHandshakeLocally = (reason) => {
|
|
629
811
|
if (handshakeAnswered || postUrl !== null)
|
|
630
812
|
return;
|
|
631
813
|
handshakeAnswered = true;
|
|
814
|
+
deliverHandshakeLocally = undefined;
|
|
815
|
+
// The catalog we are about to serve is ours, not the daemon's, so it has to be corrected
|
|
816
|
+
// the moment a real one exists. See the endpoint handler.
|
|
817
|
+
toolsAnnouncedLocally = true;
|
|
632
818
|
// Drop the handshake from the queue. Otherwise the daemon, whenever it finally arrives,
|
|
633
819
|
// answers the SAME id a second time and the client sees two responses to one request — a
|
|
634
820
|
// corrupted stream, which is worse than the hang this replaces. The daemon still gets its own
|
|
@@ -638,16 +824,28 @@ ensureDaemon) {
|
|
|
638
824
|
if (queued !== undefined && isHandshakeLine(queued))
|
|
639
825
|
stdinQueue.splice(i, 1);
|
|
640
826
|
}
|
|
641
|
-
proxyLog('reticle_mcp_local_handshake', { port, reason
|
|
827
|
+
proxyLog('reticle_mcp_local_handshake', { port, reason });
|
|
642
828
|
emit(response);
|
|
643
|
-
}
|
|
829
|
+
};
|
|
830
|
+
setTimeout(() => deliverHandshakeLocally?.('daemon did not answer in time'), LOCAL_HANDSHAKE_MS).unref();
|
|
644
831
|
};
|
|
645
832
|
let stdinBuffer = '';
|
|
833
|
+
let stdinDiscarding = false;
|
|
646
834
|
process.stdin.on('data', (chunk) => {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
835
|
+
// Not `(buffer + chunk).split()` inline — that rescans everything held on every chunk, so one
|
|
836
|
+
// large line cost O(n²) in its own size and pinned the event loop for tens of seconds, during
|
|
837
|
+
// which every other tool call on this link hung with no response and no error. See drainLines.
|
|
838
|
+
const drained = drainLines(stdinBuffer, chunk, MAX_STDIN_LINE_BYTES, stdinDiscarding);
|
|
839
|
+
stdinBuffer = drained.rest;
|
|
840
|
+
// Carried across chunks: an oversized line is dropped, and the REST of it is still arriving.
|
|
841
|
+
// Without this its tail reads as a complete line and gets forwarded as a message nobody sent.
|
|
842
|
+
stdinDiscarding = drained.discarding;
|
|
843
|
+
if (drained.overflowed) {
|
|
844
|
+
// Logged rather than answered: an unparsed line has no id to answer against, and inventing
|
|
845
|
+
// one would put a reply on the wire that no request is waiting for.
|
|
846
|
+
proxyLog('mcp_stdin_line_too_large', { limitBytes: MAX_STDIN_LINE_BYTES });
|
|
847
|
+
}
|
|
848
|
+
for (const line of drained.lines) {
|
|
651
849
|
const trimmed = line.trim();
|
|
652
850
|
if ('' === trimmed)
|
|
653
851
|
continue;
|
|
@@ -684,10 +882,14 @@ ensureDaemon) {
|
|
|
684
882
|
.then(() => connect(false))
|
|
685
883
|
.catch((err) => {
|
|
686
884
|
dormant = true;
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
885
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
886
|
+
proxyLog('reticle_mcp_proxy_wake_failed', { port, error: reason });
|
|
887
|
+
// The wake is the only thing that can start a daemon, so its failure IS the answer to
|
|
888
|
+
// everything it was woken for. It used to be logged and nothing else: the request sat
|
|
889
|
+
// out the full QUEUE_WAIT_MS and was then refused by a timer that could only say "no
|
|
890
|
+
// daemon could be reached" — twenty seconds later, and less true, than the sentence
|
|
891
|
+
// already in hand.
|
|
892
|
+
expireQueue(reason);
|
|
691
893
|
});
|
|
692
894
|
}
|
|
693
895
|
}
|
package/dist/mcp/mcp.d.ts
CHANGED
|
@@ -50,4 +50,13 @@ export declare function advertisedConfig(tool: ToolDef, advertised: readonly Too
|
|
|
50
50
|
outputSchema?: z.ZodRawShape;
|
|
51
51
|
};
|
|
52
52
|
export declare function installFriendlyArgErrors(server: McpServer, examples: Map<string, string>, allowedKeys?: Map<string, ReadonlySet<string>>): void;
|
|
53
|
-
export declare function createMcpServer(deps: ToolDeps, profile?: ToolSurface
|
|
53
|
+
export declare function createMcpServer(deps: ToolDeps, profile?: ToolSurface,
|
|
54
|
+
/**
|
|
55
|
+
* Has an app ever connected for this project? Decides what the instructions LEAD with.
|
|
56
|
+
*
|
|
57
|
+
* Passed in rather than read here, so construction stays free of disk I/O and the decision is
|
|
58
|
+
* testable without a state directory. Defaulting to `false` is the deliberate safe side: an agent
|
|
59
|
+
* told to instrument a project that is already wired loses a paragraph, while an agent not told
|
|
60
|
+
* loses the entire install — and the second is what the field overwhelmingly shows.
|
|
61
|
+
*/
|
|
62
|
+
previouslyConnected?: boolean): McpServer;
|