@reticlehq/server 2.8.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/SKILL.md +130 -813
- 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 +16 -1
- package/dist/capsule/causal-summary.js +2 -1
- package/dist/cli/cli-doctor.js +41 -13
- package/dist/cli/cli-flow-commands.js +8 -8
- package/dist/cli/cli-launch.d.ts +0 -17
- package/dist/cli/cli-launch.js +0 -28
- package/dist/cli/cli-port.d.ts +24 -0
- package/dist/cli/cli-port.js +66 -35
- package/dist/cli/cli-verify.d.ts +21 -0
- package/dist/cli/cli-verify.js +45 -1
- 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.js +6 -3
- 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/status-next-action.d.ts +37 -0
- package/dist/cli/status-next-action.js +61 -0
- package/dist/cli.js +65 -70
- package/dist/events/contradictions.d.ts +32 -0
- package/dist/events/contradictions.js +9 -5
- 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 -91
- package/dist/events/predicate-eval.js +85 -326
- 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 +127 -5
- 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-replay-run.js +8 -1
- package/dist/flows/flow-tools.js +9 -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 +4 -0
- 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 +9 -0
- package/dist/honesty/unsettled.js +7 -3
- package/dist/honesty/verified.d.ts +29 -3
- package/dist/honesty/verified.js +51 -8
- 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 +44 -10
- package/dist/init/agent-rules.d.ts +20 -0
- package/dist/init/agent-rules.js +36 -0
- package/dist/init/astro-patch.js +27 -1
- package/dist/init/confirm.d.ts +21 -3
- package/dist/init/confirm.js +32 -5
- package/dist/init/connect-steps.d.ts +2 -0
- package/dist/init/connect-steps.js +50 -0
- 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/plan-framework.d.ts +10 -0
- package/dist/init/plan-framework.js +25 -1
- package/dist/init/plan.d.ts +7 -2
- package/dist/init/plan.js +66 -30
- package/dist/init/run.js +11 -5
- package/dist/init/slash-command.d.ts +1 -1
- package/dist/init/slash-command.js +12 -4
- package/dist/init/snippets.d.ts +1 -1
- package/dist/init/snippets.js +35 -7
- package/dist/init/vite-config.js +24 -2
- package/dist/input/network-mock-tools.js +8 -1
- 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/mcp/mcp-proxy.d.ts +5 -66
- package/dist/mcp/mcp-proxy.js +180 -163
- package/dist/mcp/mcp.d.ts +10 -1
- package/dist/mcp/mcp.js +47 -27
- package/dist/mcp/proxy-handshake.d.ts +82 -1
- package/dist/mcp/proxy-handshake.js +109 -2
- package/dist/mcp/proxy-lifecycle.d.ts +10 -1
- package/dist/mcp/proxy-lifecycle.js +11 -2
- 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/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 +48 -2
- package/dist/session/no-session-diagnosis.js +176 -20
- 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 +165 -11
- package/dist/session/pending-commands.d.ts +12 -0
- package/dist/session/pending-commands.js +13 -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/bug-found.d.ts +18 -0
- package/dist/telemetry/bug-found.js +14 -0
- package/dist/telemetry/daemon-telemetry.js +24 -0
- package/dist/telemetry/install-source.d.ts +20 -0
- package/dist/telemetry/install-source.js +34 -0
- package/dist/telemetry/instrumentation-stall.d.ts +50 -0
- package/dist/telemetry/instrumentation-stall.js +90 -0
- package/dist/telemetry/session-metrics.d.ts +8 -0
- package/dist/telemetry/session-metrics.js +10 -0
- package/dist/telemetry/telemetry.d.ts +3 -1
- package/dist/telemetry/telemetry.js +13 -2
- package/dist/tools/act-tools.js +166 -29
- package/dist/tools/assert-verdict.js +32 -7
- package/dist/tools/browser-tools.js +10 -0
- package/dist/tools/dynamic-tools.js +16 -7
- package/dist/tools/error-recovery.js +9 -5
- package/dist/tools/invoke-tool.js +20 -3
- package/dist/tools/lease-hint.d.ts +30 -8
- package/dist/tools/lease-hint.js +65 -14
- package/dist/tools/lease-tools.d.ts +11 -0
- package/dist/tools/lease-tools.js +141 -96
- package/dist/tools/observe-tools.js +9 -3
- package/dist/tools/react-grace.d.ts +4 -0
- package/dist/tools/react-grace.js +6 -3
- package/dist/tools/read-tools.js +47 -1
- 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 +15 -0
- package/dist/tools/settle-in-flight.js +42 -2
- package/dist/tools/snapshot-delta.d.ts +3 -0
- package/dist/tools/snapshot-delta.js +67 -6
- package/dist/tools/tool-kit.d.ts +8 -0
- package/dist/tools/tool-surface.d.ts +60 -0
- package/dist/tools/tool-surface.js +85 -19
- package/dist/tools/tools.js +45 -4
- package/dist/tools/unadvertised-help.js +9 -6
- package/docs/README.md +1 -2
- package/docs/actions.mdx +41 -8
- package/docs/agent-cheatsheet.md +4 -2
- package/docs/architecture.md +6 -0
- package/docs/benchmarks.md +60 -12
- package/docs/best-practices.mdx +11 -6
- package/docs/capabilities.mdx +201 -54
- package/docs/cli/affected.mdx +6 -4
- package/docs/cli/capsules.mdx +9 -8
- package/docs/cli/cloud.mdx +30 -4
- package/docs/cli/doctor.mdx +41 -8
- package/docs/cli/drive.mdx +34 -15
- package/docs/cli/gate.mdx +6 -6
- package/docs/cli/help.mdx +8 -6
- package/docs/cli/hunt.mdx +18 -2
- package/docs/cli/identify.mdx +16 -2
- package/docs/cli/init.mdx +35 -8
- package/docs/cli/kill.mdx +5 -5
- package/docs/cli/license.mdx +1 -1
- package/docs/cli/open.mdx +6 -0
- package/docs/cli/restart.mdx +6 -6
- package/docs/cli/serve.mdx +10 -3
- package/docs/cli/status.mdx +29 -5
- package/docs/cli/stop.mdx +8 -0
- package/docs/cli/verify.mdx +20 -1
- package/docs/cli/version.mdx +1 -1
- package/docs/cli/watch.mdx +9 -2
- package/docs/cli.mdx +44 -22
- package/docs/debugging.md +3 -1
- package/docs/deploy-checks.md +30 -1
- package/docs/desktop.mdx +276 -80
- package/docs/docs.json +13 -12
- package/docs/enterprise.md +2 -0
- package/docs/faq.mdx +49 -35
- package/docs/fixtures.md +2 -0
- package/docs/flows.md +3 -1
- package/docs/for-agents.md +3 -3
- package/docs/frameworks.mdx +46 -7
- package/docs/gate-plan.md +4 -2
- package/docs/gates.md +7 -5
- package/docs/getting-started.md +53 -12
- package/docs/human-control.md +2 -0
- package/docs/index.mdx +43 -1
- package/docs/install-agentic.mdx +13 -4
- package/docs/install-manual.mdx +6 -4
- package/docs/instrumentation.mdx +7 -1
- package/docs/integration-patterns.md +3 -1
- package/docs/local-registry.md +4 -2
- package/docs/multi-agent-testing.md +3 -1
- package/docs/packages/babel-plugin.mdx +15 -6
- package/docs/packages/browser.mdx +2 -2
- package/docs/packages/core.mdx +26 -16
- package/docs/packages/electron.mdx +5 -7
- package/docs/packages/eslint-plugin.mdx +5 -3
- package/docs/packages/next.mdx +3 -5
- package/docs/packages/react.mdx +3 -3
- package/docs/packages/server.mdx +2 -2
- package/docs/packages/tauri.mdx +6 -6
- package/docs/packages/test.mdx +18 -7
- package/docs/packages/vite-plugin.mdx +4 -6
- package/docs/packages.mdx +14 -13
- package/docs/platform-integration.md +2 -0
- package/docs/predicates.mdx +76 -7
- package/docs/quickstart.mdx +51 -5
- package/docs/recipes.mdx +62 -22
- package/docs/skill-file.mdx +3 -1
- package/docs/state-management.mdx +2 -0
- package/docs/system-map.md +3 -1
- package/docs/telemetry-contract.md +16 -5
- package/docs/telemetry-events.mdx +63 -18
- package/docs/telemetry.md +8 -5
- package/docs/testing.md +35 -1
- package/docs/token-efficiency.md +8 -2
- package/docs/tools-act-and-wait.mdx +64 -14
- package/docs/tools-act-sequence.mdx +34 -17
- package/docs/tools-act.mdx +24 -11
- package/docs/tools-assert.mdx +69 -10
- package/docs/tools-console.mdx +11 -7
- package/docs/tools-inspect.mdx +11 -8
- package/docs/tools-navigate.mdx +41 -5
- package/docs/tools-network.mdx +42 -9
- package/docs/tools-observe.mdx +66 -10
- package/docs/tools-overview.mdx +26 -23
- package/docs/tools-query.mdx +52 -8
- package/docs/tools-session-and-feedback.mdx +30 -9
- package/docs/tools-sessions.mdx +39 -10
- package/docs/tools-snapshot.mdx +57 -3
- package/docs/tools-state.mdx +24 -1
- package/docs/tools-tools-and-run.mdx +40 -9
- package/docs/tools-wait-for.mdx +34 -15
- package/docs/troubleshooting.mdx +80 -10
- package/docs/usage.md +43 -21
- package/docs/vs-chrome-devtools-mcp.mdx +41 -0
- package/docs/vs-playwright-mcp.mdx +42 -2
- package/docs/vs-screenshots.mdx +42 -1
- package/docs/what-is-reticle.mdx +1 -1
- package/docs/why-reticle.mdx +3 -1
- package/package.json +5 -5
- package/docs/desktop-apps.md +0 -265
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { ReticleDir } from '@reticlehq/core';
|
|
3
|
+
/**
|
|
4
|
+
* The `.reticle/` entries that are LOCAL state, and must never be committed by accident.
|
|
5
|
+
*
|
|
6
|
+
* Split from the durable half deliberately, and the split is the whole design: `contract.json`,
|
|
7
|
+
* `flows/`, `baselines/` and `capsules/` are meant to be checked in — a flow that cannot be shared
|
|
8
|
+
* is not a regression check anybody else can run — while everything below is per-machine churn.
|
|
9
|
+
*
|
|
10
|
+
* The reason this matters beyond tidiness: a session journal carries URLs, request and response
|
|
11
|
+
* bodies, and DOM text from the app under test. `.reticle/` is created by the daemon rather than by
|
|
12
|
+
* the user, so it arrives untracked and unexplained, and one `git add -A` puts an app's traffic into
|
|
13
|
+
* a shared repository. Reticle created the directory; the ignore for it is Reticle's to write.
|
|
14
|
+
*/
|
|
15
|
+
const TRANSIENT = [
|
|
16
|
+
`${ReticleDir.SESSIONS_SUBDIR}/`,
|
|
17
|
+
`${ReticleDir.RUNS_SUBDIR}/`,
|
|
18
|
+
`${ReticleDir.VISUAL_SUBDIR}/`,
|
|
19
|
+
ReticleDir.PROJECT_FILE,
|
|
20
|
+
ReticleDir.AMBIENT_FILE,
|
|
21
|
+
ReticleDir.ENVELOPES_FILE,
|
|
22
|
+
ReticleDir.FLAKE_FILE,
|
|
23
|
+
ReticleDir.TIERS_FILE,
|
|
24
|
+
'*.log',
|
|
25
|
+
'*.tmp',
|
|
26
|
+
];
|
|
27
|
+
const HEADER = [
|
|
28
|
+
'# Written by Reticle, and only about the directory Reticle owns.',
|
|
29
|
+
'#',
|
|
30
|
+
'# The entries below are per-machine state — session journals, run artifacts, learned envelopes.',
|
|
31
|
+
'# Journals contain URLs, request and response bodies and page text from the app under test, so',
|
|
32
|
+
'# they are worth keeping out of a shared repository on their own account.',
|
|
33
|
+
'#',
|
|
34
|
+
`# Deliberately NOT ignored: ${ReticleDir.CONTRACT_FILE}, ${ReticleDir.FLOWS_SUBDIR}/, ${ReticleDir.BASELINES_SUBDIR}/ and`,
|
|
35
|
+
`# ${ReticleDir.CAPSULES_SUBDIR}/. Those are meant to be committed — a saved flow only earns its keep`,
|
|
36
|
+
'# when a teammate or CI can replay it.',
|
|
37
|
+
'#',
|
|
38
|
+
'# Edit freely: Reticle writes this once and never rewrites it.',
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* Write `.reticle/.gitignore` if it is not already there.
|
|
42
|
+
*
|
|
43
|
+
* Never overwrites — once the file exists it belongs to whoever edited it last, and silently
|
|
44
|
+
* reverting somebody's change to a file we placed in their repository is worse than not having
|
|
45
|
+
* written it. Best-effort throughout: this runs on the daemon's start path, and a workspace that
|
|
46
|
+
* cannot be written must not stop a daemon coming up. Nothing about verification depends on it.
|
|
47
|
+
*/
|
|
48
|
+
export async function ensureWorkspaceGitignore(fs, root) {
|
|
49
|
+
const path = join(root, '.gitignore');
|
|
50
|
+
try {
|
|
51
|
+
if (await fs.exists(path))
|
|
52
|
+
return;
|
|
53
|
+
await fs.mkdir(root);
|
|
54
|
+
await fs.writeFile(path, `${[...HEADER, '', ...TRANSIENT].join('\n')}\n`);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* a workspace we cannot write is not a reason to fail a session */
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/mcp/mcp-proxy.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-exported so every existing caller (and every spec) keeps importing the proxy's log from the
|
|
3
|
+
* proxy. Where the code lives is a file-size decision; where it is imported from is an API.
|
|
4
|
+
*/
|
|
5
|
+
export { accountProxyLogWrite, proxyLog, proxyLogPath, PROXY_LOG_CHECK_BYTES, recoverOversizedProxyLog, setProxyLogPort, } from './proxy-log.js';
|
|
1
6
|
/**
|
|
2
7
|
* Overridable so the survival spec can reach exhaustion in seconds instead of minutes — the same
|
|
3
8
|
* reason the idle windows take an env override. A positive integer only; anything else falls back,
|
|
@@ -7,62 +12,6 @@ export declare const MAX_RECONNECT_ATTEMPTS: number;
|
|
|
7
12
|
export declare function reconnectDelayMs(attempt: number): number;
|
|
8
13
|
/** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
|
|
9
14
|
export declare const RECONNECT_INITIALIZE_ID = "__reticle_proxy_reinit";
|
|
10
|
-
/**
|
|
11
|
-
* How much the proxy may append between size checks.
|
|
12
|
-
*
|
|
13
|
-
* Statting the file on every line would be a syscall per log line on a process that logs from a
|
|
14
|
-
* reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
|
|
15
|
-
* the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
|
|
16
|
-
*/
|
|
17
|
-
export declare const PROXY_LOG_CHECK_BYTES: number;
|
|
18
|
-
/**
|
|
19
|
-
* Keep the proxy log under the same cap as the daemon log, without a stat per write.
|
|
20
|
-
*
|
|
21
|
-
* Returns the new "bytes since the last check" counter, so the accounting is testable without a
|
|
22
|
-
* filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
|
|
23
|
-
* daemon log on the SAME machine stayed small, which is the whole of this defect.
|
|
24
|
-
*/
|
|
25
|
-
export declare function accountProxyLogWrite(bytesSinceCheck: number, written: number, path: string, deps: {
|
|
26
|
-
fileSize(p: string): number;
|
|
27
|
-
renameFile(from: string, to: string): void;
|
|
28
|
-
}): number;
|
|
29
|
-
/**
|
|
30
|
-
* Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
|
|
31
|
-
*
|
|
32
|
-
* Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
|
|
33
|
-
* builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
|
|
34
|
-
* failure surfaced as the operating system refusing to write files. Files like that exist on real
|
|
35
|
-
* machines now, and the cap alone does not help them: the user should not have to find one with `du`.
|
|
36
|
-
*
|
|
37
|
-
* TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
|
|
38
|
-
* and a file that a running process still holds open keeps its blocks allocated after an unlink
|
|
39
|
-
* until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
|
|
40
|
-
* and why that is the operation to copy here.
|
|
41
|
-
*
|
|
42
|
-
* Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
|
|
43
|
-
* housekeeping is strictly worse than a large file.
|
|
44
|
-
*/
|
|
45
|
-
export declare function recoverOversizedProxyLog(path: string, deps: {
|
|
46
|
-
fileSize(p: string): number;
|
|
47
|
-
truncateFile(p: string): void;
|
|
48
|
-
}, max?: number): number;
|
|
49
|
-
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
50
|
-
export declare function setProxyLogPort(port: number): void;
|
|
51
|
-
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
52
|
-
export declare function proxyLogPath(port?: number | undefined): string;
|
|
53
|
-
/**
|
|
54
|
-
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
|
|
55
|
-
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
56
|
-
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
57
|
-
*
|
|
58
|
-
* EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
|
|
59
|
-
* logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
|
|
60
|
-
* present to a human as "the MCP server disconnected" — were written to a stream the editor throws
|
|
61
|
-
* away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
|
|
62
|
-
* proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
|
|
63
|
-
* existed, and the one path that most needed it was not using it.
|
|
64
|
-
*/
|
|
65
|
-
export declare function proxyLog(event: string, fields?: Record<string, unknown>): void;
|
|
66
15
|
/**
|
|
67
16
|
* JSON-RPC requests forwarded on behalf of the client that have not been answered yet.
|
|
68
17
|
*
|
|
@@ -181,15 +130,6 @@ export declare function waitForDaemon(port: number): Promise<void>;
|
|
|
181
130
|
* to reconnect and how to answer what it cannot send.
|
|
182
131
|
*/
|
|
183
132
|
export declare function buildSessionUrl(rawData: string, port: number): string | null;
|
|
184
|
-
/**
|
|
185
|
-
* Bridge stdio ↔ SSE: connects to the running daemon's MCP endpoint and forwards
|
|
186
|
-
* Claude Code's stdin/stdout JSON-RPC through it. Never resolves — runs until stdin closes.
|
|
187
|
-
*
|
|
188
|
-
* A dropped SSE stream used to exit the process, which is what made the agent's `reticle_*` tools
|
|
189
|
-
* vanish mid-session with no message and no way for the agent to get them back — only a human running
|
|
190
|
-
* `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
|
|
191
|
-
* transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
|
|
192
|
-
*/
|
|
193
133
|
export declare function startMcpProxy(port: number,
|
|
194
134
|
/**
|
|
195
135
|
* Bring a daemon back before reconnecting. Without it the proxy retried a DEAD port until the
|
|
@@ -197,4 +137,3 @@ export declare function startMcpProxy(port: number,
|
|
|
197
137
|
* failure mode for a daemon that crashed, was stopped, or shut itself down as idle.
|
|
198
138
|
*/
|
|
199
139
|
ensureDaemon?: () => Promise<void>): Promise<never>;
|
|
200
|
-
export {};
|
package/dist/mcp/mcp-proxy.js
CHANGED
|
@@ -1,17 +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 {
|
|
14
|
-
import { OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
28
|
+
import { OutageReason, OutageStage, reportMcpOutage } from './mcp-outage.js';
|
|
15
29
|
const DEFAULT_DAEMON_READY_TIMEOUT_MS = 10_000;
|
|
16
30
|
/**
|
|
17
31
|
* How long to wait for the spawned daemon's port to accept connections before giving up. The default
|
|
@@ -52,125 +66,6 @@ export function reconnectDelayMs(attempt) {
|
|
|
52
66
|
}
|
|
53
67
|
/** JSON-RPC id the proxy uses for its own replayed `initialize` — never one the client could send. */
|
|
54
68
|
export const RECONNECT_INITIALIZE_ID = '__reticle_proxy_reinit';
|
|
55
|
-
/**
|
|
56
|
-
* Which port this proxy serves, for the log file name. Set once at startup.
|
|
57
|
-
*
|
|
58
|
-
* One file per port, matching `daemon-<port>.log`. A single shared file interleaved every proxy on
|
|
59
|
-
* the machine into one stream, so the first question anyone asks of it — "what happened to MY
|
|
60
|
-
* session?" — needed a filter before it could be answered.
|
|
61
|
-
*/
|
|
62
|
-
let logPort;
|
|
63
|
-
/**
|
|
64
|
-
* How much the proxy may append between size checks.
|
|
65
|
-
*
|
|
66
|
-
* Statting the file on every line would be a syscall per log line on a process that logs from a
|
|
67
|
-
* reconnect loop. Counting what we wrote costs nothing and is exact enough, because this process is
|
|
68
|
-
* the only writer of its own log. Well under the cap, so the cap is a cap rather than a suggestion.
|
|
69
|
-
*/
|
|
70
|
-
export const PROXY_LOG_CHECK_BYTES = 256 * 1024;
|
|
71
|
-
/** Appended since the last real size check. Reset by every check, whatever the check decides. */
|
|
72
|
-
let bytesSinceProxyLogCheck = 0;
|
|
73
|
-
/**
|
|
74
|
-
* Keep the proxy log under the same cap as the daemon log, without a stat per write.
|
|
75
|
-
*
|
|
76
|
-
* Returns the new "bytes since the last check" counter, so the accounting is testable without a
|
|
77
|
-
* filesystem. Rotation itself is `rotateDaemonLog` — the proxy log had no cap at all while the
|
|
78
|
-
* daemon log on the SAME machine stayed small, which is the whole of this defect.
|
|
79
|
-
*/
|
|
80
|
-
export function accountProxyLogWrite(bytesSinceCheck, written, path, deps) {
|
|
81
|
-
const total = bytesSinceCheck + written;
|
|
82
|
-
if (PROXY_LOG_CHECK_BYTES > total)
|
|
83
|
-
return total;
|
|
84
|
-
rotateDaemonLog(path, deps);
|
|
85
|
-
return 0;
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Reclaim a proxy log that a previous process already let run away. Returns the bytes reclaimed.
|
|
89
|
-
*
|
|
90
|
-
* Reported from the field: one machine's `proxy-4400.log` reached a third of a 460GB disk and broke
|
|
91
|
-
* builds, Docker and ordinary shell commands with ENOSPC. Nothing in Reticle degraded first, so the
|
|
92
|
-
* failure surfaced as the operating system refusing to write files. Files like that exist on real
|
|
93
|
-
* machines now, and the cap alone does not help them: the user should not have to find one with `du`.
|
|
94
|
-
*
|
|
95
|
-
* TRUNCATED IN PLACE, never renamed or unlinked. A rename moves the bytes without reclaiming a byte,
|
|
96
|
-
* and a file that a running process still holds open keeps its blocks allocated after an unlink
|
|
97
|
-
* until that handle closes — which is why the reporter recovered with `: > ~/.reticle/proxy-4400.log`
|
|
98
|
-
* and why that is the operation to copy here.
|
|
99
|
-
*
|
|
100
|
-
* Best-effort, in the same spirit as `rotateDaemonLog`: refusing to start the MCP server over
|
|
101
|
-
* housekeeping is strictly worse than a large file.
|
|
102
|
-
*/
|
|
103
|
-
export function recoverOversizedProxyLog(path, deps, max = MAX_DAEMON_LOG_BYTES) {
|
|
104
|
-
try {
|
|
105
|
-
const size = deps.fileSize(path);
|
|
106
|
-
if (max >= size)
|
|
107
|
-
return 0;
|
|
108
|
-
deps.truncateFile(path);
|
|
109
|
-
return size;
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
return 0;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
/** The real filesystem behind the two rotation helpers. */
|
|
116
|
-
const proxyLogFileOps = {
|
|
117
|
-
fileSize: (p) => statSync(p).size,
|
|
118
|
-
renameFile: (from, to) => renameSync(from, to),
|
|
119
|
-
truncateFile: (p) => truncateSync(p, 0),
|
|
120
|
-
};
|
|
121
|
-
/** Name the proxy log after the port it serves. Called before anything can log. */
|
|
122
|
-
export function setProxyLogPort(port) {
|
|
123
|
-
logPort = port;
|
|
124
|
-
// The one moment we know a size check is worth its syscall, and the only place a file inherited
|
|
125
|
-
// from an older build can be brought back under control.
|
|
126
|
-
const reclaimed = recoverOversizedProxyLog(proxyLogPath(port), proxyLogFileOps);
|
|
127
|
-
if (0 < reclaimed) {
|
|
128
|
-
proxyLog('reticle_mcp_proxy_log_truncated', {
|
|
129
|
-
port,
|
|
130
|
-
reclaimedBytes: reclaimed,
|
|
131
|
-
note: 'the proxy log had grown past its cap and was truncated in place to reclaim the space',
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
/** The proxy's own log file, so a silent drop leaves a readable trace the agent can go read. */
|
|
136
|
-
export function proxyLogPath(port = logPort) {
|
|
137
|
-
const name = port === undefined ? 'mcp-proxy.log' : `proxy-${String(port)}.log`;
|
|
138
|
-
return join(homedir(), ReticleDir.ROOT, name);
|
|
139
|
-
}
|
|
140
|
-
/**
|
|
141
|
-
* Log to stderr (which the agent host usually swallows) AND to ~/.reticle/proxy-<port>.log, which it
|
|
142
|
-
* does not. A dropped MCP connection is invisible from the agent's side — no message, no exit code —
|
|
143
|
-
* so the one thing that makes it diagnosable at all is a file somebody can read afterwards.
|
|
144
|
-
*
|
|
145
|
-
* EXPORTED because the crash handlers need it. `installProxyResilience` was wired to the bare stderr
|
|
146
|
-
* logger, so the proxy's own uncaught exceptions and unhandled rejections — the exact events that
|
|
147
|
-
* present to a human as "the MCP server disconnected" — were written to a stream the editor throws
|
|
148
|
-
* away. The handler ran, the process kept serving, and the reason went nowhere. Reported as "the
|
|
149
|
-
* proxy has no log file… when it dies the diagnostic dies with it", which was half right: the file
|
|
150
|
-
* existed, and the one path that most needed it was not using it.
|
|
151
|
-
*/
|
|
152
|
-
export function proxyLog(event, fields = {}) {
|
|
153
|
-
log(event, fields);
|
|
154
|
-
try {
|
|
155
|
-
const dir = join(homedir(), ReticleDir.ROOT);
|
|
156
|
-
mkdirSync(dir, { recursive: true });
|
|
157
|
-
// WITH A TIMESTAMP. This file is the only record of an outage that survives the session, and
|
|
158
|
-
// without one it cannot answer the two questions anyone brings to it: when did this happen, and
|
|
159
|
-
// how often. A real 3,283-line log on a dev machine held a `gave_up` — the exact event that used
|
|
160
|
-
// to precede `process.exit(1)` and cost the human a manual /mcp reconnect — and it was
|
|
161
|
-
// impossible to tell whether it happened during that person's work or a test run hours earlier.
|
|
162
|
-
// Evidence you cannot place in time is an anecdote.
|
|
163
|
-
const path = proxyLogPath();
|
|
164
|
-
const line = `${JSON.stringify({ t: new Date().toISOString(), event, ...fields })}\n`;
|
|
165
|
-
appendFileSync(path, line, 'utf8');
|
|
166
|
-
// A single proxy process is long-lived and logs from a reconnect loop, so rotating only at
|
|
167
|
-
// startup would leave the growth this file is capable of entirely unchecked while it runs.
|
|
168
|
-
bytesSinceProxyLogCheck = accountProxyLogWrite(bytesSinceProxyLogCheck, Buffer.byteLength(line, 'utf8'), path, proxyLogFileOps);
|
|
169
|
-
}
|
|
170
|
-
catch {
|
|
171
|
-
// Logging must never be the thing that kills the proxy.
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
69
|
function parseJsonRpc(line) {
|
|
175
70
|
try {
|
|
176
71
|
const parsed = JSON.parse(line);
|
|
@@ -510,6 +405,24 @@ export function buildSessionUrl(rawData, port) {
|
|
|
510
405
|
* `/mcp` could. The daemon demonstrably stays up across these drops, so the stream ending is a
|
|
511
406
|
* transport event, not a shutdown: reconnect to it and replay the handshake instead of dying.
|
|
512
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
|
+
}
|
|
513
426
|
export function startMcpProxy(port,
|
|
514
427
|
/**
|
|
515
428
|
* Bring a daemon back before reconnecting. Without it the proxy retried a DEAD port until the
|
|
@@ -531,6 +444,16 @@ ensureDaemon) {
|
|
|
531
444
|
* Dormant means the next thing the CLIENT asks for is what brings Reticle back.
|
|
532
445
|
*/
|
|
533
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;
|
|
534
457
|
const stdinQueue = [];
|
|
535
458
|
/**
|
|
536
459
|
* Fail anything still queued after QUEUE_WAIT_MS. Re-armed on every push and cleared on flush,
|
|
@@ -542,29 +465,42 @@ ensureDaemon) {
|
|
|
542
465
|
clearTimeout(queueTimer);
|
|
543
466
|
queueTimer = undefined;
|
|
544
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
|
+
};
|
|
545
498
|
const armQueueTimer = () => {
|
|
546
499
|
if (queueTimer !== undefined)
|
|
547
500
|
return; // the oldest entry owns the deadline
|
|
548
501
|
queueTimer = setTimeout(() => {
|
|
549
502
|
queueTimer = undefined;
|
|
550
|
-
|
|
551
|
-
return;
|
|
552
|
-
// Drop what we could never send, and answer it, so the caller is never left guessing.
|
|
553
|
-
stdinQueue.splice(0);
|
|
554
|
-
for (const reply of streamLossReplies(pending, 'no daemon could be reached', queueExpiryNextStep(port))) {
|
|
555
|
-
emit(reply);
|
|
556
|
-
}
|
|
557
|
-
// The expiry is the proof that the reconnect we believed was in flight is not coming — a
|
|
558
|
-
// wedged port accepts the socket and never serves SSE, so `connect` neither resolves nor
|
|
559
|
-
// rejects and `dormant` would otherwise stay false forever. Going dormant is what makes the
|
|
560
|
-
// NEXT request re-probe the port instead of queueing behind a reconnect that will never
|
|
561
|
-
// land, and it is the only path by which a daemon started AFTER we gave up is ever found.
|
|
562
|
-
dormant = onQueueExpired() === OnDrop.DORMANT;
|
|
563
|
-
proxyLog('reticle_mcp_proxy_queue_expired', {
|
|
564
|
-
port,
|
|
565
|
-
dormant,
|
|
566
|
-
note: 'answered queued requests and went dormant; the next client request re-probes the port',
|
|
567
|
-
});
|
|
503
|
+
expireQueue('no daemon could be reached');
|
|
568
504
|
}, QUEUE_WAIT_MS);
|
|
569
505
|
queueTimer.unref();
|
|
570
506
|
};
|
|
@@ -577,6 +513,10 @@ ensureDaemon) {
|
|
|
577
513
|
};
|
|
578
514
|
let stopped = false;
|
|
579
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;
|
|
580
520
|
/**
|
|
581
521
|
* When the proxy started, and how many times the first connect has been refused since.
|
|
582
522
|
*
|
|
@@ -623,6 +563,10 @@ ensureDaemon) {
|
|
|
623
563
|
// headers, is what earns a fresh retry budget. Resetting on headers let a daemon that
|
|
624
564
|
// accepts SSE and drops it spin the reconnect loop at the 250ms floor forever, never backing
|
|
625
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 });
|
|
626
570
|
attempts = 0;
|
|
627
571
|
// The new session's McpServer has never seen the client's initialize — replay it first, then
|
|
628
572
|
// flush whatever the client sent while we were reconnecting.
|
|
@@ -631,9 +575,26 @@ ensureDaemon) {
|
|
|
631
575
|
clearQueueTimer();
|
|
632
576
|
for (const queued of stdinQueue.splice(0))
|
|
633
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
|
+
}
|
|
634
585
|
handshakeAnswered = true;
|
|
635
586
|
return;
|
|
636
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
|
+
});
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
637
598
|
if ('message' === event && !replay.shouldSuppressInbound(data)) {
|
|
638
599
|
// Remember the tool catalog as it goes past: it is what makes a locally-answered handshake
|
|
639
600
|
// useful rather than toolless. See tool-catalog-cache.
|
|
@@ -641,10 +602,16 @@ ensureDaemon) {
|
|
|
641
602
|
emit(data);
|
|
642
603
|
}
|
|
643
604
|
}
|
|
644
|
-
function scheduleReconnect(
|
|
605
|
+
function scheduleReconnect(rawReason, detail) {
|
|
645
606
|
if (stopped)
|
|
646
607
|
return;
|
|
647
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;
|
|
648
615
|
attempts++;
|
|
649
616
|
// Everything the dead session owed is settled HERE, in the one place every drop funnels
|
|
650
617
|
// through, and not in the response callback that first noticed the socket die. A reset emits
|
|
@@ -695,16 +662,22 @@ ensureDaemon) {
|
|
|
695
662
|
// shut itself down as idle would be respawned instantly, sit unused, shut down again, and
|
|
696
663
|
// loop forever — measured at four processes in 200s before this. If nothing is listening the
|
|
697
664
|
// proxy goes DORMANT and the next client request brings a daemon back (see the stdin reader).
|
|
698
|
-
void
|
|
699
|
-
if (onStreamDrop(
|
|
665
|
+
void probePresence(port, { tcpOpen: probeDaemon, status: fetchDaemonStatus }).then((presence) => {
|
|
666
|
+
if (onStreamDrop(presence) === OnDrop.REATTACH) {
|
|
700
667
|
connect(false);
|
|
701
668
|
return;
|
|
702
669
|
}
|
|
703
670
|
dormant = true;
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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);
|
|
708
681
|
});
|
|
709
682
|
}, wait).unref();
|
|
710
683
|
}
|
|
@@ -797,6 +770,14 @@ ensureDaemon) {
|
|
|
797
770
|
// ── stdin reader ─────────────────────────────────────────────────────────
|
|
798
771
|
process.stdin.setEncoding('utf8');
|
|
799
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;
|
|
800
781
|
/**
|
|
801
782
|
* Answer a queued `initialize` locally if the daemon has not produced an endpoint in time.
|
|
802
783
|
*
|
|
@@ -807,13 +788,33 @@ ensureDaemon) {
|
|
|
807
788
|
const armLocalHandshake = (line) => {
|
|
808
789
|
if (handshakeAnswered)
|
|
809
790
|
return;
|
|
810
|
-
|
|
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));
|
|
811
794
|
if (null === response)
|
|
812
795
|
return;
|
|
813
|
-
|
|
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) => {
|
|
814
811
|
if (handshakeAnswered || postUrl !== null)
|
|
815
812
|
return;
|
|
816
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;
|
|
817
818
|
// Drop the handshake from the queue. Otherwise the daemon, whenever it finally arrives,
|
|
818
819
|
// answers the SAME id a second time and the client sees two responses to one request — a
|
|
819
820
|
// corrupted stream, which is worse than the hang this replaces. The daemon still gets its own
|
|
@@ -823,16 +824,28 @@ ensureDaemon) {
|
|
|
823
824
|
if (queued !== undefined && isHandshakeLine(queued))
|
|
824
825
|
stdinQueue.splice(i, 1);
|
|
825
826
|
}
|
|
826
|
-
proxyLog('reticle_mcp_local_handshake', { port, reason
|
|
827
|
+
proxyLog('reticle_mcp_local_handshake', { port, reason });
|
|
827
828
|
emit(response);
|
|
828
|
-
}
|
|
829
|
+
};
|
|
830
|
+
setTimeout(() => deliverHandshakeLocally?.('daemon did not answer in time'), LOCAL_HANDSHAKE_MS).unref();
|
|
829
831
|
};
|
|
830
832
|
let stdinBuffer = '';
|
|
833
|
+
let stdinDiscarding = false;
|
|
831
834
|
process.stdin.on('data', (chunk) => {
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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) {
|
|
836
849
|
const trimmed = line.trim();
|
|
837
850
|
if ('' === trimmed)
|
|
838
851
|
continue;
|
|
@@ -869,10 +882,14 @@ ensureDaemon) {
|
|
|
869
882
|
.then(() => connect(false))
|
|
870
883
|
.catch((err) => {
|
|
871
884
|
dormant = true;
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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);
|
|
876
893
|
});
|
|
877
894
|
}
|
|
878
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;
|